Browse Source

Merge pull request #843 from AppFlowy-IO/feat/board_card

Board: Support display text/number/url/single-select/muti-select/date card style
Nathan.fooo 2 năm trước cách đây
mục cha
commit
6b0becd9ca
47 tập tin đã thay đổi với 1356 bổ sung189 xóa
  1. 32 13
      frontend/app_flowy/lib/plugins/board/application/board_bloc.dart
  2. 45 5
      frontend/app_flowy/lib/plugins/board/application/board_data_controller.dart
  3. 71 0
      frontend/app_flowy/lib/plugins/board/application/card/board_checkbox_cell_bloc.dart
  4. 85 0
      frontend/app_flowy/lib/plugins/board/application/card/board_date_cell_bloc.dart
  5. 67 0
      frontend/app_flowy/lib/plugins/board/application/card/board_number_cell_bloc.dart
  6. 78 0
      frontend/app_flowy/lib/plugins/board/application/card/board_url_cell_bloc.dart
  7. 111 0
      frontend/app_flowy/lib/plugins/board/application/card/card_bloc.dart
  8. 49 0
      frontend/app_flowy/lib/plugins/board/application/card/card_data_controller.dart
  9. 27 4
      frontend/app_flowy/lib/plugins/board/presentation/board_page.dart
  10. 59 0
      frontend/app_flowy/lib/plugins/board/presentation/card/board_checkbox_cell.dart
  11. 59 0
      frontend/app_flowy/lib/plugins/board/presentation/card/board_date_cell.dart
  12. 59 0
      frontend/app_flowy/lib/plugins/board/presentation/card/board_number_cell.dart
  13. 10 4
      frontend/app_flowy/lib/plugins/board/presentation/card/board_select_option_cell.dart
  14. 11 4
      frontend/app_flowy/lib/plugins/board/presentation/card/board_text_cell.dart
  15. 64 0
      frontend/app_flowy/lib/plugins/board/presentation/card/board_url_cell.dart
  16. 77 5
      frontend/app_flowy/lib/plugins/board/presentation/card/card.dart
  17. 69 0
      frontend/app_flowy/lib/plugins/board/presentation/card/card_cell_builder.dart
  18. 133 0
      frontend/app_flowy/lib/plugins/board/presentation/card/card_container.dart
  19. 6 4
      frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/context_builder.dart
  20. 1 1
      frontend/app_flowy/lib/plugins/grid/application/cell/checkbox_cell_bloc.dart
  21. 1 1
      frontend/app_flowy/lib/plugins/grid/application/cell/number_cell_bloc.dart
  22. 1 1
      frontend/app_flowy/lib/plugins/grid/application/grid_data_controller.dart
  23. 2 2
      frontend/app_flowy/lib/plugins/grid/application/row/row_action_sheet_bloc.dart
  24. 8 8
      frontend/app_flowy/lib/plugins/grid/application/row/row_bloc.dart
  25. 1 5
      frontend/app_flowy/lib/plugins/grid/application/row/row_data_controller.dart
  26. 2 2
      frontend/app_flowy/lib/plugins/grid/application/row/row_service.dart
  27. 2 12
      frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_accessory.dart
  28. 12 0
      frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_builder.dart
  29. 20 16
      frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_container.dart
  30. 2 1
      frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/checkbox_cell.dart
  31. 4 3
      frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/extension.dart
  32. 23 20
      frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/select_option_cell.dart
  33. 4 2
      frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/text_field.dart
  34. 2 2
      frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/url_cell/url_cell.dart
  35. 16 17
      frontend/app_flowy/lib/plugins/grid/presentation/widgets/row/grid_row.dart
  36. 8 7
      frontend/app_flowy/packages/appflowy_board/example/lib/multi_board_list_example.dart
  37. 5 1
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board.dart
  38. 9 8
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_column/board_column_data.dart
  39. 5 5
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_data.dart
  40. 1 1
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_phantom/phantom_controller.dart
  41. 5 5
      frontend/rust-lib/flowy-folder/tests/workspace/script.rs
  42. 4 4
      frontend/rust-lib/flowy-grid/src/services/field/type_options/selection_type_option/multi_select_type_option.rs
  43. 2 2
      frontend/rust-lib/flowy-grid/src/services/field/type_options/text_type_option/text_type_option.rs
  44. 22 7
      frontend/rust-lib/flowy-grid/src/services/row/row_builder.rs
  45. 74 3
      frontend/rust-lib/flowy-grid/src/util.rs
  46. 5 11
      frontend/rust-lib/flowy-grid/tests/grid/block_test/util.rs
  47. 3 3
      frontend/rust-lib/flowy-grid/tests/grid/grid_editor.rs

+ 32 - 13
frontend/app_flowy/lib/plugins/board/application/board_bloc.dart

@@ -1,5 +1,6 @@
 import 'dart:async';
 import 'package:app_flowy/plugins/grid/application/block/block_cache.dart';
+import 'package:app_flowy/plugins/grid/application/field/field_cache.dart';
 import 'package:app_flowy/plugins/grid/application/row/row_cache.dart';
 import 'package:appflowy_board/appflowy_board.dart';
 import 'package:dartz/dartz.dart';
@@ -20,6 +21,9 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
   final BoardDataController _dataController;
   late final AFBoardDataController boardDataController;
 
+  GridFieldCache get fieldCache => _dataController.fieldCache;
+  String get gridId => _dataController.gridId;
+
   BoardBloc({required ViewPB view})
       : _dataController = BoardDataController(view: view),
         super(BoardState.initial(view.id)) {
@@ -57,6 +61,9 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
           didReceiveGroups: (List<GroupPB> groups) {
             emit(state.copyWith(groups: groups));
           },
+          didReceiveRows: (List<RowInfo> rowInfos) {
+            emit(state.copyWith(rowInfos: rowInfos));
+          },
         );
       },
     );
@@ -68,7 +75,7 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
     return super.close();
   }
 
-  GridRowCache? getRowCache(String blockId, String rowId) {
+  GridRowCache? getRowCache(String blockId) {
     final GridBlockCache? blockCache = _dataController.blocks[blockId];
     return blockCache?.rowCache;
   }
@@ -92,24 +99,29 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
 
         boardDataController.addColumns(columns);
       },
+      onRowsChanged: (List<RowInfo> rowInfos, RowChangeReason reason) {
+        add(BoardEvent.didReceiveRows(rowInfos));
+      },
       onError: (err) {
         Log.error(err);
       },
     );
   }
 
-  List<BoardColumnItem> _buildRows(List<RowPB> rows) {
-    return rows.map((row) {
-      final rowInfo = RowInfo(
-        gridId: _dataController.gridId,
-        blockId: row.blockId,
-        id: row.id,
-        fields: _dataController.fieldCache.unmodifiableFields,
-        height: row.height.toDouble(),
-        rawRow: row,
-      );
-      return BoardColumnItem(row: rowInfo);
+  List<AFColumnItem> _buildRows(List<RowPB> rows) {
+    final items = rows.map((row) {
+      // final rowInfo = RowInfo(
+      //   gridId: _dataController.gridId,
+      //   blockId: row.blockId,
+      //   id: row.id,
+      //   fields: _dataController.fieldCache.unmodifiableFields,
+      //   height: row.height.toDouble(),
+      //   rawRow: row,
+      // );
+      return BoardColumnItem(row: row);
     }).toList();
+
+    return <AFColumnItem>[...items];
   }
 
   Future<void> _loadGrid(Emitter<BoardState> emit) async {
@@ -131,6 +143,8 @@ class BoardEvent with _$BoardEvent {
   const factory BoardEvent.createRow() = _CreateRow;
   const factory BoardEvent.didReceiveGroups(List<GroupPB> groups) =
       _DidReceiveGroup;
+  const factory BoardEvent.didReceiveRows(List<RowInfo> rowInfos) =
+      _DidReceiveRows;
   const factory BoardEvent.didReceiveGridUpdate(
     GridPB grid,
   ) = _DidReceiveGridUpdate;
@@ -186,10 +200,15 @@ class GridFieldEquatable extends Equatable {
 }
 
 class BoardColumnItem extends AFColumnItem {
-  final RowInfo row;
+  final RowPB row;
 
   BoardColumnItem({required this.row});
 
   @override
   String get id => row.id;
 }
+
+class CreateCardItem extends AFColumnItem {
+  @override
+  String get id => '$CreateCardItem';
+}

+ 45 - 5
frontend/app_flowy/lib/plugins/board/application/board_data_controller.dart

@@ -3,6 +3,8 @@ import 'dart:collection';
 import 'package:app_flowy/plugins/grid/application/block/block_cache.dart';
 import 'package:app_flowy/plugins/grid/application/field/field_cache.dart';
 import 'package:app_flowy/plugins/grid/application/grid_service.dart';
+import 'package:app_flowy/plugins/grid/application/row/row_cache.dart';
+import 'package:flowy_sdk/log.dart';
 import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart';
 import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart';
 import 'dart:async';
@@ -12,6 +14,10 @@ import 'package:flowy_sdk/protobuf/flowy-grid/protobuf.dart';
 typedef OnFieldsChanged = void Function(UnmodifiableListView<FieldPB>);
 typedef OnGridChanged = void Function(GridPB);
 typedef OnGroupChanged = void Function(List<GroupPB>);
+typedef OnRowsChanged = void Function(
+  List<RowInfo> rowInfos,
+  RowChangeReason,
+);
 typedef OnError = void Function(FlowyError);
 
 class BoardDataController {
@@ -21,17 +27,25 @@ class BoardDataController {
 
   // key: the block id
   final LinkedHashMap<String, GridBlockCache> _blocks;
-  UnmodifiableMapView<String, GridBlockCache> get blocks =>
-      UnmodifiableMapView(_blocks);
+  LinkedHashMap<String, GridBlockCache> get blocks => _blocks;
 
   OnFieldsChanged? _onFieldsChanged;
   OnGridChanged? _onGridChanged;
   OnGroupChanged? _onGroupChanged;
+  OnRowsChanged? _onRowsChanged;
   OnError? _onError;
 
+  List<RowInfo> get rowInfos {
+    final List<RowInfo> rows = [];
+    for (var block in _blocks.values) {
+      rows.addAll(block.rows);
+    }
+    return rows;
+  }
+
   BoardDataController({required ViewPB view})
       : gridId = view.id,
-        _blocks = LinkedHashMap.identity(),
+        _blocks = LinkedHashMap.new(),
         _gridFFIService = GridService(gridId: view.id),
         fieldCache = GridFieldCache(gridId: view.id);
 
@@ -39,11 +53,13 @@ class BoardDataController {
     OnGridChanged? onGridChanged,
     OnFieldsChanged? onFieldsChanged,
     OnGroupChanged? onGroupChanged,
+    OnRowsChanged? onRowsChanged,
     OnError? onError,
   }) {
     _onGridChanged = onGridChanged;
     _onFieldsChanged = onFieldsChanged;
     _onGroupChanged = onGroupChanged;
+    _onRowsChanged = onRowsChanged;
     _onError = onError;
 
     fieldCache.addListener(onFields: (fields) {
@@ -57,6 +73,7 @@ class BoardDataController {
       () => result.fold(
         (grid) async {
           _onGridChanged?.call(grid);
+          _initialBlocks(grid.blocks);
           return await _loadFields(grid).then((result) {
             return result.fold(
               (l) {
@@ -72,8 +89,8 @@ class BoardDataController {
     );
   }
 
-  void createRow() {
-    _gridFFIService.createRow();
+  Future<Either<RowPB, FlowyError>> createRow() {
+    return _gridFFIService.createRow();
   }
 
   Future<void> dispose() async {
@@ -85,6 +102,29 @@ class BoardDataController {
     }
   }
 
+  void _initialBlocks(List<BlockPB> blocks) {
+    for (final block in blocks) {
+      if (_blocks[block.id] != null) {
+        Log.warn("Initial duplicate block's cache: ${block.id}");
+        return;
+      }
+
+      final cache = GridBlockCache(
+        gridId: gridId,
+        block: block,
+        fieldCache: fieldCache,
+      );
+
+      cache.addListener(
+        onChangeReason: (reason) {
+          _onRowsChanged?.call(rowInfos, reason);
+        },
+      );
+
+      _blocks[block.id] = cache;
+    }
+  }
+
   Future<Either<Unit, FlowyError>> _loadFields(GridPB grid) async {
     final result = await _gridFFIService.getFields(fieldIds: grid.fields);
     return Future(

+ 71 - 0
frontend/app_flowy/lib/plugins/board/application/card/board_checkbox_cell_bloc.dart

@@ -0,0 +1,71 @@
+import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+import 'dart:async';
+
+part 'board_checkbox_cell_bloc.freezed.dart';
+
+class BoardCheckboxCellBloc
+    extends Bloc<BoardCheckboxCellEvent, BoardCheckboxCellState> {
+  final GridCheckboxCellController cellController;
+  void Function()? _onCellChangedFn;
+  BoardCheckboxCellBloc({
+    required this.cellController,
+  }) : super(BoardCheckboxCellState.initial(cellController)) {
+    on<BoardCheckboxCellEvent>(
+      (event, emit) async {
+        await event.when(
+          initial: () async {
+            _startListening();
+          },
+          didReceiveCellUpdate: (cellData) {
+            emit(state.copyWith(isSelected: _isSelected(cellData)));
+          },
+        );
+      },
+    );
+  }
+
+  @override
+  Future<void> close() async {
+    if (_onCellChangedFn != null) {
+      cellController.removeListener(_onCellChangedFn!);
+      _onCellChangedFn = null;
+    }
+    cellController.dispose();
+    return super.close();
+  }
+
+  void _startListening() {
+    _onCellChangedFn = cellController.startListening(
+      onCellChanged: ((cellContent) {
+        if (!isClosed) {
+          add(BoardCheckboxCellEvent.didReceiveCellUpdate(cellContent ?? ""));
+        }
+      }),
+    );
+  }
+}
+
+@freezed
+class BoardCheckboxCellEvent with _$BoardCheckboxCellEvent {
+  const factory BoardCheckboxCellEvent.initial() = _InitialCell;
+  const factory BoardCheckboxCellEvent.didReceiveCellUpdate(
+      String cellContent) = _DidReceiveCellUpdate;
+}
+
+@freezed
+class BoardCheckboxCellState with _$BoardCheckboxCellState {
+  const factory BoardCheckboxCellState({
+    required bool isSelected,
+  }) = _CheckboxCellState;
+
+  factory BoardCheckboxCellState.initial(GridCellController context) {
+    return BoardCheckboxCellState(
+        isSelected: _isSelected(context.getCellData()));
+  }
+}
+
+bool _isSelected(String? cellData) {
+  return cellData == "Yes";
+}

+ 85 - 0
frontend/app_flowy/lib/plugins/board/application/card/board_date_cell_bloc.dart

@@ -0,0 +1,85 @@
+import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart';
+import 'package:flowy_sdk/protobuf/flowy-grid/date_type_option_entities.pb.dart';
+import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+import 'dart:async';
+part 'board_date_cell_bloc.freezed.dart';
+
+class BoardDateCellBloc extends Bloc<BoardDateCellEvent, BoardDateCellState> {
+  final GridDateCellController cellController;
+  void Function()? _onCellChangedFn;
+
+  BoardDateCellBloc({required this.cellController})
+      : super(BoardDateCellState.initial(cellController)) {
+    on<BoardDateCellEvent>(
+      (event, emit) async {
+        event.when(
+          initial: () => _startListening(),
+          didReceiveCellUpdate: (DateCellDataPB? cellData) {
+            emit(state.copyWith(
+                data: cellData, dateStr: _dateStrFromCellData(cellData)));
+          },
+          didReceiveFieldUpdate: (FieldPB value) =>
+              emit(state.copyWith(field: value)),
+        );
+      },
+    );
+  }
+
+  @override
+  Future<void> close() async {
+    if (_onCellChangedFn != null) {
+      cellController.removeListener(_onCellChangedFn!);
+      _onCellChangedFn = null;
+    }
+    cellController.dispose();
+    return super.close();
+  }
+
+  void _startListening() {
+    _onCellChangedFn = cellController.startListening(
+      onCellChanged: ((data) {
+        if (!isClosed) {
+          add(BoardDateCellEvent.didReceiveCellUpdate(data));
+        }
+      }),
+    );
+  }
+}
+
+@freezed
+class BoardDateCellEvent with _$BoardDateCellEvent {
+  const factory BoardDateCellEvent.initial() = _InitialCell;
+  const factory BoardDateCellEvent.didReceiveCellUpdate(DateCellDataPB? data) =
+      _DidReceiveCellUpdate;
+  const factory BoardDateCellEvent.didReceiveFieldUpdate(FieldPB field) =
+      _DidReceiveFieldUpdate;
+}
+
+@freezed
+class BoardDateCellState with _$BoardDateCellState {
+  const factory BoardDateCellState({
+    required DateCellDataPB? data,
+    required String dateStr,
+    required FieldPB field,
+  }) = _BoardDateCellState;
+
+  factory BoardDateCellState.initial(GridDateCellController context) {
+    final cellData = context.getCellData();
+
+    return BoardDateCellState(
+      field: context.field,
+      data: cellData,
+      dateStr: _dateStrFromCellData(cellData),
+    );
+  }
+}
+
+String _dateStrFromCellData(DateCellDataPB? cellData) {
+  String dateStr = "";
+  if (cellData != null) {
+    dateStr = cellData.date + " " + cellData.time;
+  }
+  return dateStr;
+}

+ 67 - 0
frontend/app_flowy/lib/plugins/board/application/card/board_number_cell_bloc.dart

@@ -0,0 +1,67 @@
+import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+import 'dart:async';
+
+part 'board_number_cell_bloc.freezed.dart';
+
+class BoardNumberCellBloc
+    extends Bloc<BoardNumberCellEvent, BoardNumberCellState> {
+  final GridNumberCellController cellController;
+  void Function()? _onCellChangedFn;
+  BoardNumberCellBloc({
+    required this.cellController,
+  }) : super(BoardNumberCellState.initial(cellController)) {
+    on<BoardNumberCellEvent>(
+      (event, emit) async {
+        await event.when(
+          initial: () async {
+            _startListening();
+          },
+          didReceiveCellUpdate: (content) {
+            emit(state.copyWith(content: content));
+          },
+        );
+      },
+    );
+  }
+
+  @override
+  Future<void> close() async {
+    if (_onCellChangedFn != null) {
+      cellController.removeListener(_onCellChangedFn!);
+      _onCellChangedFn = null;
+    }
+    cellController.dispose();
+    return super.close();
+  }
+
+  void _startListening() {
+    _onCellChangedFn = cellController.startListening(
+      onCellChanged: ((cellContent) {
+        if (!isClosed) {
+          add(BoardNumberCellEvent.didReceiveCellUpdate(cellContent ?? ""));
+        }
+      }),
+    );
+  }
+}
+
+@freezed
+class BoardNumberCellEvent with _$BoardNumberCellEvent {
+  const factory BoardNumberCellEvent.initial() = _InitialCell;
+  const factory BoardNumberCellEvent.didReceiveCellUpdate(String cellContent) =
+      _DidReceiveCellUpdate;
+}
+
+@freezed
+class BoardNumberCellState with _$BoardNumberCellState {
+  const factory BoardNumberCellState({
+    required String content,
+  }) = _BoardNumberCellState;
+
+  factory BoardNumberCellState.initial(GridCellController context) =>
+      BoardNumberCellState(
+        content: context.getCellData() ?? "",
+      );
+}

+ 78 - 0
frontend/app_flowy/lib/plugins/board/application/card/board_url_cell_bloc.dart

@@ -0,0 +1,78 @@
+import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart';
+import 'package:flowy_sdk/protobuf/flowy-grid/url_type_option_entities.pb.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+import 'dart:async';
+
+part 'board_url_cell_bloc.freezed.dart';
+
+class BoardURLCellBloc extends Bloc<BoardURLCellEvent, BoardURLCellState> {
+  final GridURLCellController cellController;
+  void Function()? _onCellChangedFn;
+  BoardURLCellBloc({
+    required this.cellController,
+  }) : super(BoardURLCellState.initial(cellController)) {
+    on<BoardURLCellEvent>(
+      (event, emit) async {
+        event.when(
+          initial: () {
+            _startListening();
+          },
+          didReceiveCellUpdate: (cellData) {
+            emit(state.copyWith(
+              content: cellData?.content ?? "",
+              url: cellData?.url ?? "",
+            ));
+          },
+          updateURL: (String url) {
+            cellController.saveCellData(url, deduplicate: true);
+          },
+        );
+      },
+    );
+  }
+
+  @override
+  Future<void> close() async {
+    if (_onCellChangedFn != null) {
+      cellController.removeListener(_onCellChangedFn!);
+      _onCellChangedFn = null;
+    }
+    cellController.dispose();
+    return super.close();
+  }
+
+  void _startListening() {
+    _onCellChangedFn = cellController.startListening(
+      onCellChanged: ((cellData) {
+        if (!isClosed) {
+          add(BoardURLCellEvent.didReceiveCellUpdate(cellData));
+        }
+      }),
+    );
+  }
+}
+
+@freezed
+class BoardURLCellEvent with _$BoardURLCellEvent {
+  const factory BoardURLCellEvent.initial() = _InitialCell;
+  const factory BoardURLCellEvent.updateURL(String url) = _UpdateURL;
+  const factory BoardURLCellEvent.didReceiveCellUpdate(URLCellDataPB? cell) =
+      _DidReceiveCellUpdate;
+}
+
+@freezed
+class BoardURLCellState with _$BoardURLCellState {
+  const factory BoardURLCellState({
+    required String content,
+    required String url,
+  }) = _BoardURLCellState;
+
+  factory BoardURLCellState.initial(GridURLCellController context) {
+    final cellData = context.getCellData();
+    return BoardURLCellState(
+      content: cellData?.content ?? "",
+      url: cellData?.url ?? "",
+    );
+  }
+}

+ 111 - 0
frontend/app_flowy/lib/plugins/board/application/card/card_bloc.dart

@@ -0,0 +1,111 @@
+import 'dart:collection';
+import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart';
+import 'package:app_flowy/plugins/grid/application/row/row_cache.dart';
+import 'package:app_flowy/plugins/grid/application/row/row_service.dart';
+import 'package:equatable/equatable.dart';
+import 'package:flowy_sdk/protobuf/flowy-grid/block_entities.pb.dart';
+import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+import 'dart:async';
+
+import 'card_data_controller.dart';
+
+part 'card_bloc.freezed.dart';
+
+class BoardCardBloc extends Bloc<BoardCardEvent, BoardCardState> {
+  final RowFFIService _rowService;
+  final CardDataController _dataController;
+
+  BoardCardBloc({
+    required String gridId,
+    required CardDataController dataController,
+  })  : _rowService = RowFFIService(
+          gridId: gridId,
+          blockId: dataController.rowPB.blockId,
+          rowId: dataController.rowPB.id,
+        ),
+        _dataController = dataController,
+        super(BoardCardState.initial(
+            dataController.rowPB, dataController.loadData())) {
+    on<BoardCardEvent>(
+      (event, emit) async {
+        await event.map(
+          initial: (_InitialRow value) async {
+            await _startListening();
+          },
+          createRow: (_CreateRow value) {
+            _rowService.createRow();
+          },
+          didReceiveCells: (_DidReceiveCells value) async {
+            final cells = value.gridCellMap.values
+                .map((e) => GridCellEquatable(e.field))
+                .toList();
+            emit(state.copyWith(
+              gridCellMap: value.gridCellMap,
+              cells: UnmodifiableListView(cells),
+              changeReason: value.reason,
+            ));
+          },
+        );
+      },
+    );
+  }
+
+  @override
+  Future<void> close() async {
+    _dataController.dispose();
+    return super.close();
+  }
+
+  Future<void> _startListening() async {
+    _dataController.addListener(
+      onRowChanged: (cells, reason) {
+        if (!isClosed) {
+          add(BoardCardEvent.didReceiveCells(cells, reason));
+        }
+      },
+    );
+  }
+}
+
+@freezed
+class BoardCardEvent with _$BoardCardEvent {
+  const factory BoardCardEvent.initial() = _InitialRow;
+  const factory BoardCardEvent.createRow() = _CreateRow;
+  const factory BoardCardEvent.didReceiveCells(
+      GridCellMap gridCellMap, RowChangeReason reason) = _DidReceiveCells;
+}
+
+@freezed
+class BoardCardState with _$BoardCardState {
+  const factory BoardCardState({
+    required RowPB rowPB,
+    required GridCellMap gridCellMap,
+    required UnmodifiableListView<GridCellEquatable> cells,
+    RowChangeReason? changeReason,
+  }) = _BoardCardState;
+
+  factory BoardCardState.initial(RowPB rowPB, GridCellMap cellDataMap) =>
+      BoardCardState(
+        rowPB: rowPB,
+        gridCellMap: cellDataMap,
+        cells: UnmodifiableListView(
+          cellDataMap.values.map((e) => GridCellEquatable(e.field)).toList(),
+        ),
+      );
+}
+
+class GridCellEquatable extends Equatable {
+  final FieldPB _field;
+
+  const GridCellEquatable(FieldPB field) : _field = field;
+
+  @override
+  List<Object?> get props => [
+        _field.id,
+        _field.fieldType,
+        _field.visibility,
+        _field.width,
+      ];
+}

+ 49 - 0
frontend/app_flowy/lib/plugins/board/application/card/card_data_controller.dart

@@ -0,0 +1,49 @@
+import 'package:app_flowy/plugins/board/presentation/card/card_cell_builder.dart';
+import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart';
+import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_field_notifier.dart';
+import 'package:app_flowy/plugins/grid/application/field/field_cache.dart';
+import 'package:app_flowy/plugins/grid/application/row/row_cache.dart';
+import 'package:flowy_sdk/protobuf/flowy-grid/block_entities.pb.dart';
+import 'package:flutter/foundation.dart';
+
+typedef OnCardChanged = void Function(GridCellMap, RowChangeReason);
+
+class CardDataController extends BoardCellBuilderDelegate {
+  final RowPB rowPB;
+  final GridFieldCache _fieldCache;
+  final GridRowCache _rowCache;
+  final List<VoidCallback> _onCardChangedListeners = [];
+
+  CardDataController({
+    required this.rowPB,
+    required GridFieldCache fieldCache,
+    required GridRowCache rowCache,
+  })  : _fieldCache = fieldCache,
+        _rowCache = rowCache;
+
+  GridCellMap loadData() {
+    return _rowCache.loadGridCells(rowPB.id);
+  }
+
+  void addListener({OnCardChanged? onRowChanged}) {
+    _onCardChangedListeners.add(_rowCache.addListener(
+      rowId: rowPB.id,
+      onCellUpdated: onRowChanged,
+    ));
+  }
+
+  void dispose() {
+    for (final fn in _onCardChangedListeners) {
+      _rowCache.removeRowListener(fn);
+    }
+  }
+
+  @override
+  GridCellFieldNotifier buildFieldNotifier() {
+    return GridCellFieldNotifier(
+        notifier: GridCellFieldNotifierImpl(_fieldCache));
+  }
+
+  @override
+  GridCellCache get cellCache => _rowCache.cellCache;
+}

+ 27 - 4
frontend/app_flowy/lib/plugins/board/presentation/board_page.dart

@@ -1,5 +1,6 @@
 // ignore_for_file: unused_field
 
+import 'package:app_flowy/plugins/board/application/card/card_data_controller.dart';
 import 'package:appflowy_board/appflowy_board.dart';
 import 'package:flowy_infra_ui/widget/error_page.dart';
 import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart';
@@ -7,6 +8,7 @@ import 'package:flutter/material.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
 import '../application/board_bloc.dart';
 import 'card/card.dart';
+import 'card/card_cell_builder.dart';
 
 class BoardPage extends StatelessWidget {
   final ViewPB view;
@@ -49,12 +51,14 @@ class BoardContent extends StatelessWidget {
         return Container(
           color: Colors.white,
           child: Padding(
-            padding: const EdgeInsets.symmetric(vertical: 30, horizontal: 20),
+            padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
             child: AFBoard(
+              key: UniqueKey(),
+              scrollController: ScrollController(),
               dataController: context.read<BoardBloc>().boardDataController,
               headerBuilder: _buildHeader,
               footBuilder: _buildFooter,
-              cardBuilder: _buildCard,
+              cardBuilder: (_, data) => _buildCard(context, data),
               columnConstraints: const BoxConstraints.tightFor(width: 240),
               config: AFBoardConfig(
                 columnBackgroundColor: HexColor.fromHex('#F7F8FC'),
@@ -87,10 +91,29 @@ class BoardContent extends StatelessWidget {
   }
 
   Widget _buildCard(BuildContext context, AFColumnItem item) {
-    final rowInfo = (item as BoardColumnItem).row;
+    final rowPB = (item as BoardColumnItem).row;
+    final rowCache = context.read<BoardBloc>().getRowCache(rowPB.blockId);
+
+    /// Return placeholder widget if the rowCache is null.
+    if (rowCache == null) return SizedBox(key: ObjectKey(item));
+
+    final fieldCache = context.read<BoardBloc>().fieldCache;
+    final gridId = context.read<BoardBloc>().gridId;
+    final cardController = CardDataController(
+      fieldCache: fieldCache,
+      rowCache: rowCache,
+      rowPB: rowPB,
+    );
+
+    final cellBuilder = BoardCellBuilder(cardController);
+
     return AppFlowyColumnItemCard(
       key: ObjectKey(item),
-      child: BoardCard(rowInfo: rowInfo),
+      child: BoardCard(
+        cellBuilder: cellBuilder,
+        dataController: cardController,
+        gridId: gridId,
+      ),
     );
   }
 }

+ 59 - 0
frontend/app_flowy/lib/plugins/board/presentation/card/board_checkbox_cell.dart

@@ -0,0 +1,59 @@
+import 'package:app_flowy/plugins/board/application/card/board_checkbox_cell_bloc.dart';
+import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart';
+import 'package:flowy_infra/image.dart';
+import 'package:flowy_infra_ui/style_widget/icon_button.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+class BoardCheckboxCell extends StatefulWidget {
+  final GridCellControllerBuilder cellControllerBuilder;
+
+  const BoardCheckboxCell({
+    required this.cellControllerBuilder,
+    Key? key,
+  }) : super(key: key);
+
+  @override
+  State<BoardCheckboxCell> createState() => _BoardCheckboxCellState();
+}
+
+class _BoardCheckboxCellState extends State<BoardCheckboxCell> {
+  late BoardCheckboxCellBloc _cellBloc;
+
+  @override
+  void initState() {
+    final cellController =
+        widget.cellControllerBuilder.build() as GridCheckboxCellController;
+    _cellBloc = BoardCheckboxCellBloc(cellController: cellController);
+    _cellBloc.add(const BoardCheckboxCellEvent.initial());
+    super.initState();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return BlocProvider.value(
+      value: _cellBloc,
+      child: BlocBuilder<BoardCheckboxCellBloc, BoardCheckboxCellState>(
+        builder: (context, state) {
+          final icon = state.isSelected
+              ? svgWidget('editor/editor_check')
+              : svgWidget('editor/editor_uncheck');
+          return Align(
+            alignment: Alignment.centerLeft,
+            child: FlowyIconButton(
+              iconPadding: EdgeInsets.zero,
+              icon: icon,
+              width: 20,
+            ),
+          );
+        },
+      ),
+    );
+  }
+
+  @override
+  Future<void> dispose() async {
+    _cellBloc.close();
+    super.dispose();
+  }
+}

+ 59 - 0
frontend/app_flowy/lib/plugins/board/presentation/card/board_date_cell.dart

@@ -0,0 +1,59 @@
+import 'package:app_flowy/plugins/board/application/card/board_date_cell_bloc.dart';
+import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart';
+import 'package:flowy_infra_ui/style_widget/text.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+class BoardDateCell extends StatefulWidget {
+  final GridCellControllerBuilder cellControllerBuilder;
+
+  const BoardDateCell({
+    required this.cellControllerBuilder,
+    Key? key,
+  }) : super(key: key);
+
+  @override
+  State<BoardDateCell> createState() => _BoardDateCellState();
+}
+
+class _BoardDateCellState extends State<BoardDateCell> {
+  late BoardDateCellBloc _cellBloc;
+
+  @override
+  void initState() {
+    final cellController =
+        widget.cellControllerBuilder.build() as GridDateCellController;
+
+    _cellBloc = BoardDateCellBloc(cellController: cellController)
+      ..add(const BoardDateCellEvent.initial());
+    super.initState();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return BlocProvider.value(
+      value: _cellBloc,
+      child: BlocBuilder<BoardDateCellBloc, BoardDateCellState>(
+        builder: (context, state) {
+          if (state.dateStr.isEmpty) {
+            return const SizedBox();
+          } else {
+            return Align(
+              alignment: Alignment.centerLeft,
+              child: FlowyText.regular(
+                state.dateStr,
+                fontSize: 14,
+              ),
+            );
+          }
+        },
+      ),
+    );
+  }
+
+  @override
+  Future<void> dispose() async {
+    _cellBloc.close();
+    super.dispose();
+  }
+}

+ 59 - 0
frontend/app_flowy/lib/plugins/board/presentation/card/board_number_cell.dart

@@ -0,0 +1,59 @@
+import 'package:app_flowy/plugins/board/application/card/board_number_cell_bloc.dart';
+import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart';
+import 'package:flowy_infra_ui/style_widget/text.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+class BoardNumberCell extends StatefulWidget {
+  final GridCellControllerBuilder cellControllerBuilder;
+
+  const BoardNumberCell({
+    required this.cellControllerBuilder,
+    Key? key,
+  }) : super(key: key);
+
+  @override
+  State<BoardNumberCell> createState() => _BoardNumberCellState();
+}
+
+class _BoardNumberCellState extends State<BoardNumberCell> {
+  late BoardNumberCellBloc _cellBloc;
+
+  @override
+  void initState() {
+    final cellController =
+        widget.cellControllerBuilder.build() as GridNumberCellController;
+
+    _cellBloc = BoardNumberCellBloc(cellController: cellController)
+      ..add(const BoardNumberCellEvent.initial());
+    super.initState();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return BlocProvider.value(
+      value: _cellBloc,
+      child: BlocBuilder<BoardNumberCellBloc, BoardNumberCellState>(
+        builder: (context, state) {
+          if (state.content.isEmpty) {
+            return const SizedBox();
+          } else {
+            return Align(
+              alignment: Alignment.centerLeft,
+              child: FlowyText.regular(
+                state.content,
+                fontSize: 14,
+              ),
+            );
+          }
+        },
+      ),
+    );
+  }
+
+  @override
+  Future<void> dispose() async {
+    _cellBloc.close();
+    super.dispose();
+  }
+}

+ 10 - 4
frontend/app_flowy/lib/plugins/board/presentation/card/board_select_option_cell.dart

@@ -1,6 +1,6 @@
 import 'package:app_flowy/plugins/board/application/card/board_select_option_cell_bloc.dart';
 import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart';
-import 'package:app_flowy/plugins/grid/presentation/widgets/cell/select_option_cell/select_option_cell.dart';
+import 'package:app_flowy/plugins/grid/presentation/widgets/cell/select_option_cell/extension.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
 
@@ -34,9 +34,15 @@ class _BoardSelectOptionCellState extends State<BoardSelectOptionCell> {
       value: _cellBloc,
       child: BlocBuilder<BoardSelectOptionCellBloc, BoardSelectOptionCellState>(
         builder: (context, state) {
-          return SelectOptionWrap(
-            selectOptions: state.selectedOptions,
-            cellControllerBuilder: widget.cellControllerBuilder,
+          final children = state.selectedOptions
+              .map((option) => SelectOptionTag.fromOption(
+                    context: context,
+                    option: option,
+                  ))
+              .toList();
+          return Align(
+            alignment: Alignment.centerLeft,
+            child: Wrap(children: children, spacing: 4, runSpacing: 2),
           );
         },
       ),

+ 11 - 4
frontend/app_flowy/lib/plugins/board/presentation/card/board_text_cell.dart

@@ -32,10 +32,17 @@ class _BoardTextCellState extends State<BoardTextCell> {
       value: _cellBloc,
       child: BlocBuilder<BoardTextCellBloc, BoardTextCellState>(
         builder: (context, state) {
-          return SizedBox(
-            height: 30,
-            child: FlowyText.medium(state.content),
-          );
+          if (state.content.isEmpty) {
+            return const SizedBox();
+          } else {
+            return Align(
+              alignment: Alignment.centerLeft,
+              child: FlowyText.regular(
+                state.content,
+                fontSize: 14,
+              ),
+            );
+          }
         },
       ),
     );

+ 64 - 0
frontend/app_flowy/lib/plugins/board/presentation/card/board_url_cell.dart

@@ -0,0 +1,64 @@
+import 'package:app_flowy/plugins/board/application/card/board_url_cell_bloc.dart';
+import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart';
+import 'package:flowy_infra/theme.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+class BoardUrlCell extends StatefulWidget {
+  final GridCellControllerBuilder cellControllerBuilder;
+
+  const BoardUrlCell({
+    required this.cellControllerBuilder,
+    Key? key,
+  }) : super(key: key);
+
+  @override
+  State<BoardUrlCell> createState() => _BoardUrlCellState();
+}
+
+class _BoardUrlCellState extends State<BoardUrlCell> {
+  late BoardURLCellBloc _cellBloc;
+
+  @override
+  void initState() {
+    final cellController =
+        widget.cellControllerBuilder.build() as GridURLCellController;
+    _cellBloc = BoardURLCellBloc(cellController: cellController);
+    _cellBloc.add(const BoardURLCellEvent.initial());
+    super.initState();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final theme = context.watch<AppTheme>();
+    return BlocProvider.value(
+      value: _cellBloc,
+      child: BlocBuilder<BoardURLCellBloc, BoardURLCellState>(
+        builder: (context, state) {
+          final richText = RichText(
+            textAlign: TextAlign.left,
+            text: TextSpan(
+              text: state.content,
+              style: TextStyle(
+                color: theme.main2,
+                fontSize: 14,
+                decoration: TextDecoration.underline,
+              ),
+            ),
+          );
+
+          return Align(
+            alignment: Alignment.centerLeft,
+            child: richText,
+          );
+        },
+      ),
+    );
+  }
+
+  @override
+  Future<void> dispose() async {
+    _cellBloc.close();
+    super.dispose();
+  }
+}

+ 77 - 5
frontend/app_flowy/lib/plugins/board/presentation/card/card.dart

@@ -1,13 +1,85 @@
-import 'package:app_flowy/plugins/grid/application/row/row_cache.dart';
+import 'package:app_flowy/plugins/board/application/card/card_bloc.dart';
+import 'package:app_flowy/plugins/board/application/card/card_data_controller.dart';
+import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart';
+import 'package:flowy_infra/image.dart';
+import 'package:flowy_infra/theme.dart';
+import 'package:flowy_sdk/log.dart';
 import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'card_cell_builder.dart';
+import 'card_container.dart';
 
-class BoardCard extends StatelessWidget {
-  final RowInfo rowInfo;
+class BoardCard extends StatefulWidget {
+  final String gridId;
+  final CardDataController dataController;
+  final BoardCellBuilder cellBuilder;
 
-  const BoardCard({required this.rowInfo, Key? key}) : super(key: key);
+  const BoardCard({
+    required this.gridId,
+    required this.dataController,
+    required this.cellBuilder,
+    Key? key,
+  }) : super(key: key);
+
+  @override
+  State<BoardCard> createState() => _BoardCardState();
+}
+
+class _BoardCardState extends State<BoardCard> {
+  late BoardCardBloc _cardBloc;
+
+  @override
+  void initState() {
+    _cardBloc = BoardCardBloc(
+      gridId: widget.gridId,
+      dataController: widget.dataController,
+    );
+    super.initState();
+  }
 
   @override
   Widget build(BuildContext context) {
-    return const SizedBox(height: 20, child: Text('1234'));
+    return BlocProvider.value(
+      value: _cardBloc,
+      child: BlocBuilder<BoardCardBloc, BoardCardState>(
+        builder: (context, state) {
+          return BoardCardContainer(
+            accessoryBuilder: (context) {
+              return [const _CardMoreOption()];
+            },
+            child: Column(
+              children: _makeCells(context, state.gridCellMap),
+            ),
+          );
+        },
+      ),
+    );
+  }
+
+  List<Widget> _makeCells(BuildContext context, GridCellMap cellMap) {
+    return cellMap.values.map(
+      (cellId) {
+        final child = widget.cellBuilder.buildCell(cellId);
+
+        return Padding(
+          padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
+          child: child,
+        );
+      },
+    ).toList();
+  }
+}
+
+class _CardMoreOption extends StatelessWidget with CardAccessory {
+  const _CardMoreOption({Key? key}) : super(key: key);
+
+  @override
+  Widget build(BuildContext context) {
+    return svgWidget('home/details', color: context.read<AppTheme>().iconColor);
+  }
+
+  @override
+  void onTap(BuildContext context) {
+    Log.debug('show options');
   }
 }

+ 69 - 0
frontend/app_flowy/lib/plugins/board/presentation/card/card_cell_builder.dart

@@ -0,0 +1,69 @@
+import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart';
+import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart';
+import 'package:flutter/material.dart';
+
+import 'board_checkbox_cell.dart';
+import 'board_date_cell.dart';
+import 'board_number_cell.dart';
+import 'board_select_option_cell.dart';
+import 'board_text_cell.dart';
+import 'board_url_cell.dart';
+
+abstract class BoardCellBuilderDelegate
+    extends GridCellControllerBuilderDelegate {
+  GridCellCache get cellCache;
+}
+
+class BoardCellBuilder {
+  final BoardCellBuilderDelegate delegate;
+
+  BoardCellBuilder(this.delegate);
+
+  Widget buildCell(GridCellIdentifier cellId) {
+    final cellControllerBuilder = GridCellControllerBuilder(
+      delegate: delegate,
+      cellId: cellId,
+      cellCache: delegate.cellCache,
+    );
+
+    final key = cellId.key();
+    switch (cellId.fieldType) {
+      case FieldType.Checkbox:
+        return BoardCheckboxCell(
+          cellControllerBuilder: cellControllerBuilder,
+          key: key,
+        );
+      case FieldType.DateTime:
+        return BoardDateCell(
+          cellControllerBuilder: cellControllerBuilder,
+          key: key,
+        );
+      case FieldType.SingleSelect:
+        return BoardSelectOptionCell(
+          cellControllerBuilder: cellControllerBuilder,
+          key: key,
+        );
+      case FieldType.MultiSelect:
+        return BoardSelectOptionCell(
+          cellControllerBuilder: cellControllerBuilder,
+          key: key,
+        );
+      case FieldType.Number:
+        return BoardNumberCell(
+          cellControllerBuilder: cellControllerBuilder,
+          key: key,
+        );
+      case FieldType.RichText:
+        return BoardTextCell(
+          cellControllerBuilder: cellControllerBuilder,
+          key: key,
+        );
+      case FieldType.URL:
+        return BoardUrlCell(
+          cellControllerBuilder: cellControllerBuilder,
+          key: key,
+        );
+    }
+    throw UnimplementedError;
+  }
+}

+ 133 - 0
frontend/app_flowy/lib/plugins/board/presentation/card/card_container.dart

@@ -0,0 +1,133 @@
+import 'package:flowy_infra/theme.dart';
+import 'package:flowy_infra_ui/style_widget/hover.dart';
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import 'package:styled_widget/styled_widget.dart';
+
+class BoardCardContainer extends StatelessWidget {
+  final Widget child;
+  final CardAccessoryBuilder? accessoryBuilder;
+  const BoardCardContainer({
+    required this.child,
+    this.accessoryBuilder,
+    Key? key,
+  }) : super(key: key);
+
+  @override
+  Widget build(BuildContext context) {
+    return ChangeNotifierProvider(
+      create: (_) => _CardContainerNotifier(),
+      child: Consumer<_CardContainerNotifier>(
+        builder: (context, notifier, _) {
+          Widget container = Center(child: child);
+          if (accessoryBuilder != null) {
+            final accessories = accessoryBuilder!(context);
+            if (accessories.isNotEmpty) {
+              container = _CardEnterRegion(
+                child: container,
+                accessories: accessories,
+              );
+            }
+          }
+          return Padding(
+            padding: const EdgeInsets.all(8),
+            child: container,
+          );
+        },
+      ),
+    );
+  }
+}
+
+abstract class CardAccessory implements Widget {
+  void onTap(BuildContext context);
+}
+
+typedef CardAccessoryBuilder = List<CardAccessory> Function(
+  BuildContext buildContext,
+);
+
+class CardAccessoryContainer extends StatelessWidget {
+  final List<CardAccessory> accessories;
+  const CardAccessoryContainer({required this.accessories, Key? key})
+      : super(key: key);
+
+  @override
+  Widget build(BuildContext context) {
+    final theme = context.read<AppTheme>();
+    final children = accessories.map((accessory) {
+      final hover = FlowyHover(
+        style: HoverStyle(
+          hoverColor: theme.hover,
+          backgroundColor: theme.surface,
+        ),
+        builder: (_, onHover) => Container(
+          width: 26,
+          height: 26,
+          padding: const EdgeInsets.all(3),
+          child: accessory,
+        ),
+      );
+      return GestureDetector(
+        child: hover,
+        behavior: HitTestBehavior.opaque,
+        onTap: () => accessory.onTap(context),
+      );
+    }).toList();
+
+    return Wrap(children: children, spacing: 6);
+  }
+}
+
+class _CardEnterRegion extends StatelessWidget {
+  final Widget child;
+  final List<CardAccessory> accessories;
+  const _CardEnterRegion(
+      {required this.child, required this.accessories, Key? key})
+      : super(key: key);
+
+  @override
+  Widget build(BuildContext context) {
+    return Selector<_CardContainerNotifier, bool>(
+      selector: (context, notifier) => notifier.onEnter,
+      builder: (context, onEnter, _) {
+        List<Widget> children = [child];
+        if (onEnter) {
+          children.add(CardAccessoryContainer(accessories: accessories)
+              .positioned(right: 0));
+        }
+
+        return MouseRegion(
+          cursor: SystemMouseCursors.click,
+          onEnter: (p) =>
+              Provider.of<_CardContainerNotifier>(context, listen: false)
+                  .onEnter = true,
+          onExit: (p) =>
+              Provider.of<_CardContainerNotifier>(context, listen: false)
+                  .onEnter = false,
+          child: IntrinsicHeight(
+              child: Stack(
+            alignment: AlignmentDirectional.center,
+            fit: StackFit.expand,
+            children: children,
+          )),
+        );
+      },
+    );
+  }
+}
+
+class _CardContainerNotifier extends ChangeNotifier {
+  bool _onEnter = false;
+
+  _CardContainerNotifier();
+
+  set onEnter(bool value) {
+    if (_onEnter != value) {
+      _onEnter = value;
+      notifyListeners();
+    }
+  }
+
+  bool get onEnter => _onEnter;
+}

+ 6 - 4
frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/context_builder.dart

@@ -1,6 +1,8 @@
 part of 'cell_service.dart';
 
 typedef GridCellController = IGridCellController<String, String>;
+typedef GridCheckboxCellController = IGridCellController<String, String>;
+typedef GridNumberCellController = IGridCellController<String, String>;
 typedef GridSelectOptionCellController
     = IGridCellController<SelectOptionCellDataPB, String>;
 typedef GridDateCellController
@@ -58,7 +60,7 @@ class GridCellControllerBuilder {
           parser: StringCellDataParser(),
           reloadOnFieldChanged: true,
         );
-        return GridCellController(
+        return GridNumberCellController(
           cellId: _cellId,
           cellCache: _cellCache,
           cellDataLoader: cellDataLoader,
@@ -127,7 +129,7 @@ class IGridCellController<T, D> extends Equatable {
   final GridCellDataLoader<T> _cellDataLoader;
   final IGridCellDataPersistence<D> _cellDataPersistence;
 
-  late final CellListener _cellListener;
+  CellListener? _cellListener;
   ValueNotifier<T?>? _cellDataNotifier;
 
   bool isListening = false;
@@ -186,7 +188,7 @@ class IGridCellController<T, D> extends Equatable {
     /// For example:
     ///  user input: 12
     ///  cell display: $12
-    _cellListener.start(onCellChanged: (result) {
+    _cellListener?.start(onCellChanged: (result) {
       result.fold(
         (_) => _loadData(),
         (err) => Log.error(err),
@@ -289,7 +291,7 @@ class IGridCellController<T, D> extends Equatable {
       return;
     }
     _isDispose = true;
-    _cellListener.stop();
+    _cellListener?.stop();
     _loadDataOperation?.cancel();
     _saveDataOperation?.cancel();
     _cellDataNotifier = null;

+ 1 - 1
frontend/app_flowy/lib/plugins/grid/application/cell/checkbox_cell_bloc.dart

@@ -6,7 +6,7 @@ import 'cell_service/cell_service.dart';
 part 'checkbox_cell_bloc.freezed.dart';
 
 class CheckboxCellBloc extends Bloc<CheckboxCellEvent, CheckboxCellState> {
-  final GridCellController cellController;
+  final GridCheckboxCellController cellController;
   void Function()? _onCellChangedFn;
 
   CheckboxCellBloc({

+ 1 - 1
frontend/app_flowy/lib/plugins/grid/application/cell/number_cell_bloc.dart

@@ -8,7 +8,7 @@ import 'cell_service/cell_service.dart';
 part 'number_cell_bloc.freezed.dart';
 
 class NumberCellBloc extends Bloc<NumberCellEvent, NumberCellState> {
-  final GridCellController cellController;
+  final GridNumberCellController cellController;
   void Function()? _onCellChangedFn;
 
   NumberCellBloc({

+ 1 - 1
frontend/app_flowy/lib/plugins/grid/application/grid_data_controller.dart

@@ -46,7 +46,7 @@ class GridDataController {
 
   GridDataController({required ViewPB view})
       : gridId = view.id,
-        _blocks = LinkedHashMap.identity(),
+        _blocks = LinkedHashMap.new(),
         _gridFFIService = GridService(gridId: view.id),
         fieldCache = GridFieldCache(gridId: view.id);
 

+ 2 - 2
frontend/app_flowy/lib/plugins/grid/application/row/row_action_sheet_bloc.dart

@@ -12,10 +12,10 @@ part 'row_action_sheet_bloc.freezed.dart';
 
 class RowActionSheetBloc
     extends Bloc<RowActionSheetEvent, RowActionSheetState> {
-  final RowService _rowService;
+  final RowFFIService _rowService;
 
   RowActionSheetBloc({required RowInfo rowData})
-      : _rowService = RowService(
+      : _rowService = RowFFIService(
           gridId: rowData.gridId,
           blockId: rowData.blockId,
           rowId: rowData.id,

+ 8 - 8
frontend/app_flowy/lib/plugins/grid/application/row/row_bloc.dart

@@ -12,13 +12,13 @@ import 'row_service.dart';
 part 'row_bloc.freezed.dart';
 
 class RowBloc extends Bloc<RowEvent, RowState> {
-  final RowService _rowService;
+  final RowFFIService _rowService;
   final GridRowDataController _dataController;
 
   RowBloc({
     required RowInfo rowInfo,
     required GridRowDataController dataController,
-  })  : _rowService = RowService(
+  })  : _rowService = RowFFIService(
           gridId: rowInfo.gridId,
           blockId: rowInfo.blockId,
           rowId: rowInfo.id,
@@ -35,13 +35,12 @@ class RowBloc extends Bloc<RowEvent, RowState> {
             _rowService.createRow();
           },
           didReceiveCells: (_DidReceiveCells value) async {
-            final fields = value.gridCellMap.values
+            final cells = value.gridCellMap.values
                 .map((e) => GridCellEquatable(e.field))
                 .toList();
-            final snapshots = UnmodifiableListView(fields);
             emit(state.copyWith(
               gridCellMap: value.gridCellMap,
-              snapshots: snapshots,
+              cells: UnmodifiableListView(cells),
               changeReason: value.reason,
             ));
           },
@@ -80,7 +79,7 @@ class RowState with _$RowState {
   const factory RowState({
     required RowInfo rowInfo,
     required GridCellMap gridCellMap,
-    required UnmodifiableListView<GridCellEquatable> snapshots,
+    required UnmodifiableListView<GridCellEquatable> cells,
     RowChangeReason? changeReason,
   }) = _RowState;
 
@@ -88,8 +87,9 @@ class RowState with _$RowState {
       RowState(
         rowInfo: rowInfo,
         gridCellMap: cellDataMap,
-        snapshots: UnmodifiableListView(
-            cellDataMap.values.map((e) => GridCellEquatable(e.field)).toList()),
+        cells: UnmodifiableListView(
+          cellDataMap.values.map((e) => GridCellEquatable(e.field)).toList(),
+        ),
       );
 }
 

+ 1 - 5
frontend/app_flowy/lib/plugins/grid/application/row/row_data_controller.dart

@@ -13,10 +13,6 @@ class GridRowDataController extends GridCellBuilderDelegate {
   final GridFieldCache _fieldCache;
   final GridRowCache _rowCache;
 
-  GridFieldCache get fieldCache => _fieldCache;
-
-  GridRowCache get rowCache => _rowCache;
-
   GridRowDataController({
     required this.rowInfo,
     required GridFieldCache fieldCache,
@@ -49,5 +45,5 @@ class GridRowDataController extends GridCellBuilderDelegate {
   }
 
   @override
-  GridCellCache get cellCache => rowCache.cellCache;
+  GridCellCache get cellCache => _rowCache.cellCache;
 }

+ 2 - 2
frontend/app_flowy/lib/plugins/grid/application/row/row_service.dart

@@ -5,12 +5,12 @@ import 'package:flowy_sdk/protobuf/flowy-grid/block_entities.pb.dart';
 import 'package:flowy_sdk/protobuf/flowy-grid/grid_entities.pb.dart';
 import 'package:flowy_sdk/protobuf/flowy-grid/row_entities.pb.dart';
 
-class RowService {
+class RowFFIService {
   final String gridId;
   final String blockId;
   final String rowId;
 
-  RowService(
+  RowFFIService(
       {required this.gridId, required this.blockId, required this.rowId});
 
   Future<Either<RowPB, FlowyError>> createRow() {

+ 2 - 12
frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_accessory.dart

@@ -8,6 +8,8 @@ import 'package:styled_widget/styled_widget.dart';
 import 'package:app_flowy/generated/locale_keys.g.dart';
 import 'package:easy_localization/easy_localization.dart';
 
+import 'cell_builder.dart';
+
 class GridCellAccessoryBuildContext {
   final BuildContext anchorContext;
   final bool isCellEditing;
@@ -57,18 +59,6 @@ class PrimaryCellAccessory extends StatelessWidget with GridCellAccessory {
   bool enable() => !isCellEditing;
 }
 
-typedef AccessoryBuilder = List<GridCellAccessory> Function(
-    GridCellAccessoryBuildContext buildContext);
-
-abstract class CellAccessory extends Widget {
-  const CellAccessory({Key? key}) : super(key: key);
-
-  // The hover will show if the isHover's value is true
-  ValueNotifier<bool>? get onAccessoryHover;
-
-  AccessoryBuilder? get accessoryBuilder;
-}
-
 class AccessoryHover extends StatefulWidget {
   final CellAccessory child;
   final EdgeInsets contentPadding;

+ 12 - 0
frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_builder.dart

@@ -94,6 +94,18 @@ abstract class CellEditable {
   ValueNotifier<bool> get onCellEditing;
 }
 
+typedef AccessoryBuilder = List<GridCellAccessory> Function(
+    GridCellAccessoryBuildContext buildContext);
+
+abstract class CellAccessory extends Widget {
+  const CellAccessory({Key? key}) : super(key: key);
+
+  // The hover will show if the isHover's value is true
+  ValueNotifier<bool>? get onAccessoryHover;
+
+  AccessoryBuilder? get accessoryBuilder;
+}
+
 abstract class GridCellWidget extends StatefulWidget
     implements CellAccessory, CellEditable, CellShortcuts {
   GridCellWidget({Key? key}) : super(key: key) {

+ 20 - 16
frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_container.dart

@@ -25,24 +25,28 @@ class CellContainer extends StatelessWidget {
   @override
   Widget build(BuildContext context) {
     return ChangeNotifierProxyProvider<RegionStateNotifier,
-        CellContainerNotifier>(
-      create: (_) => CellContainerNotifier(child),
+        _CellContainerNotifier>(
+      create: (_) => _CellContainerNotifier(child),
       update: (_, rowStateNotifier, cellStateNotifier) =>
           cellStateNotifier!..onEnter = rowStateNotifier.onEnter,
-      child: Selector<CellContainerNotifier, bool>(
+      child: Selector<_CellContainerNotifier, bool>(
         selector: (context, notifier) => notifier.isFocus,
         builder: (context, isFocus, _) {
           Widget container = Center(child: GridCellShortcuts(child: child));
 
           if (accessoryBuilder != null) {
-            final accessories = accessoryBuilder!(GridCellAccessoryBuildContext(
-              anchorContext: context,
-              isCellEditing: isFocus,
-            ));
+            final accessories = accessoryBuilder!(
+              GridCellAccessoryBuildContext(
+                anchorContext: context,
+                isCellEditing: isFocus,
+              ),
+            );
 
             if (accessories.isNotEmpty) {
-              container =
-                  CellEnterRegion(child: container, accessories: accessories);
+              container = _GridCellEnterRegion(
+                child: container,
+                accessories: accessories,
+              );
             }
           }
 
@@ -74,16 +78,16 @@ class CellContainer extends StatelessWidget {
   }
 }
 
-class CellEnterRegion extends StatelessWidget {
+class _GridCellEnterRegion extends StatelessWidget {
   final Widget child;
   final List<GridCellAccessory> accessories;
-  const CellEnterRegion(
+  const _GridCellEnterRegion(
       {required this.child, required this.accessories, Key? key})
       : super(key: key);
 
   @override
   Widget build(BuildContext context) {
-    return Selector<CellContainerNotifier, bool>(
+    return Selector<_CellContainerNotifier, bool>(
       selector: (context, notifier) => notifier.onEnter,
       builder: (context, onEnter, _) {
         List<Widget> children = [child];
@@ -95,10 +99,10 @@ class CellEnterRegion extends StatelessWidget {
         return MouseRegion(
           cursor: SystemMouseCursors.click,
           onEnter: (p) =>
-              Provider.of<CellContainerNotifier>(context, listen: false)
+              Provider.of<_CellContainerNotifier>(context, listen: false)
                   .onEnter = true,
           onExit: (p) =>
-              Provider.of<CellContainerNotifier>(context, listen: false)
+              Provider.of<_CellContainerNotifier>(context, listen: false)
                   .onEnter = false,
           child: Stack(
             alignment: AlignmentDirectional.center,
@@ -111,13 +115,13 @@ class CellEnterRegion extends StatelessWidget {
   }
 }
 
-class CellContainerNotifier extends ChangeNotifier {
+class _CellContainerNotifier extends ChangeNotifier {
   final CellEditable cellEditable;
   VoidCallback? _onCellFocusListener;
   bool _isFocus = false;
   bool _onEnter = false;
 
-  CellContainerNotifier(this.cellEditable) {
+  _CellContainerNotifier(this.cellEditable) {
     _onCellFocusListener = () => isFocus = cellEditable.onCellFocus.value;
     cellEditable.onCellFocus.addListener(_onCellFocusListener!);
   }

+ 2 - 1
frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/checkbox_cell.dart

@@ -22,7 +22,8 @@ class _CheckboxCellState extends GridCellState<GridCheckboxCell> {
 
   @override
   void initState() {
-    final cellController = widget.cellControllerBuilder.build();
+    final cellController =
+        widget.cellControllerBuilder.build() as GridCheckboxCellController;
     _cellBloc = getIt<CheckboxCellBloc>(param1: cellController)
       ..add(const CheckboxCellEvent.initial());
     super.initState();

+ 4 - 3
frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/extension.dart

@@ -73,7 +73,7 @@ class SelectOptionTag extends StatelessWidget {
     Key? key,
   }) : super(key: key);
 
-  factory SelectOptionTag.fromSelectOption({
+  factory SelectOptionTag.fromOption({
     required BuildContext context,
     required SelectOptionPB option,
     VoidCallback? onSelected,
@@ -91,7 +91,8 @@ class SelectOptionTag extends StatelessWidget {
   Widget build(BuildContext context) {
     return ChoiceChip(
       pressElevation: 1,
-      label: FlowyText.medium(name, fontSize: 12, overflow: TextOverflow.ellipsis),
+      label:
+          FlowyText.medium(name, fontSize: 12, overflow: TextOverflow.ellipsis),
       selectedColor: color,
       backgroundColor: color,
       labelPadding: const EdgeInsets.symmetric(horizontal: 6),
@@ -133,7 +134,7 @@ class SelectOptionTagCell extends StatelessWidget {
                   Flexible(
                     fit: FlexFit.loose,
                     flex: 2,
-                    child: SelectOptionTag.fromSelectOption(
+                    child: SelectOptionTag.fromOption(
                       context: context,
                       option: option,
                       onSelected: () => onSelected(option),

+ 23 - 20
frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/select_option_cell.dart

@@ -153,21 +153,25 @@ class SelectOptionWrap extends StatelessWidget {
     if (selectOptions.isEmpty && cellStyle != null) {
       child = Align(
         alignment: Alignment.centerLeft,
-        child: FlowyText.medium(cellStyle!.placeholder,
-            fontSize: 14, color: theme.shader3),
+        child: FlowyText.medium(
+          cellStyle!.placeholder,
+          fontSize: 14,
+          color: theme.shader3,
+        ),
       );
     } else {
-      final tags = selectOptions
-          .map(
-            (option) => SelectOptionTag.fromSelectOption(
-              context: context,
-              option: option,
-            ),
-          )
-          .toList();
       child = Align(
         alignment: Alignment.centerLeft,
-        child: Wrap(children: tags, spacing: 4, runSpacing: 2),
+        child: Wrap(
+          children: selectOptions
+              .map((option) => SelectOptionTag.fromOption(
+                    context: context,
+                    option: option,
+                  ))
+              .toList(),
+          spacing: 4,
+          runSpacing: 2,
+        ),
       );
     }
 
@@ -176,15 +180,14 @@ class SelectOptionWrap extends StatelessWidget {
       fit: StackFit.expand,
       children: [
         child,
-        InkWell(
-          onTap: () {
-            onFocus?.call(true);
-            final cellContext =
-                cellControllerBuilder.build() as GridSelectOptionCellController;
-            SelectOptionCellEditor.show(
-                context, cellContext, () => onFocus?.call(false));
-          },
-        ),
+        InkWell(onTap: () {
+          onFocus?.call(true);
+          SelectOptionCellEditor.show(
+            context,
+            cellControllerBuilder.build() as GridSelectOptionCellController,
+            () => onFocus?.call(false),
+          );
+        }),
       ],
     );
   }

+ 4 - 2
frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/text_field.dart

@@ -49,7 +49,8 @@ class SelectOptionTextField extends StatelessWidget {
       initialTags: selectedOptionMap.keys.toList(),
       focusNode: _focusNode,
       textSeparators: const [' ', ','],
-      inputfieldBuilder: (BuildContext context, editController, focusNode, error, onChanged, onSubmitted) {
+      inputfieldBuilder: (BuildContext context, editController, focusNode,
+          error, onChanged, onSubmitted) {
         return ((context, sc, tags, onTagDelegate) {
           return TextField(
             autofocus: true,
@@ -99,7 +100,8 @@ class SelectOptionTextField extends StatelessWidget {
     }
 
     final children = selectedOptionMap.values
-        .map((option) => SelectOptionTag.fromSelectOption(context: context, option: option))
+        .map((option) =>
+            SelectOptionTag.fromOption(context: context, option: option))
         .toList();
     return Padding(
       padding: const EdgeInsets.all(8.0),

+ 2 - 2
frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/url_cell/url_cell.dart

@@ -90,9 +90,9 @@ class _GridURLCellState extends GridCellState<GridURLCell> {
 
   @override
   void initState() {
-    final cellContext =
+    final cellController =
         widget.cellControllerBuilder.build() as GridURLCellController;
-    _cellBloc = URLCellBloc(cellController: cellContext);
+    _cellBloc = URLCellBloc(cellController: cellController);
     _cellBloc.add(const URLCellEvent.initial());
     super.initState();
   }

+ 16 - 17
frontend/app_flowy/lib/plugins/grid/presentation/widgets/row/grid_row.dart

@@ -164,7 +164,7 @@ class RowContent extends StatelessWidget {
   Widget build(BuildContext context) {
     return BlocBuilder<RowBloc, RowState>(
       buildWhen: (previous, current) =>
-          !listEquals(previous.snapshots, current.snapshots),
+          !listEquals(previous.cells, current.cells),
       builder: (context, state) {
         return IntrinsicHeight(
             child: Row(
@@ -181,28 +181,27 @@ class RowContent extends StatelessWidget {
     return gridCellMap.values.map(
       (cellId) {
         final GridCellWidget child = builder.build(cellId);
-        accessoryBuilder(GridCellAccessoryBuildContext buildContext) {
-          final builder = child.accessoryBuilder;
-          List<GridCellAccessory> accessories = [];
-          if (cellId.field.isPrimary) {
-            accessories.add(PrimaryCellAccessory(
-              onTapCallback: onExpand,
-              isCellEditing: buildContext.isCellEditing,
-            ));
-          }
-
-          if (builder != null) {
-            accessories.addAll(builder(buildContext));
-          }
-          return accessories;
-        }
 
         return CellContainer(
           width: cellId.field.width.toDouble(),
           child: child,
           rowStateNotifier:
               Provider.of<RegionStateNotifier>(context, listen: false),
-          accessoryBuilder: accessoryBuilder,
+          accessoryBuilder: (buildContext) {
+            final builder = child.accessoryBuilder;
+            List<GridCellAccessory> accessories = [];
+            if (cellId.field.isPrimary) {
+              accessories.add(PrimaryCellAccessory(
+                onTapCallback: onExpand,
+                isCellEditing: buildContext.isCellEditing,
+              ));
+            }
+
+            if (builder != null) {
+              accessories.addAll(builder(buildContext));
+            }
+            return accessories;
+          },
         );
       },
     ).toList();

+ 8 - 7
frontend/app_flowy/packages/appflowy_board/example/lib/multi_board_list_example.dart

@@ -23,18 +23,19 @@ class _MultiBoardListExampleState extends State<MultiBoardListExample> {
 
   @override
   void initState() {
-    final column1 = AFBoardColumnData(id: "To Do", items: [
+    List<AFColumnItem> a = [
       TextItem("Card 1"),
       TextItem("Card 2"),
-      RichTextItem(title: "Card 3", subtitle: 'Aug 1, 2020 4:05 PM'),
+      // RichTextItem(title: "Card 3", subtitle: 'Aug 1, 2020 4:05 PM'),
       TextItem("Card 4"),
-    ]);
-    final column2 = AFBoardColumnData(id: "In Progress", items: [
-      RichTextItem(title: "Card 5", subtitle: 'Aug 1, 2020 4:05 PM'),
-      TextItem("Card 6"),
+    ];
+    final column1 = AFBoardColumnData(id: "To Do", items: a);
+    final column2 = AFBoardColumnData(id: "In Progress", items: <AFColumnItem>[
+      // RichTextItem(title: "Card 5", subtitle: 'Aug 1, 2020 4:05 PM'),
+      // TextItem("Card 6"),
     ]);
 
-    final column3 = AFBoardColumnData(id: "Done", items: []);
+    final column3 = AFBoardColumnData(id: "Done", items: <AFColumnItem>[]);
 
     boardDataController.addColumn(column1);
     boardDataController.addColumn(column2);

+ 5 - 1
frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board.dart

@@ -46,6 +46,8 @@ class AFBoard extends StatelessWidget {
   ///
   final BoardPhantomController phantomController;
 
+  final ScrollController? scrollController;
+
   final AFBoardConfig config;
 
   AFBoard({
@@ -54,6 +56,7 @@ class AFBoard extends StatelessWidget {
     this.background,
     this.footBuilder,
     this.headerBuilder,
+    this.scrollController,
     this.columnConstraints = const BoxConstraints(maxWidth: 200),
     this.config = const AFBoardConfig(),
     Key? key,
@@ -69,6 +72,7 @@ class AFBoard extends StatelessWidget {
           return BoardContent(
             config: config,
             dataController: dataController,
+            scrollController: scrollController,
             background: background,
             delegate: phantomController,
             columnConstraints: columnConstraints,
@@ -202,7 +206,7 @@ class _BoardContentState extends State<BoardContent> {
         return ChangeNotifierProvider.value(
           key: ValueKey(columnData.id),
           value: widget.dataController.columnController(columnData.id),
-          child: Consumer<BoardColumnDataController>(
+          child: Consumer<AFBoardColumnDataController>(
             builder: (context, value, child) {
               return ConstrainedBox(
                 constraints: widget.columnConstraints,

+ 9 - 8
frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_column/board_column_data.dart

@@ -12,7 +12,7 @@ abstract class AFColumnItem extends ReoderFlexItem {
   String toString() => id;
 }
 
-/// [BoardColumnDataController] is used to handle the [AFBoardColumnData].
+/// [AFBoardColumnDataController] is used to handle the [AFBoardColumnData].
 /// * Remove an item by calling [removeAt] method.
 /// * Move item to another position by calling [move] method.
 /// * Insert item to index by calling [insert] method
@@ -20,10 +20,10 @@ abstract class AFColumnItem extends ReoderFlexItem {
 ///
 /// All there operations will notify listeners by default.
 ///
-class BoardColumnDataController extends ChangeNotifier with EquatableMixin {
+class AFBoardColumnDataController extends ChangeNotifier with EquatableMixin {
   final AFBoardColumnData columnData;
 
-  BoardColumnDataController({
+  AFBoardColumnDataController({
     required this.columnData,
   });
 
@@ -42,7 +42,8 @@ class BoardColumnDataController extends ChangeNotifier with EquatableMixin {
   AFColumnItem removeAt(int index, {bool notify = true}) {
     assert(index >= 0);
 
-    Log.debug('[$BoardColumnDataController] $columnData remove item at $index');
+    Log.debug(
+        '[$AFBoardColumnDataController] $columnData remove item at $index');
     final item = columnData._items.removeAt(index);
     if (notify) {
       notifyListeners();
@@ -64,7 +65,7 @@ class BoardColumnDataController extends ChangeNotifier with EquatableMixin {
       return false;
     }
     Log.debug(
-        '[$BoardColumnDataController] $columnData move item from $fromIndex to $toIndex');
+        '[$AFBoardColumnDataController] $columnData move item from $fromIndex to $toIndex');
     final item = columnData._items.removeAt(fromIndex);
     columnData._items.insert(toIndex, item);
     notifyListeners();
@@ -78,7 +79,7 @@ class BoardColumnDataController extends ChangeNotifier with EquatableMixin {
   bool insert(int index, AFColumnItem item, {bool notify = true}) {
     assert(index >= 0);
     Log.debug(
-        '[$BoardColumnDataController] $columnData insert $item at $index');
+        '[$AFBoardColumnDataController] $columnData insert $item at $index');
 
     if (columnData._items.length > index) {
       columnData._items.insert(index, item);
@@ -100,12 +101,12 @@ class BoardColumnDataController extends ChangeNotifier with EquatableMixin {
   void replace(int index, AFColumnItem newItem) {
     if (columnData._items.isEmpty) {
       columnData._items.add(newItem);
-      Log.debug('[$BoardColumnDataController] $columnData add $newItem');
+      Log.debug('[$AFBoardColumnDataController] $columnData add $newItem');
     } else {
       final removedItem = columnData._items.removeAt(index);
       columnData._items.insert(index, newItem);
       Log.debug(
-          '[$BoardColumnDataController] $columnData replace $removedItem with $newItem at $index');
+          '[$AFBoardColumnDataController] $columnData replace $removedItem with $newItem at $index');
     }
 
     notifyListeners();

+ 5 - 5
frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_data.dart

@@ -35,7 +35,7 @@ class AFBoardDataController extends ChangeNotifier
   List<String> get columnIds =>
       _columnDatas.map((columnData) => columnData.id).toList();
 
-  final LinkedHashMap<String, BoardColumnDataController> _columnControllers =
+  final LinkedHashMap<String, AFBoardColumnDataController> _columnControllers =
       LinkedHashMap();
 
   AFBoardDataController({
@@ -47,7 +47,7 @@ class AFBoardDataController extends ChangeNotifier
   void addColumn(AFBoardColumnData columnData, {bool notify = true}) {
     if (_columnControllers[columnData.id] != null) return;
 
-    final controller = BoardColumnDataController(columnData: columnData);
+    final controller = AFBoardColumnDataController(columnData: columnData);
     _columnDatas.add(columnData);
     _columnControllers[columnData.id] = controller;
     if (notify) notifyListeners();
@@ -84,11 +84,11 @@ class AFBoardDataController extends ChangeNotifier
     if (columnIds.isNotEmpty && notify) notifyListeners();
   }
 
-  BoardColumnDataController columnController(String columnId) {
+  AFBoardColumnDataController columnController(String columnId) {
     return _columnControllers[columnId]!;
   }
 
-  BoardColumnDataController? getColumnController(String columnId) {
+  AFBoardColumnDataController? getColumnController(String columnId) {
     final columnController = _columnControllers[columnId];
     if (columnController == null) {
       Log.warn('Column:[$columnId] \'s controller is not exist');
@@ -153,7 +153,7 @@ class AFBoardDataController extends ChangeNotifier
   }
 
   @override
-  BoardColumnDataController? controller(String columnId) {
+  AFBoardColumnDataController? controller(String columnId) {
     return _columnControllers[columnId];
   }
 

+ 1 - 1
frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_phantom/phantom_controller.dart

@@ -7,7 +7,7 @@ import '../reorder_flex/drag_target_inteceptor.dart';
 import 'phantom_state.dart';
 
 abstract class BoardPhantomControllerDelegate {
-  BoardColumnDataController? controller(String columnId);
+  AFBoardColumnDataController? controller(String columnId);
 
   bool removePhantom(String columnId);
 

+ 5 - 5
frontend/rust-lib/flowy-folder/tests/workspace/script.rs

@@ -150,7 +150,7 @@ impl FolderTest {
             //     assert_eq!(json, expected_json);
             // }
             FolderScript::AssertWorkspace(workspace) => {
-                assert_eq!(self.workspace, workspace);
+                assert_eq!(self.workspace, workspace, "Workspace not equal");
             }
             FolderScript::ReadWorkspace(workspace_id) => {
                 let workspace = read_workspace(sdk, workspace_id).await.pop().unwrap();
@@ -166,7 +166,7 @@ impl FolderTest {
             //     assert_eq!(json, expected_json);
             // }
             FolderScript::AssertApp(app) => {
-                assert_eq!(self.app, app);
+                assert_eq!(self.app, app, "App not equal");
             }
             FolderScript::ReadApp(app_id) => {
                 let app = read_app(sdk, &app_id).await;
@@ -184,7 +184,7 @@ impl FolderTest {
                 self.view = view;
             }
             FolderScript::AssertView(view) => {
-                assert_eq!(self.view, view);
+                assert_eq!(self.view, view, "View not equal");
             }
             FolderScript::ReadView(view_id) => {
                 let view = read_view(sdk, &view_id).await;
@@ -215,7 +215,7 @@ impl FolderTest {
             }
             FolderScript::AssertRevisionState { rev_id, state } => {
                 let record = cache.get(rev_id).await.unwrap();
-                assert_eq!(record.state, state);
+                assert_eq!(record.state, state, "Revision state is not match");
                 if let RevisionState::Ack = state {
                     // There is a defer action that writes the revisions to disk, so we wait here.
                     // Make sure everything is written.
@@ -235,7 +235,7 @@ impl FolderTest {
                     .unwrap_or_else(|| panic!("Expected Next revision is {}, but receive None", rev_id.unwrap()));
                 let mut notify = rev_manager.ack_notify();
                 let _ = notify.recv().await;
-                assert_eq!(next_revision.rev_id, rev_id.unwrap());
+                assert_eq!(next_revision.rev_id, rev_id.unwrap(), "Revision id not match");
             }
         }
     }

+ 4 - 4
frontend/rust-lib/flowy-grid/src/services/field/type_options/selection_type_option/multi_select_type_option.rs

@@ -97,7 +97,7 @@ pub struct MultiSelectTypeOptionBuilder(MultiSelectTypeOptionPB);
 impl_into_box_type_option_builder!(MultiSelectTypeOptionBuilder);
 impl_builder_from_json_str_and_from_bytes!(MultiSelectTypeOptionBuilder, MultiSelectTypeOptionPB);
 impl MultiSelectTypeOptionBuilder {
-    pub fn option(mut self, opt: SelectOptionPB) -> Self {
+    pub fn add_option(mut self, opt: SelectOptionPB) -> Self {
         self.0.options.push(opt);
         self
     }
@@ -127,9 +127,9 @@ mod tests {
         let facebook_option = SelectOptionPB::new("Facebook");
         let twitter_option = SelectOptionPB::new("Twitter");
         let multi_select = MultiSelectTypeOptionBuilder::default()
-            .option(google_option.clone())
-            .option(facebook_option.clone())
-            .option(twitter_option);
+            .add_option(google_option.clone())
+            .add_option(facebook_option.clone())
+            .add_option(twitter_option);
 
         let field_rev = FieldBuilder::new(multi_select)
             .name("Platform")

+ 2 - 2
frontend/rust-lib/flowy-grid/src/services/field/type_options/text_type_option/text_type_option.rs

@@ -156,8 +156,8 @@ mod tests {
         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()
-            .option(google_option.clone())
-            .option(facebook_option.clone());
+            .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

+ 22 - 7
frontend/rust-lib/flowy-grid/src/services/row/row_builder.rs

@@ -1,6 +1,5 @@
 use crate::services::cell::apply_cell_data_changeset;
-use crate::services::field::SelectOptionCellChangeset;
-use flowy_error::{FlowyError, FlowyResult};
+use crate::services::field::{DateCellChangesetPB, SelectOptionCellChangeset};
 use flowy_grid_data_model::revision::{gen_row_id, CellRevision, FieldRevision, RowRevision, DEFAULT_ROW_HEIGHT};
 use indexmap::IndexMap;
 use std::collections::HashMap;
@@ -35,17 +34,33 @@ impl<'a> RowRevisionBuilder<'a> {
         }
     }
 
-    pub fn insert_cell(&mut self, field_id: &str, data: String) -> FlowyResult<()> {
+    pub fn insert_cell(&mut self, field_id: &str, data: String) {
         match self.field_rev_map.get(&field_id.to_owned()) {
             None => {
-                let msg = format!("Can't find the field with id: {}", field_id);
-                Err(FlowyError::internal().context(msg))
+                tracing::warn!("Can't find the field with id: {}", field_id);
             }
             Some(field_rev) => {
-                let data = apply_cell_data_changeset(data, None, field_rev)?;
+                let data = apply_cell_data_changeset(data, None, field_rev).unwrap();
+                let cell = CellRevision::new(data);
+                self.payload.cell_by_field_id.insert(field_id.to_owned(), cell);
+            }
+        }
+    }
+
+    pub fn insert_date_cell(&mut self, field_id: &str, timestamp: i64) {
+        match self.field_rev_map.get(&field_id.to_owned()) {
+            None => {
+                tracing::warn!("Invalid field_id: {}", field_id);
+            }
+            Some(field_rev) => {
+                let cell_data = serde_json::to_string(&DateCellChangesetPB {
+                    date: Some(timestamp.to_string()),
+                    time: None,
+                })
+                .unwrap();
+                let data = apply_cell_data_changeset(cell_data, None, field_rev).unwrap();
                 let cell = CellRevision::new(data);
                 self.payload.cell_by_field_id.insert(field_id.to_owned(), cell);
-                Ok(())
             }
         }
     }

+ 74 - 3
frontend/rust-lib/flowy-grid/src/util.rs

@@ -3,6 +3,7 @@ use crate::services::field::*;
 use crate::services::row::RowRevisionBuilder;
 use flowy_grid_data_model::revision::BuildGridContext;
 use flowy_sync::client_grid::GridBuilder;
+use lib_infra::util::timestamp;
 
 pub fn make_default_grid() -> BuildGridContext {
     let mut grid_builder = GridBuilder::new();
@@ -40,24 +41,94 @@ pub fn make_default_board() -> BuildGridContext {
         .visibility(true)
         .primary(true)
         .build();
+    let text_field_id = text_field.id.clone();
     grid_builder.add_field(text_field);
 
+    // date
+    let date_type_option = DateTypeOptionBuilder::default();
+    let date_field = FieldBuilder::new(date_type_option)
+        .name("Date")
+        .visibility(true)
+        .build();
+    let date_field_id = date_field.id.clone();
+    let timestamp = timestamp();
+    grid_builder.add_field(date_field);
+
     // single select
     let in_progress_option = SelectOptionPB::new("In progress");
     let not_started_option = SelectOptionPB::new("Not started");
     let done_option = SelectOptionPB::new("Done");
-    let single_select = SingleSelectTypeOptionBuilder::default()
+    let single_select_type_option = SingleSelectTypeOptionBuilder::default()
         .add_option(not_started_option.clone())
         .add_option(in_progress_option)
         .add_option(done_option);
-    let single_select_field = FieldBuilder::new(single_select).name("Status").visibility(true).build();
+    let single_select_field = FieldBuilder::new(single_select_type_option)
+        .name("Status")
+        .visibility(true)
+        .build();
     let single_select_field_id = single_select_field.id.clone();
     grid_builder.add_field(single_select_field);
 
+    // MultiSelect
+    let apple_option = SelectOptionPB::new("Apple");
+    let banana_option = SelectOptionPB::new("Banana");
+    let pear_option = SelectOptionPB::new("Pear");
+    let multi_select_type_option = MultiSelectTypeOptionBuilder::default()
+        .add_option(banana_option.clone())
+        .add_option(apple_option.clone())
+        .add_option(pear_option.clone());
+    let multi_select_field = FieldBuilder::new(multi_select_type_option)
+        .name("Fruit")
+        .visibility(true)
+        .build();
+    let multi_select_field_id = multi_select_field.id.clone();
+    grid_builder.add_field(multi_select_field);
+
+    // Number
+    let number_type_option = NumberTypeOptionBuilder::default().set_format(NumberFormat::USD);
+    let number_field = FieldBuilder::new(number_type_option)
+        .name("Price")
+        .visibility(true)
+        .build();
+    let number_field_id = number_field.id.clone();
+    grid_builder.add_field(number_field);
+
+    // Checkbox
+    let checkbox_type_option = CheckboxTypeOptionBuilder::default();
+    let checkbox_field = FieldBuilder::new(checkbox_type_option).name("Reimbursement").build();
+    let checkbox_field_id = checkbox_field.id.clone();
+    grid_builder.add_field(checkbox_field);
+
+    // Url
+    let url_type_option = URLTypeOptionBuilder::default();
+    let url_field = FieldBuilder::new(url_type_option).name("Shop Link").build();
+    let url_field_id = url_field.id.clone();
+    grid_builder.add_field(url_field);
+
     // Insert rows
-    for _ in 0..3 {
+    for i in 0..10 {
+        // insert single select
         let mut row_builder = RowRevisionBuilder::new(grid_builder.block_id(), grid_builder.field_revs());
         row_builder.insert_select_option_cell(&single_select_field_id, not_started_option.id.clone());
+        // insert multi select
+        row_builder.insert_select_option_cell(&multi_select_field_id, apple_option.id.clone());
+        row_builder.insert_select_option_cell(&multi_select_field_id, banana_option.id.clone());
+        // insert text
+        row_builder.insert_cell(&text_field_id, format!("Card {}", i));
+        // insert date
+        row_builder.insert_date_cell(&date_field_id, timestamp);
+        // number
+        row_builder.insert_cell(&number_field_id, format!("{}", i));
+        // checkbox
+        let is_check = if i % 2 == 0 {
+            CHECK.to_string()
+        } else {
+            UNCHECK.to_string()
+        };
+        row_builder.insert_cell(&checkbox_field_id, is_check);
+        // url
+        row_builder.insert_cell(&url_field_id, "https://appflowy.io".to_string());
+
         let row = row_builder.build();
         grid_builder.add_row(row);
     }

+ 5 - 11
frontend/rust-lib/flowy-grid/tests/grid/block_test/util.rs

@@ -26,18 +26,14 @@ impl<'a> GridRowTestBuilder<'a> {
 
     pub fn insert_text_cell(&mut self, data: &str) -> String {
         let text_field = self.field_rev_with_type(&FieldType::RichText);
-        self.inner_builder
-            .insert_cell(&text_field.id, data.to_string())
-            .unwrap();
+        self.inner_builder.insert_cell(&text_field.id, data.to_string());
 
         text_field.id.clone()
     }
 
     pub fn insert_number_cell(&mut self, data: &str) -> String {
         let number_field = self.field_rev_with_type(&FieldType::Number);
-        self.inner_builder
-            .insert_cell(&number_field.id, data.to_string())
-            .unwrap();
+        self.inner_builder.insert_cell(&number_field.id, data.to_string());
         number_field.id.clone()
     }
 
@@ -48,22 +44,20 @@ impl<'a> GridRowTestBuilder<'a> {
         })
         .unwrap();
         let date_field = self.field_rev_with_type(&FieldType::DateTime);
-        self.inner_builder.insert_cell(&date_field.id, value).unwrap();
+        self.inner_builder.insert_cell(&date_field.id, value);
         date_field.id.clone()
     }
 
     pub fn insert_checkbox_cell(&mut self, data: &str) -> String {
         let checkbox_field = self.field_rev_with_type(&FieldType::Checkbox);
-        self.inner_builder
-            .insert_cell(&checkbox_field.id, data.to_string())
-            .unwrap();
+        self.inner_builder.insert_cell(&checkbox_field.id, data.to_string());
 
         checkbox_field.id.clone()
     }
 
     pub fn insert_url_cell(&mut self, data: &str) -> String {
         let url_field = self.field_rev_with_type(&FieldType::URL);
-        self.inner_builder.insert_cell(&url_field.id, data.to_string()).unwrap();
+        self.inner_builder.insert_cell(&url_field.id, data.to_string());
         url_field.id.clone()
     }
 

+ 3 - 3
frontend/rust-lib/flowy-grid/tests/grid/grid_editor.rs

@@ -147,9 +147,9 @@ fn make_test_grid() -> BuildGridContext {
             FieldType::MultiSelect => {
                 // MultiSelect
                 let multi_select = MultiSelectTypeOptionBuilder::default()
-                    .option(SelectOptionPB::new(GOOGLE))
-                    .option(SelectOptionPB::new(FACEBOOK))
-                    .option(SelectOptionPB::new(TWITTER));
+                    .add_option(SelectOptionPB::new(GOOGLE))
+                    .add_option(SelectOptionPB::new(FACEBOOK))
+                    .add_option(SelectOptionPB::new(TWITTER));
                 let multi_select_field = FieldBuilder::new(multi_select)
                     .name("Platform")
                     .visibility(true)