Browse Source

chore: grid row page detail redesign (#2351)

* chore: grid row page detail update

* chore: update row_detail.dart

Co-authored-by: Alex Wallen <[email protected]>

* chore: more adaptive and code cleanup

* feat: duplicate row

* feat: duplicate calendar event

* fix: ci

* feat: show other options

* fix: show include time

* fix: add key in RowCard to avoid incorrect data when open the row page

---------

Co-authored-by: Alex Wallen <[email protected]>
Co-authored-by: nathan <[email protected]>
Richard Shiue 2 years ago
parent
commit
77d58a81fd
27 changed files with 607 additions and 342 deletions
  1. 2 1
      frontend/appflowy_flutter/assets/translations/en.json
  2. 1 4
      frontend/appflowy_flutter/lib/plugins/database_view/application/cell/cell_controller.dart
  3. 7 2
      frontend/appflowy_flutter/lib/plugins/database_view/application/database_controller.dart
  4. 2 1
      frontend/appflowy_flutter/lib/plugins/database_view/application/view/view_cache.dart
  5. 1 1
      frontend/appflowy_flutter/lib/plugins/database_view/board/application/board_bloc.dart
  6. 5 5
      frontend/appflowy_flutter/lib/plugins/database_view/board/presentation/board_page.dart
  7. 46 16
      frontend/appflowy_flutter/lib/plugins/database_view/calendar/application/calendar_bloc.dart
  8. 146 77
      frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_day.dart
  9. 9 2
      frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_page.dart
  10. 1 1
      frontend/appflowy_flutter/lib/plugins/database_view/grid/application/grid_bloc.dart
  11. 18 7
      frontend/appflowy_flutter/lib/plugins/database_view/grid/application/row/row_detail_bloc.dart
  12. 67 28
      frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/card.dart
  13. 27 40
      frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/card_bloc.dart
  14. 8 5
      frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/card_cell_builder.dart
  15. 42 9
      frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/cells/card_cell.dart
  16. 10 7
      frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/cells/checkbox_card_cell.dart
  17. 12 1
      frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/cells/date_card_cell.dart
  18. 5 3
      frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/cells/select_option_card_cell.dart
  19. 16 3
      frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/cells/text_card_cell.dart
  20. 2 2
      frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/container/card_container.dart
  21. 138 110
      frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_detail.dart
  22. 1 1
      frontend/appflowy_flutter/lib/workspace/application/appearance.dart
  23. 3 1
      frontend/rust-lib/flowy-database/src/event_handler.rs
  24. 3 3
      frontend/rust-lib/flowy-database/src/services/cell/cell_operation.rs
  25. 25 1
      frontend/rust-lib/flowy-database/src/services/database/database_editor.rs
  26. 1 2
      frontend/rust-lib/flowy-database/src/services/field/type_options/date_type_option/date_type_option_entities.rs
  27. 9 9
      frontend/rust-lib/flowy-database/src/services/row/row_builder.rs

+ 2 - 1
frontend/appflowy_flutter/assets/translations/en.json

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

+ 1 - 4
frontend/appflowy_flutter/lib/plugins/database_view/application/cell/cell_controller.dart

@@ -76,10 +76,7 @@ class CellController<T, D> extends Equatable {
     _cellListener?.start(
       onCellChanged: (result) {
         result.fold(
-          (_) {
-            _cellCache.remove(_cacheKey);
-            _loadData();
-          },
+          (_) => _loadData(),
           (err) => Log.error(err),
         );
       },

+ 7 - 2
frontend/appflowy_flutter/lib/plugins/database_view/application/database_controller.dart

@@ -115,7 +115,7 @@ class DatabaseController {
     }
   }
 
-  void addListener({
+  void setListener({
     DatabaseCallbacks? onDatabaseChanged,
     LayoutCallbacks? onLayoutChanged,
     GroupCallbacks? onGroupChanged,
@@ -211,6 +211,11 @@ class DatabaseController {
     await _databaseViewBackendSvc.closeView();
     await fieldController.dispose();
     await groupListener.stop();
+    await _viewCache.dispose();
+    _databaseCallbacks = null;
+    _groupCallbacks = null;
+    _layoutCallbacks = null;
+    _calendarLayoutCallbacks = null;
   }
 
   Future<void> _loadGroups() async {
@@ -251,7 +256,7 @@ class DatabaseController {
         _databaseCallbacks?.onRowsCreated?.call(ids);
       },
     );
-    _viewCache.addListener(callbacks);
+    _viewCache.setListener(callbacks);
   }
 
   void _listenOnFieldsChanged() {

+ 2 - 1
frontend/appflowy_flutter/lib/plugins/database_view/application/view/view_cache.dart

@@ -111,9 +111,10 @@ class DatabaseViewCache {
   Future<void> dispose() async {
     await _databaseViewListener.stop();
     await _rowCache.dispose();
+    _callbacks = null;
   }
 
-  void addListener(DatabaseViewCallbacks callbacks) {
+  void setListener(DatabaseViewCallbacks callbacks) {
     _callbacks = callbacks;
   }
 }

+ 1 - 1
frontend/appflowy_flutter/lib/plugins/database_view/board/application/board_bloc.dart

@@ -236,7 +236,7 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
       },
     );
 
-    _databaseController.addListener(
+    _databaseController.setListener(
       onDatabaseChanged: onDatabaseChanged,
       onGroupChanged: onGroupChanged,
     );

+ 5 - 5
frontend/appflowy_flutter/lib/plugins/database_view/board/presentation/board_page.dart

@@ -78,7 +78,7 @@ class BoardContent extends StatefulWidget {
 
 class _BoardContentState extends State<BoardContent> {
   late AppFlowyBoardScrollController scrollManager;
-  final cardConfiguration = CardConfiguration<String>();
+  final renderHook = RowCardRenderHook<String>();
 
   final config = const AppFlowyBoardConfig(
     groupBackgroundColor: Color(0xffF7F8FC),
@@ -87,7 +87,7 @@ class _BoardContentState extends State<BoardContent> {
   @override
   void initState() {
     scrollManager = AppFlowyBoardScrollController();
-    cardConfiguration.addSelectOptionHook((options, groupId) {
+    renderHook.addSelectOptionHook((options, groupId, _) {
       // The cell should hide if the option id is equal to the groupId.
       final isInGroup =
           options.where((element) => element.id == groupId).isNotEmpty;
@@ -254,15 +254,15 @@ class _BoardContentState extends State<BoardContent> {
       key: ValueKey(groupItemId),
       margin: config.cardPadding,
       decoration: _makeBoxDecoration(context),
-      child: Card<String>(
+      child: RowCard<String>(
         row: rowPB,
         viewId: viewId,
         rowCache: rowCache,
         cardData: groupData.group.groupId,
-        fieldId: groupItem.fieldInfo.id,
+        groupingFieldId: groupItem.fieldInfo.id,
         isEditing: isEditing,
         cellBuilder: cellBuilder,
-        configuration: cardConfiguration,
+        renderHook: renderHook,
         openCard: (context) => _openCard(
           viewId,
           fieldController,

+ 46 - 16
frontend/appflowy_flutter/lib/plugins/database_view/calendar/application/calendar_bloc.dart

@@ -55,6 +55,13 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
           createEvent: (DateTime date, String title) async {
             await _createEvent(date, title);
           },
+          didCreateEvent: (CalendarEventData<CalendarDayEvent> event) {
+            emit(
+              state.copyWith(
+                createdEvent: event,
+              ),
+            );
+          },
           updateCalendarLayoutSetting:
               (CalendarLayoutSettingsPB layoutSetting) async {
             await _updateCalendarLayoutSetting(layoutSetting);
@@ -74,14 +81,6 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
               ),
             );
           },
-          didReceiveNewEvent: (CalendarEventData<CalendarDayEvent> event) {
-            emit(
-              state.copyWith(
-                allEvents: [...state.allEvents, event],
-                newEvent: event,
-              ),
-            );
-          },
           didDeleteEvents: (List<String> deletedRowIds) {
             var events = [...state.allEvents];
             events.retainWhere(
@@ -94,11 +93,25 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
               ),
             );
           },
+          didReceiveEvent: (CalendarEventData<CalendarDayEvent> event) {
+            emit(
+              state.copyWith(
+                allEvents: [...state.allEvents, event],
+                newEvent: event,
+              ),
+            );
+          },
         );
       },
     );
   }
 
+  @override
+  Future<void> close() async {
+    await _databaseController.dispose();
+    return super.close();
+  }
+
   FieldInfo? _getCalendarFieldInfo(String fieldId) {
     final fieldInfos = _databaseController.fieldController.fieldInfos;
     final index = fieldInfos.indexWhere(
@@ -142,17 +155,27 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
         final dateField = _getCalendarFieldInfo(settings.layoutFieldId);
         final titleField = _getTitleFieldInfo();
         if (dateField != null && titleField != null) {
-          final result = await _databaseController.createRow(
+          final newRow = await _databaseController.createRow(
             withCells: (builder) {
               builder.insertDate(dateField, date);
               builder.insertText(titleField, title);
             },
+          ).then(
+            (result) => result.fold(
+              (newRow) => newRow,
+              (err) {
+                Log.error(err);
+                return null;
+              },
+            ),
           );
 
-          return result.fold(
-            (newRow) {},
-            (err) => Log.error(err),
-          );
+          if (newRow != null) {
+            final event = await _loadEvent(newRow.id);
+            if (event != null && !isClosed) {
+              add(CalendarEvent.didCreateEvent(event));
+            }
+          }
         }
       },
     );
@@ -247,7 +270,7 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
         for (final id in ids) {
           final event = await _loadEvent(id);
           if (event != null && !isClosed) {
-            add(CalendarEvent.didReceiveNewEvent(event));
+            add(CalendarEvent.didReceiveEvent(event));
           }
         }
       }),
@@ -275,7 +298,7 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
       onCalendarLayoutChanged: _didReceiveNewLayoutField,
     );
 
-    _databaseController.addListener(
+    _databaseController.setListener(
       onDatabaseChanged: onDatabaseChanged,
       onLayoutChanged: onLayoutChanged,
       onCalendarLayoutChanged: onCalendarLayoutFieldChanged,
@@ -318,10 +341,15 @@ class CalendarEvent with _$CalendarEvent {
   ) = _DidUpdateEvent;
 
   // Called after creating a new event
-  const factory CalendarEvent.didReceiveNewEvent(
+  const factory CalendarEvent.didCreateEvent(
     CalendarEventData<CalendarDayEvent> event,
   ) = _DidReceiveNewEvent;
 
+  // Called when receive a new event
+  const factory CalendarEvent.didReceiveEvent(
+    CalendarEventData<CalendarDayEvent> event,
+  ) = _DidReceiveEvent;
+
   // Called when deleting events
   const factory CalendarEvent.didDeleteEvents(List<String> rowIds) =
       _DidDeleteEvents;
@@ -349,6 +377,7 @@ class CalendarState with _$CalendarState {
     required Option<DatabasePB> database,
     required Events allEvents,
     required Events initialEvents,
+    CalendarEventData<CalendarDayEvent>? createdEvent,
     CalendarEventData<CalendarDayEvent>? newEvent,
     required List<String> deleteEventIds,
     CalendarEventData<CalendarDayEvent>? updateEvent,
@@ -391,5 +420,6 @@ class CalendarDayEvent {
   final CellIdentifier cellId;
 
   String get eventId => cellId.rowId;
+  String get fieldId => cellId.fieldId;
   CalendarDayEvent({required this.cellId, required this.event});
 }

+ 146 - 77
frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_day.dart

@@ -1,20 +1,20 @@
 import 'package:appflowy/plugins/database_view/application/row/row_cache.dart';
 import 'package:appflowy/plugins/database_view/application/row/row_data_controller.dart';
+import 'package:appflowy/plugins/database_view/widgets/card/card.dart';
 import 'package:appflowy/plugins/database_view/widgets/card/card_cell_builder.dart';
-import 'package:appflowy/plugins/database_view/widgets/card/cells/text_card_cell.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/row_detail.dart';
-import 'package:appflowy_backend/protobuf/flowy-database/field_entities.pbenum.dart';
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flowy_infra/image.dart';
 import 'package:flowy_infra/size.dart';
 import 'package:flowy_infra/theme_extension.dart';
 import 'package:flowy_infra_ui/flowy_infra_ui.dart';
-import 'package:flowy_infra_ui/style_widget/hover.dart';
 import 'package:flutter/material.dart';
 import 'package:provider/provider.dart';
 
 import '../../grid/presentation/layout/sizes.dart';
+import '../../widgets/row/cells/select_option_cell/extension.dart';
 import '../application/calendar_bloc.dart';
 
 class CalendarDayCard extends StatelessWidget {
@@ -23,11 +23,10 @@ class CalendarDayCard extends StatelessWidget {
   final bool isInMonth;
   final DateTime date;
   final RowCache _rowCache;
-  final CardCellBuilder _cellBuilder;
   final List<CalendarDayEvent> events;
   final void Function(DateTime) onCreateEvent;
 
-  CalendarDayCard({
+  const CalendarDayCard({
     required this.viewId,
     required this.isToday,
     required this.isInMonth,
@@ -37,7 +36,6 @@ class CalendarDayCard extends StatelessWidget {
     required this.events,
     Key? key,
   })  : _rowCache = rowCache,
-        _cellBuilder = CardCellBuilder(rowCache.cellCache),
         super(key: key);
 
   @override
@@ -50,42 +48,38 @@ class CalendarDayCard extends StatelessWidget {
     return ChangeNotifierProvider(
       create: (_) => _CardEnterNotifier(),
       builder: (context, child) {
-        final children = events.map((event) {
-          return _DayEventCell(
-            event: event,
-            viewId: viewId,
-            onClick: () => _showRowDetailPage(event, context),
-            child: _cellBuilder.buildCell(
-              cellId: event.cellId,
-              styles: {FieldType.RichText: TextCardCellStyle(10)},
+        List<GestureDetector> cards = _buildCards(context);
+
+        Widget? multipleCards;
+        if (cards.isNotEmpty) {
+          multipleCards = Flexible(
+            child: ListView.separated(
+              itemBuilder: (BuildContext context, int index) {
+                return cards[index];
+              },
+              itemCount: cards.length,
+              padding: const EdgeInsets.symmetric(horizontal: 8.0),
+              separatorBuilder: (BuildContext context, int index) =>
+                  VSpace(GridSize.typeOptionSeparatorHeight),
             ),
           );
-        }).toList();
+        }
 
         final child = Column(
           mainAxisSize: MainAxisSize.min,
           children: [
-            Padding(
-              padding: const EdgeInsets.symmetric(horizontal: 8.0),
-              child: _Header(
-                date: date,
-                isInMonth: isInMonth,
-                isToday: isToday,
-                onCreate: () => onCreateEvent(date),
-              ),
+            _Header(
+              date: date,
+              isInMonth: isInMonth,
+              isToday: isToday,
+              onCreate: () => onCreateEvent(date),
             ),
+
+            // Add a separator between the header and the content.
             VSpace(GridSize.typeOptionSeparatorHeight),
-            Flexible(
-              child: ListView.separated(
-                itemBuilder: (BuildContext context, int index) {
-                  return children[index];
-                },
-                itemCount: children.length,
-                padding: const EdgeInsets.symmetric(horizontal: 8.0),
-                separatorBuilder: (BuildContext context, int index) =>
-                    VSpace(GridSize.typeOptionSeparatorHeight),
-              ),
-            ),
+
+            // Use SizedBox instead of ListView if there are no cards.
+            multipleCards ?? const SizedBox(),
           ],
         );
 
@@ -96,7 +90,7 @@ class CalendarDayCard extends StatelessWidget {
             onEnter: (p) => notifyEnter(context, true),
             onExit: (p) => notifyEnter(context, false),
             child: Padding(
-              padding: const EdgeInsets.symmetric(vertical: 8.0),
+              padding: const EdgeInsets.symmetric(vertical: 2.0),
               child: child,
             ),
           ),
@@ -105,6 +99,113 @@ class CalendarDayCard extends StatelessWidget {
     );
   }
 
+  List<GestureDetector> _buildCards(BuildContext context) {
+    final children = events.map((CalendarDayEvent event) {
+      final cellBuilder = CardCellBuilder<String>(_rowCache.cellCache);
+      final rowInfo = _rowCache.getRow(event.eventId);
+
+      final renderHook = RowCardRenderHook<String>();
+      renderHook.addTextFieldHook((cellData, primaryFieldId, _) {
+        if (cellData.isEmpty) {
+          return const SizedBox();
+        }
+        return Align(
+          alignment: Alignment.centerLeft,
+          child: FlowyText.medium(
+            cellData,
+            textAlign: TextAlign.left,
+            fontSize: 11,
+            maxLines: null, // Enable multiple lines
+          ),
+        );
+      });
+
+      renderHook.addDateFieldHook((cellData, cardData, _) {
+        return Align(
+          alignment: Alignment.centerLeft,
+          child: Padding(
+            padding: const EdgeInsets.symmetric(vertical: 2),
+            child: Row(
+              children: [
+                FlowyText.regular(
+                  cellData.date,
+                  fontSize: 10,
+                  color: Theme.of(context).hintColor,
+                ),
+                const Spacer(),
+                FlowyText.regular(
+                  cellData.time,
+                  fontSize: 10,
+                  color: Theme.of(context).hintColor,
+                )
+              ],
+            ),
+          ),
+        );
+      });
+
+      renderHook.addSelectOptionHook((selectedOptions, cardData, _) {
+        final children = selectedOptions.map(
+          (option) {
+            return SelectOptionTag.fromOption(
+              context: context,
+              option: option,
+            );
+          },
+        ).toList();
+
+        return IntrinsicHeight(
+          child: Padding(
+            padding: const EdgeInsets.symmetric(vertical: 2),
+            child: SizedBox.expand(
+              child: Wrap(spacing: 4, runSpacing: 4, children: children),
+            ),
+          ),
+        );
+      });
+
+      // renderHook.addDateFieldHook((cellData, cardData) {
+
+      final card = RowCard<String>(
+        // Add the key here to make sure the card is rebuilt when the cells
+        // in this row are updated.
+        key: ValueKey(event.eventId),
+        row: rowInfo!.rowPB,
+        viewId: viewId,
+        rowCache: _rowCache,
+        cardData: event.fieldId,
+        isEditing: false,
+        cellBuilder: cellBuilder,
+        openCard: (context) => _showRowDetailPage(event, context),
+        styleConfiguration: const RowCardStyleConfiguration(
+          showAccessory: false,
+          cellPadding: EdgeInsets.zero,
+        ),
+        renderHook: renderHook,
+        onStartEditing: () {},
+        onEndEditing: () {},
+      );
+
+      return GestureDetector(
+        onTap: () => _showRowDetailPage(event, context),
+        child: Container(
+          padding: const EdgeInsets.symmetric(horizontal: 2),
+          decoration: BoxDecoration(
+            border: Border.fromBorderSide(
+              BorderSide(
+                color: Theme.of(context).dividerColor,
+                width: 1.5,
+              ),
+            ),
+            borderRadius: Corners.s6Border,
+          ),
+          child: card,
+        ),
+      );
+    }).toList();
+    return children;
+  }
+
   void _showRowDetailPage(CalendarDayEvent event, BuildContext context) {
     final dataController = RowController(
       rowId: event.cellId.rowId,
@@ -133,42 +234,6 @@ class CalendarDayCard extends StatelessWidget {
   }
 }
 
-class _DayEventCell extends StatelessWidget {
-  final String viewId;
-  final CalendarDayEvent event;
-  final VoidCallback onClick;
-  final Widget child;
-  const _DayEventCell({
-    required this.viewId,
-    required this.event,
-    required this.onClick,
-    required this.child,
-    Key? key,
-  }) : super(key: key);
-
-  @override
-  Widget build(BuildContext context) {
-    return FlowyHover(
-      child: GestureDetector(
-        onTap: onClick,
-        child: Container(
-          padding: const EdgeInsets.symmetric(horizontal: 8),
-          decoration: BoxDecoration(
-            border: Border.fromBorderSide(
-              BorderSide(
-                color: Theme.of(context).dividerColor,
-                width: 1.0,
-              ),
-            ),
-            borderRadius: Corners.s6Border,
-          ),
-          child: child,
-        ),
-      ),
-    );
-  }
-}
-
 class _Header extends StatelessWidget {
   final bool isToday;
   final bool isInMonth;
@@ -191,12 +256,16 @@ class _Header extends StatelessWidget {
           isInMonth: isInMonth,
           date: date,
         );
-        return Row(
-          children: [
-            if (notifier.onEnter) _NewEventButton(onClick: onCreate),
-            const Spacer(),
-            badge,
-          ],
+
+        return Padding(
+          padding: const EdgeInsets.symmetric(horizontal: 8.0),
+          child: Row(
+            children: [
+              if (notifier.onEnter) _NewEventButton(onClick: onCreate),
+              const Spacer(),
+              badge,
+            ],
+          ),
         );
       },
     );

+ 9 - 2
frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_page.dart

@@ -85,13 +85,20 @@ class _CalendarPageState extends State<CalendarPage> {
                 }
               },
             ),
+            BlocListener<CalendarBloc, CalendarState>(
+              listenWhen: (p, c) => p.createdEvent != c.createdEvent,
+              listener: (context, state) {
+                if (state.createdEvent != null) {
+                  _showRowDetailPage(state.createdEvent!.event!, context);
+                }
+              },
+            ),
             BlocListener<CalendarBloc, CalendarState>(
               listenWhen: (p, c) => p.newEvent != c.newEvent,
               listener: (context, state) {
                 if (state.newEvent != null) {
                   _eventController.add(state.newEvent!);
                 }
-                _showRowDetailPage(state.newEvent!.event!, context);
               },
             ),
           ],
@@ -120,7 +127,7 @@ class _CalendarPageState extends State<CalendarPage> {
       child: MonthView(
         key: _calendarState,
         controller: _eventController,
-        cellAspectRatio: .9,
+        cellAspectRatio: .6,
         startDay: _weekdayFromInt(firstDayOfWeek),
         borderColor: Theme.of(context).dividerColor,
         headerBuilder: _headerNavigatorBuilder,

+ 1 - 1
frontend/appflowy_flutter/lib/plugins/database_view/grid/application/grid_bloc.dart

@@ -87,7 +87,7 @@ class GridBloc extends Bloc<GridEvent, GridState> {
         }
       },
     );
-    databaseController.addListener(onDatabaseChanged: onDatabaseChanged);
+    databaseController.setListener(onDatabaseChanged: onDatabaseChanged);
   }
 
   Future<void> _openGrid(Emitter<GridState> emit) async {

+ 18 - 7
frontend/appflowy_flutter/lib/plugins/database_view/grid/application/row/row_detail_bloc.dart

@@ -1,3 +1,4 @@
+import 'package:appflowy/plugins/database_view/application/row/row_service.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
 import 'package:freezed_annotation/freezed_annotation.dart';
 import 'dart:async';
@@ -7,31 +8,39 @@ import '../../../application/row/row_data_controller.dart';
 part 'row_detail_bloc.freezed.dart';
 
 class RowDetailBloc extends Bloc<RowDetailEvent, RowDetailState> {
+  final RowBackendService rowService;
   final RowController dataController;
 
   RowDetailBloc({
     required this.dataController,
-  }) : super(RowDetailState.initial()) {
+  })  : rowService = RowBackendService(viewId: dataController.viewId),
+        super(RowDetailState.initial()) {
     on<RowDetailEvent>(
       (event, emit) async {
-        await event.map(
-          initial: (_Initial value) async {
+        await event.when(
+          initial: () async {
             await _startListening();
             final cells = dataController.loadData();
             if (!isClosed) {
               add(RowDetailEvent.didReceiveCellDatas(cells.values.toList()));
             }
           },
-          didReceiveCellDatas: (_DidReceiveCellDatas value) {
-            emit(state.copyWith(gridCells: value.gridCells));
+          didReceiveCellDatas: (cells) {
+            emit(state.copyWith(gridCells: cells));
           },
-          deleteField: (_DeleteField value) {
+          deleteField: (fieldId) {
             final fieldService = FieldBackendService(
               viewId: dataController.viewId,
-              fieldId: value.fieldId,
+              fieldId: fieldId,
             );
             fieldService.deleteField();
           },
+          deleteRow: (rowId) async {
+            await rowService.deleteRow(rowId);
+          },
+          duplicateRow: (String rowId) async {
+            await rowService.duplicateRow(rowId);
+          },
         );
       },
     );
@@ -58,6 +67,8 @@ class RowDetailBloc extends Bloc<RowDetailEvent, RowDetailState> {
 class RowDetailEvent with _$RowDetailEvent {
   const factory RowDetailEvent.initial() = _Initial;
   const factory RowDetailEvent.deleteField(String fieldId) = _DeleteField;
+  const factory RowDetailEvent.deleteRow(String rowId) = _DeleteRow;
+  const factory RowDetailEvent.duplicateRow(String rowId) = _DuplicateRow;
   const factory RowDetailEvent.didReceiveCellDatas(
     List<CellIdentifier> gridCells,
   ) = _DidReceiveCellDatas;

+ 67 - 28
frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/card.dart

@@ -1,3 +1,4 @@
+import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart';
 import 'package:appflowy/plugins/database_view/application/row/row_cache.dart';
 import 'package:appflowy/plugins/database_view/grid/presentation/widgets/row/action.dart';
 import 'package:appflowy_backend/protobuf/flowy-database/row_entities.pb.dart';
@@ -13,23 +14,40 @@ import 'card_cell_builder.dart';
 import 'container/accessory.dart';
 import 'container/card_container.dart';
 
-class Card<CustomCardData> extends StatefulWidget {
+/// Edit a database row with card style widget
+class RowCard<CustomCardData> extends StatefulWidget {
   final RowPB row;
   final String viewId;
-  final String fieldId;
+  final String? groupingFieldId;
+
+  /// Allows passing a custom card data object to the card. The card will be
+  /// returned in the [CardCellBuilder] and can be used to build the card.
   final CustomCardData? cardData;
   final bool isEditing;
   final RowCache rowCache;
+
+  /// The [CardCellBuilder] is used to build the card cells.
   final CardCellBuilder<CustomCardData> cellBuilder;
+
+  /// Called when the user taps on the card.
   final void Function(BuildContext) openCard;
+
+  /// Called when the user starts editing the card.
   final VoidCallback onStartEditing;
+
+  /// Called when the user ends editing the card.
   final VoidCallback onEndEditing;
-  final CardConfiguration<CustomCardData>? configuration;
 
-  const Card({
+  /// The [RowCardRenderHook] is used to render the card's cell. Other than
+  /// using the default cell builder. For example the [SelectOptionCardCell]
+  final RowCardRenderHook<CustomCardData>? renderHook;
+
+  final RowCardStyleConfiguration styleConfiguration;
+
+  const RowCard({
     required this.row,
     required this.viewId,
-    required this.fieldId,
+    this.groupingFieldId,
     required this.isEditing,
     required this.rowCache,
     required this.cellBuilder,
@@ -37,15 +55,19 @@ class Card<CustomCardData> extends StatefulWidget {
     required this.onStartEditing,
     required this.onEndEditing,
     this.cardData,
-    this.configuration,
+    this.styleConfiguration = const RowCardStyleConfiguration(
+      showAccessory: true,
+    ),
+    this.renderHook,
     Key? key,
   }) : super(key: key);
 
   @override
-  State<Card<CustomCardData>> createState() => _CardState<CustomCardData>();
+  State<RowCard<CustomCardData>> createState() =>
+      _RowCardState<CustomCardData>();
 }
 
-class _CardState<T> extends State<Card<T>> {
+class _RowCardState<T> extends State<RowCard<T>> {
   late CardBloc _cardBloc;
   late EditableRowNotifier rowNotifier;
   late PopoverController popoverController;
@@ -56,15 +78,15 @@ class _CardState<T> extends State<Card<T>> {
     rowNotifier = EditableRowNotifier(isEditing: widget.isEditing);
     _cardBloc = CardBloc(
       viewId: widget.viewId,
-      groupFieldId: widget.fieldId,
+      groupFieldId: widget.groupingFieldId,
       isEditing: widget.isEditing,
       row: widget.row,
       rowCache: widget.rowCache,
-    )..add(const BoardCardEvent.initial());
+    )..add(const RowCardEvent.initial());
 
     rowNotifier.isEditing.addListener(() {
       if (!mounted) return;
-      _cardBloc.add(BoardCardEvent.setIsEditing(rowNotifier.isEditing.value));
+      _cardBloc.add(RowCardEvent.setIsEditing(rowNotifier.isEditing.value));
 
       if (rowNotifier.isEditing.value) {
         widget.onStartEditing();
@@ -81,7 +103,7 @@ class _CardState<T> extends State<Card<T>> {
   Widget build(BuildContext context) {
     return BlocProvider.value(
       value: _cardBloc,
-      child: BlocBuilder<CardBloc, BoardCardState>(
+      child: BlocBuilder<CardBloc, RowCardState>(
         buildWhen: (previous, current) {
           // Rebuild when:
           // 1.If the length of the cells is not the same
@@ -106,21 +128,26 @@ class _CardState<T> extends State<Card<T>> {
               context,
               popoverContext,
             ),
-            child: BoardCardContainer(
+            child: RowCardContainer(
               buildAccessoryWhen: () => state.isEditing == false,
               accessoryBuilder: (context) {
-                return [
-                  _CardEditOption(rowNotifier: rowNotifier),
-                  _CardMoreOption(),
-                ];
+                if (widget.styleConfiguration.showAccessory == false) {
+                  return [];
+                } else {
+                  return [
+                    _CardEditOption(rowNotifier: rowNotifier),
+                    _CardMoreOption(),
+                  ];
+                }
               },
               openAccessory: _handleOpenAccessory,
               openCard: (context) => widget.openCard(context),
               child: _CardContent<T>(
                 rowNotifier: rowNotifier,
                 cellBuilder: widget.cellBuilder,
+                styleConfiguration: widget.styleConfiguration,
                 cells: state.cells,
-                cardConfiguration: widget.configuration,
+                renderHook: widget.renderHook,
                 cardData: widget.cardData,
               ),
             ),
@@ -166,15 +193,17 @@ class _CardState<T> extends State<Card<T>> {
 class _CardContent<CustomCardData> extends StatelessWidget {
   final CardCellBuilder<CustomCardData> cellBuilder;
   final EditableRowNotifier rowNotifier;
-  final List<BoardCellEquatable> cells;
-  final CardConfiguration<CustomCardData>? cardConfiguration;
+  final List<CellIdentifier> cells;
+  final RowCardRenderHook<CustomCardData>? renderHook;
   final CustomCardData? cardData;
+  final RowCardStyleConfiguration styleConfiguration;
   const _CardContent({
     required this.rowNotifier,
     required this.cellBuilder,
     required this.cells,
     required this.cardData,
-    this.cardConfiguration,
+    required this.styleConfiguration,
+    this.renderHook,
     Key? key,
   }) : super(key: key);
 
@@ -188,30 +217,30 @@ class _CardContent<CustomCardData> extends StatelessWidget {
 
   List<Widget> _makeCells(
     BuildContext context,
-    List<BoardCellEquatable> cells,
+    List<CellIdentifier> cells,
   ) {
     final List<Widget> children = [];
     // Remove all the cell listeners.
     rowNotifier.unbind();
 
     cells.asMap().forEach(
-      (int index, BoardCellEquatable cell) {
+      (int index, CellIdentifier cell) {
         final isEditing = index == 0 ? rowNotifier.isEditing.value : false;
         final cellNotifier = EditableCardNotifier(isEditing: isEditing);
 
         if (index == 0) {
           // Only use the first cell to receive user's input when click the edit
           // button
-          rowNotifier.bindCell(cell.identifier, cellNotifier);
+          rowNotifier.bindCell(cell, cellNotifier);
         }
 
         final child = Padding(
-          key: cell.identifier.key(),
-          padding: const EdgeInsets.only(left: 4, right: 4),
+          key: cell.key(),
+          padding: styleConfiguration.cellPadding,
           child: cellBuilder.buildCell(
-            cellId: cell.identifier,
+            cellId: cell,
             cellNotifier: cellNotifier,
-            cardConfiguration: cardConfiguration,
+            renderHook: renderHook,
             cardData: cardData,
           ),
         );
@@ -265,3 +294,13 @@ class _CardEditOption extends StatelessWidget with CardAccessory {
   @override
   AccessoryType get type => AccessoryType.edit;
 }
+
+class RowCardStyleConfiguration {
+  final bool showAccessory;
+  final EdgeInsets cellPadding;
+
+  const RowCardStyleConfiguration({
+    this.showAccessory = true,
+    this.cellPadding = const EdgeInsets.only(left: 4, right: 4),
+  });
+}

+ 27 - 40
frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/card_bloc.dart

@@ -1,5 +1,4 @@
 import 'dart:collection';
-import 'package:equatable/equatable.dart';
 import 'package:appflowy_backend/protobuf/flowy-database/row_entities.pb.dart';
 import 'package:flutter/foundation.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
@@ -12,9 +11,9 @@ import '../../application/row/row_service.dart';
 
 part 'card_bloc.freezed.dart';
 
-class CardBloc extends Bloc<BoardCardEvent, BoardCardState> {
+class CardBloc extends Bloc<RowCardEvent, RowCardState> {
   final RowPB row;
-  final String groupFieldId;
+  final String? groupFieldId;
   final RowBackendService _rowBackendSvc;
   final RowCache _rowCache;
   VoidCallback? _rowCallback;
@@ -28,13 +27,13 @@ class CardBloc extends Bloc<BoardCardEvent, BoardCardState> {
   })  : _rowBackendSvc = RowBackendService(viewId: viewId),
         _rowCache = rowCache,
         super(
-          BoardCardState.initial(
+          RowCardState.initial(
             row,
             _makeCells(groupFieldId, rowCache.loadGridCells(row.id)),
             isEditing,
           ),
         ) {
-    on<BoardCardEvent>(
+    on<RowCardEvent>(
       (event, emit) async {
         await event.when(
           initial: () async {
@@ -69,7 +68,7 @@ class CardBloc extends Bloc<BoardCardEvent, BoardCardState> {
     return RowInfo(
       viewId: _rowBackendSvc.viewId,
       fields: UnmodifiableListView(
-        state.cells.map((cell) => cell.identifier.fieldInfo).toList(),
+        state.cells.map((cell) => cell.fieldInfo).toList(),
       ),
       rowPB: state.rowPB,
     );
@@ -81,70 +80,58 @@ class CardBloc extends Bloc<BoardCardEvent, BoardCardState> {
       onCellUpdated: (cellMap, reason) {
         if (!isClosed) {
           final cells = _makeCells(groupFieldId, cellMap);
-          add(BoardCardEvent.didReceiveCells(cells, reason));
+          add(RowCardEvent.didReceiveCells(cells, reason));
         }
       },
     );
   }
 }
 
-List<BoardCellEquatable> _makeCells(
-  String groupFieldId,
+List<CellIdentifier> _makeCells(
+  String? groupFieldId,
   CellByFieldId originalCellMap,
 ) {
-  List<BoardCellEquatable> cells = [];
+  List<CellIdentifier> cells = [];
   for (final entry in originalCellMap.entries) {
     // Filter out the cell if it's fieldId equal to the groupFieldId
-    if (entry.value.fieldId != groupFieldId) {
-      cells.add(BoardCellEquatable(entry.value));
+    if (groupFieldId != null) {
+      if (entry.value.fieldId == groupFieldId) {
+        continue;
+      }
     }
+
+    cells.add(entry.value);
   }
   return cells;
 }
 
 @freezed
-class BoardCardEvent with _$BoardCardEvent {
-  const factory BoardCardEvent.initial() = _InitialRow;
-  const factory BoardCardEvent.setIsEditing(bool isEditing) = _IsEditing;
-  const factory BoardCardEvent.didReceiveCells(
-    List<BoardCellEquatable> cells,
+class RowCardEvent with _$RowCardEvent {
+  const factory RowCardEvent.initial() = _InitialRow;
+  const factory RowCardEvent.setIsEditing(bool isEditing) = _IsEditing;
+  const factory RowCardEvent.didReceiveCells(
+    List<CellIdentifier> cells,
     RowsChangedReason reason,
   ) = _DidReceiveCells;
 }
 
 @freezed
-class BoardCardState with _$BoardCardState {
-  const factory BoardCardState({
+class RowCardState with _$RowCardState {
+  const factory RowCardState({
     required RowPB rowPB,
-    required List<BoardCellEquatable> cells,
+    required List<CellIdentifier> cells,
     required bool isEditing,
     RowsChangedReason? changeReason,
-  }) = _BoardCardState;
+  }) = _RowCardState;
 
-  factory BoardCardState.initial(
+  factory RowCardState.initial(
     RowPB rowPB,
-    List<BoardCellEquatable> cells,
+    List<CellIdentifier> cells,
     bool isEditing,
   ) =>
-      BoardCardState(
+      RowCardState(
         rowPB: rowPB,
         cells: cells,
         isEditing: isEditing,
       );
 }
-
-class BoardCellEquatable extends Equatable {
-  final CellIdentifier identifier;
-
-  const BoardCellEquatable(this.identifier);
-
-  @override
-  List<Object?> get props {
-    return [
-      identifier.fieldInfo.id,
-      identifier.fieldInfo.fieldType,
-      identifier.fieldInfo.visibility,
-      identifier.fieldInfo.width,
-    ];
-  }
-}

+ 8 - 5
frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/card_cell_builder.dart

@@ -22,7 +22,7 @@ class CardCellBuilder<CustomCardData> {
     CustomCardData? cardData,
     required CellIdentifier cellId,
     EditableCardNotifier? cellNotifier,
-    CardConfiguration<CustomCardData>? cardConfiguration,
+    RowCardRenderHook<CustomCardData>? renderHook,
     Map<FieldType, CardCellStyle>? styles,
   }) {
     final cellControllerBuilder = CellControllerBuilder(
@@ -39,20 +39,21 @@ class CardCellBuilder<CustomCardData> {
           key: key,
         );
       case FieldType.DateTime:
-        return DateCardCell(
+        return DateCardCell<CustomCardData>(
+          renderHook: renderHook?.renderHook[FieldType.DateTime],
           cellControllerBuilder: cellControllerBuilder,
           key: key,
         );
       case FieldType.SingleSelect:
         return SelectOptionCardCell<CustomCardData>(
-          renderHook: cardConfiguration?.renderHook[FieldType.SingleSelect],
+          renderHook: renderHook?.renderHook[FieldType.SingleSelect],
           cellControllerBuilder: cellControllerBuilder,
           cardData: cardData,
           key: key,
         );
       case FieldType.MultiSelect:
         return SelectOptionCardCell<CustomCardData>(
-          renderHook: cardConfiguration?.renderHook[FieldType.MultiSelect],
+          renderHook: renderHook?.renderHook[FieldType.MultiSelect],
           cellControllerBuilder: cellControllerBuilder,
           cardData: cardData,
           editableNotifier: cellNotifier,
@@ -69,9 +70,11 @@ class CardCellBuilder<CustomCardData> {
           key: key,
         );
       case FieldType.RichText:
-        return TextCardCell(
+        return TextCardCell<CustomCardData>(
+          renderHook: renderHook?.renderHook[FieldType.RichText],
           cellControllerBuilder: cellControllerBuilder,
           editableNotifier: cellNotifier,
+          cardData: cardData,
           style: isStyleOrNull<TextCardCellStyle>(style),
           key: key,
         );

+ 42 - 9
frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/cells/card_cell.dart

@@ -1,26 +1,59 @@
 import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart';
+import 'package:appflowy_backend/log.dart';
+import 'package:appflowy_backend/protobuf/flowy-database/date_type_option_entities.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-database/field_entities.pbenum.dart';
 import 'package:appflowy_backend/protobuf/flowy-database/select_type_option.pb.dart';
 import 'package:flutter/material.dart';
 
-typedef CellRenderHook<C, T> = Widget? Function(C cellData, T cardData);
+typedef CellRenderHook<C, CustomCardData> = Widget? Function(
+  C cellData,
+  CustomCardData cardData,
+  BuildContext buildContext,
+);
 typedef RenderHookByFieldType<C> = Map<FieldType, CellRenderHook<dynamic, C>>;
 
-class CardConfiguration<CustomCardData> {
+class RowCardRenderHook<CustomCardData> {
   final RenderHookByFieldType<CustomCardData> renderHook = {};
-  CardConfiguration();
+  RowCardRenderHook();
 
+  /// Add render hook for the FieldType.SingleSelect and FieldType.MultiSelect
   void addSelectOptionHook(
-    CellRenderHook<List<SelectOptionPB>, CustomCardData> hook,
+    CellRenderHook<List<SelectOptionPB>, CustomCardData?> hook,
   ) {
-    selectOptionHook(cellData, cardData) {
-      if (cellData is List<SelectOptionPB>) {
-        hook(cellData, cardData);
+    final hookFn = _typeSafeHook<List<SelectOptionPB>>(hook);
+    renderHook[FieldType.SingleSelect] = hookFn;
+    renderHook[FieldType.MultiSelect] = hookFn;
+  }
+
+  void addTextFieldHook(
+    CellRenderHook<String, CustomCardData?> hook,
+  ) {
+    renderHook[FieldType.RichText] = _typeSafeHook<String>(hook);
+  }
+
+  void addDateFieldHook(
+    CellRenderHook<DateCellDataPB, CustomCardData?> hook,
+  ) {
+    renderHook[FieldType.DateTime] = _typeSafeHook<DateCellDataPB>(hook);
+  }
+
+  CellRenderHook<dynamic, CustomCardData> _typeSafeHook<C>(
+    CellRenderHook<C, CustomCardData?> hook,
+  ) {
+    hookFn(cellData, cardData, buildContext) {
+      if (cellData == null) {
+        return null;
+      }
+
+      if (cellData is C) {
+        return hook(cellData, cardData, buildContext);
+      } else {
+        Log.debug("Unexpected cellData type: ${cellData.runtimeType}");
+        return null;
       }
     }
 
-    renderHook[FieldType.SingleSelect] = selectOptionHook;
-    renderHook[FieldType.MultiSelect] = selectOptionHook;
+    return hookFn;
   }
 }
 

+ 10 - 7
frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/cells/checkbox_card_cell.dart

@@ -44,13 +44,16 @@ class _CheckboxCardCellState extends State<CheckboxCardCell> {
               : svgWidget('editor/editor_uncheck');
           return Align(
             alignment: Alignment.centerLeft,
-            child: FlowyIconButton(
-              iconPadding: EdgeInsets.zero,
-              icon: icon,
-              width: 20,
-              onPressed: () => context
-                  .read<CheckboxCardCellBloc>()
-                  .add(const CheckboxCardCellEvent.select()),
+            child: Padding(
+              padding: const EdgeInsets.symmetric(vertical: 2),
+              child: FlowyIconButton(
+                iconPadding: EdgeInsets.zero,
+                icon: icon,
+                width: 20,
+                onPressed: () => context
+                    .read<CheckboxCardCellBloc>()
+                    .add(const CheckboxCardCellEvent.select()),
+              ),
             ),
           );
         },

+ 12 - 1
frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/cells/date_card_cell.dart

@@ -7,11 +7,13 @@ import '../bloc/date_card_cell_bloc.dart';
 import '../define.dart';
 import 'card_cell.dart';
 
-class DateCardCell extends CardCell {
+class DateCardCell<CustomCardData> extends CardCell {
   final CellControllerBuilder cellControllerBuilder;
+  final CellRenderHook<dynamic, CustomCardData>? renderHook;
 
   const DateCardCell({
     required this.cellControllerBuilder,
+    this.renderHook,
     Key? key,
   }) : super(key: key);
 
@@ -42,6 +44,15 @@ class _DateCardCellState extends State<DateCardCell> {
           if (state.dateStr.isEmpty) {
             return const SizedBox();
           } else {
+            Widget? custom = widget.renderHook?.call(
+              state.data,
+              widget.cardData,
+              context,
+            );
+            if (custom != null) {
+              return custom;
+            }
+
             return Align(
               alignment: Alignment.centerLeft,
               child: Padding(

+ 5 - 3
frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/cells/select_option_card_cell.dart

@@ -11,17 +11,18 @@ import 'card_cell.dart';
 
 class SelectOptionCardCellStyle extends CardCellStyle {}
 
-class SelectOptionCardCell<T> extends CardCell<T, SelectOptionCardCellStyle>
+class SelectOptionCardCell<CustomCardData>
+    extends CardCell<CustomCardData, SelectOptionCardCellStyle>
     with EditableCell {
   final CellControllerBuilder cellControllerBuilder;
-  final CellRenderHook<List<SelectOptionPB>, T>? renderHook;
+  final CellRenderHook<List<SelectOptionPB>, CustomCardData>? renderHook;
 
   @override
   final EditableCardNotifier? editableNotifier;
 
   SelectOptionCardCell({
     required this.cellControllerBuilder,
-    required T? cardData,
+    required CustomCardData? cardData,
     this.renderHook,
     this.editableNotifier,
     Key? key,
@@ -57,6 +58,7 @@ class _SelectOptionCardCellState extends State<SelectOptionCardCell> {
           Widget? custom = widget.renderHook?.call(
             state.selectedOptions,
             widget.cardData,
+            context,
           );
           if (custom != null) {
             return custom;

+ 16 - 3
frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/cells/text_card_cell.dart

@@ -14,18 +14,21 @@ class TextCardCellStyle extends CardCellStyle {
   TextCardCellStyle(this.fontSize);
 }
 
-class TextCardCell extends CardCell<String, TextCardCellStyle>
-    with EditableCell {
+class TextCardCell<CustomCardData>
+    extends CardCell<CustomCardData, TextCardCellStyle> with EditableCell {
   @override
   final EditableCardNotifier? editableNotifier;
   final CellControllerBuilder cellControllerBuilder;
+  final CellRenderHook<String, CustomCardData>? renderHook;
 
   const TextCardCell({
     required this.cellControllerBuilder,
+    required CustomCardData? cardData,
     this.editableNotifier,
+    this.renderHook,
     TextCardCellStyle? style,
     Key? key,
-  }) : super(key: key, style: style);
+  }) : super(key: key, style: style, cardData: cardData);
 
   @override
   State<TextCardCell> createState() => _TextCardCellState();
@@ -104,6 +107,16 @@ class _TextCardCellState extends State<TextCardCell> {
             return previous != current;
           },
           builder: (context, state) {
+            // Returns a custom render widget
+            Widget? custom = widget.renderHook?.call(
+              state.content,
+              widget.cardData,
+              context,
+            );
+            if (custom != null) {
+              return custom;
+            }
+
             if (state.content.isEmpty &&
                 state.enableEdit == false &&
                 focusWhenInit == false) {

+ 2 - 2
frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/container/card_container.dart

@@ -4,13 +4,13 @@ import 'package:styled_widget/styled_widget.dart';
 
 import 'accessory.dart';
 
-class BoardCardContainer extends StatelessWidget {
+class RowCardContainer extends StatelessWidget {
   final Widget child;
   final CardAccessoryBuilder? accessoryBuilder;
   final bool Function()? buildAccessoryWhen;
   final void Function(BuildContext) openCard;
   final void Function(AccessoryType) openAccessory;
-  const BoardCardContainer({
+  const RowCardContainer({
     required this.child,
     required this.openCard,
     required this.openAccessory,

+ 138 - 110
frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_detail.dart

@@ -43,83 +43,84 @@ class RowDetailPage extends StatefulWidget with FlowyOverlayDelegate {
 }
 
 class _RowDetailPageState extends State<RowDetailPage> {
-  final padding = const EdgeInsets.symmetric(
-    horizontal: 40,
-    vertical: 20,
-  );
-
   @override
   Widget build(BuildContext context) {
     return FlowyDialog(
       child: BlocProvider(
         create: (context) {
-          final bloc = RowDetailBloc(
-            dataController: widget.dataController,
-          );
-          bloc.add(const RowDetailEvent.initial());
-          return bloc;
+          return RowDetailBloc(dataController: widget.dataController)
+            ..add(const RowDetailEvent.initial());
         },
-        child: Padding(
-          padding: padding,
-          child: Column(
-            children: [
-              const _Header(),
-              Expanded(
-                child: _PropertyColumn(
-                  cellBuilder: widget.cellBuilder,
-                  viewId: widget.dataController.viewId,
-                ),
-              ),
-            ],
-          ),
+        child: ListView(
+          children: [
+            // using ListView here for future expansion:
+            // - header and cover image
+            // - lower rich text area
+            IntrinsicHeight(child: _responsiveRowInfo()),
+            const Divider(height: 1.0)
+          ],
         ),
       ),
     );
   }
-}
-
-class _Header extends StatelessWidget {
-  const _Header({Key? key}) : super(key: key);
 
-  @override
-  Widget build(BuildContext context) {
-    return SizedBox(
-      height: 30,
-      child: Row(
-        children: const [Spacer(), _CloseButton()],
-      ),
+  Widget _responsiveRowInfo() {
+    final rowDataColumn = _PropertyColumn(
+      cellBuilder: widget.cellBuilder,
+      viewId: widget.dataController.viewId,
     );
-  }
-}
-
-class _CloseButton extends StatelessWidget {
-  const _CloseButton({Key? key}) : super(key: key);
-
-  @override
-  Widget build(BuildContext context) {
-    return FlowyIconButton(
-      hoverColor: AFThemeExtension.of(context).lightGreyHover,
-      width: 24,
-      onPressed: () => FlowyOverlay.pop(context),
-      iconPadding: const EdgeInsets.fromLTRB(2, 2, 2, 2),
-      icon: svgWidget(
-        "home/close",
-        color: Theme.of(context).iconTheme.color,
-      ),
+    final rowOptionColumn = _RowOptionColumn(
+      viewId: widget.dataController.viewId,
+      rowId: widget.dataController.rowId,
     );
+    if (MediaQuery.of(context).size.width > 800) {
+      return Row(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          Flexible(
+            flex: 4,
+            child: Padding(
+              padding: const EdgeInsets.fromLTRB(50, 50, 20, 20),
+              child: rowDataColumn,
+            ),
+          ),
+          const VerticalDivider(width: 1.0),
+          Flexible(
+            child: Padding(
+              padding: const EdgeInsets.fromLTRB(20, 50, 20, 20),
+              child: rowOptionColumn,
+            ),
+          ),
+        ],
+      );
+    } else {
+      return Column(
+        crossAxisAlignment: CrossAxisAlignment.stretch,
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          Padding(
+            padding: const EdgeInsets.fromLTRB(20, 50, 20, 20),
+            child: rowDataColumn,
+          ),
+          const Divider(height: 1.0),
+          Padding(
+            padding: const EdgeInsets.all(20),
+            child: rowOptionColumn,
+          )
+        ],
+      );
+    }
   }
 }
 
 class _PropertyColumn extends StatelessWidget {
   final String viewId;
   final GridCellBuilder cellBuilder;
-  final ScrollController _scrollController;
-  _PropertyColumn({
+  const _PropertyColumn({
     required this.viewId,
     required this.cellBuilder,
     Key? key,
-  })  : _scrollController = ScrollController(),
-        super(key: key);
+  }) : super(key: key);
 
   @override
   Widget build(BuildContext context) {
@@ -127,63 +128,34 @@ class _PropertyColumn extends StatelessWidget {
       buildWhen: (previous, current) => previous.gridCells != current.gridCells,
       builder: (context, state) {
         return Column(
+          mainAxisSize: MainAxisSize.min,
+          crossAxisAlignment: CrossAxisAlignment.start,
           children: [
-            Expanded(child: _wrapScrollbar(buildPropertyCells(state))),
-            const VSpace(10),
-            _CreatePropertyButton(
-              viewId: viewId,
-              onClosed: _scrollToNewProperty,
-            ),
+            ...state.gridCells
+                .map(
+                  (cell) => Padding(
+                    padding: const EdgeInsets.only(bottom: 4.0),
+                    child: _PropertyCell(
+                      cellId: cell,
+                      cellBuilder: cellBuilder,
+                    ),
+                  ),
+                )
+                .toList(),
+            const VSpace(20),
+            _CreatePropertyButton(viewId: viewId),
           ],
         );
       },
     );
   }
-
-  Widget buildPropertyCells(RowDetailState state) {
-    return ListView.separated(
-      controller: _scrollController,
-      itemCount: state.gridCells.length,
-      itemBuilder: (BuildContext context, int index) {
-        return _PropertyCell(
-          cellId: state.gridCells[index],
-          cellBuilder: cellBuilder,
-        );
-      },
-      separatorBuilder: (BuildContext context, int index) {
-        return const VSpace(2);
-      },
-    );
-  }
-
-  Widget _wrapScrollbar(Widget child) {
-    return ScrollbarListStack(
-      axis: Axis.vertical,
-      controller: _scrollController,
-      barSize: GridSize.scrollBarSize,
-      autoHideScrollbar: false,
-      child: child,
-    );
-  }
-
-  void _scrollToNewProperty() {
-    WidgetsBinding.instance.addPostFrameCallback((_) {
-      _scrollController.animateTo(
-        _scrollController.position.maxScrollExtent,
-        duration: const Duration(milliseconds: 250),
-        curve: Curves.ease,
-      );
-    });
-  }
 }
 
 class _CreatePropertyButton extends StatefulWidget {
   final String viewId;
-  final VoidCallback onClosed;
 
   const _CreatePropertyButton({
     required this.viewId,
-    required this.onClosed,
     Key? key,
   }) : super(key: key);
 
@@ -207,10 +179,8 @@ class _CreatePropertyButtonState extends State<_CreatePropertyButton> {
       controller: popoverController,
       direction: PopoverDirection.topWithLeftAligned,
       margin: EdgeInsets.zero,
-      onClose: widget.onClosed,
-      child: Container(
+      child: SizedBox(
         height: 40,
-        decoration: _makeBoxDecoration(context),
         child: FlowyButton(
           text: FlowyText.medium(
             LocaleKeys.grid_field_newProperty.tr(),
@@ -244,14 +214,6 @@ class _CreatePropertyButtonState extends State<_CreatePropertyButton> {
       },
     );
   }
-
-  BoxDecoration _makeBoxDecoration(BuildContext context) {
-    final borderSide =
-        BorderSide(color: Theme.of(context).dividerColor, width: 1.0);
-    return BoxDecoration(
-      border: Border(top: borderSide),
-    );
-  }
 }
 
 class _PropertyCell extends StatefulWidget {
@@ -377,3 +339,69 @@ GridCellStyle? _customCellStyle(FieldType fieldType) {
   }
   throw UnimplementedError;
 }
+
+class _RowOptionColumn extends StatelessWidget {
+  final String rowId;
+  const _RowOptionColumn({
+    required String viewId,
+    required this.rowId,
+    Key? key,
+  }) : super(key: key);
+
+  @override
+  Widget build(BuildContext context) {
+    return Column(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      mainAxisSize: MainAxisSize.min,
+      children: [
+        Padding(
+          padding: const EdgeInsets.only(left: 10),
+          child: FlowyText(LocaleKeys.grid_row_action.tr()),
+        ),
+        const VSpace(15),
+        _DeleteButton(rowId: rowId),
+        _DuplicateButton(rowId: rowId),
+      ],
+    );
+  }
+}
+
+class _DeleteButton extends StatelessWidget {
+  final String rowId;
+  const _DeleteButton({required this.rowId, Key? key}) : super(key: key);
+
+  @override
+  Widget build(BuildContext context) {
+    return SizedBox(
+      height: GridSize.popoverItemHeight,
+      child: FlowyButton(
+        text: FlowyText.regular(LocaleKeys.grid_row_delete.tr()),
+        leftIcon: const FlowySvg(name: "home/trash"),
+        onTap: () {
+          context.read<RowDetailBloc>().add(RowDetailEvent.deleteRow(rowId));
+          FlowyOverlay.pop(context);
+        },
+      ),
+    );
+  }
+}
+
+class _DuplicateButton extends StatelessWidget {
+  final String rowId;
+  const _DuplicateButton({required this.rowId, Key? key}) : super(key: key);
+
+  @override
+  Widget build(BuildContext context) {
+    return SizedBox(
+      height: GridSize.popoverItemHeight,
+      child: FlowyButton(
+        text: FlowyText.regular(LocaleKeys.grid_row_duplicate.tr()),
+        leftIcon: const FlowySvg(name: "grid/duplicate"),
+        onTap: () {
+          context.read<RowDetailBloc>().add(RowDetailEvent.duplicateRow(rowId));
+          FlowyOverlay.pop(context);
+        },
+      ),
+    );
+  }
+}

+ 1 - 1
frontend/appflowy_flutter/lib/workspace/application/appearance.dart

@@ -283,7 +283,7 @@ class AppearanceSettingsState with _$AppearanceSettingsState {
           return 3.0;
         }),
         crossAxisMargin: 0.0,
-        mainAxisMargin: 0.0,
+        mainAxisMargin: 6.0,
         radius: Corners.s10Radius,
       ),
       materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,

+ 3 - 1
frontend/rust-lib/flowy-database/src/event_handler.rs

@@ -313,7 +313,9 @@ pub(crate) async fn duplicate_row_handler(
 ) -> Result<(), FlowyError> {
   let params: RowIdParams = data.into_inner().try_into()?;
   let editor = manager.get_database_editor(&params.view_id).await?;
-  editor.duplicate_row(&params.row_id).await?;
+  editor
+    .duplicate_row(&params.view_id, &params.row_id)
+    .await?;
   Ok(())
 }
 

+ 3 - 3
frontend/rust-lib/flowy-database/src/services/cell/cell_operation.rs

@@ -248,11 +248,11 @@ pub fn insert_checkbox_cell(is_check: bool, field_rev: &FieldRevision) -> CellRe
   CellRevision::new(data)
 }
 
-pub fn insert_date_cell(timestamp: i64, field_rev: &FieldRevision) -> CellRevision {
+pub fn insert_date_cell(date_cell_data: DateCellData, field_rev: &FieldRevision) -> CellRevision {
   let cell_data = serde_json::to_string(&DateCellChangeset {
-    date: Some(timestamp.to_string()),
+    date: date_cell_data.timestamp.map(|t| t.to_string()),
     time: None,
-    include_time: Some(false),
+    include_time: Some(date_cell_data.include_time),
     is_utc: true,
   })
   .unwrap();

+ 25 - 1
frontend/rust-lib/flowy-database/src/services/database/database_editor.rs

@@ -520,7 +520,31 @@ impl DatabaseEditor {
     self.database_views.subscribe_view_changed(view_id).await
   }
 
-  pub async fn duplicate_row(&self, _row_id: &str) -> FlowyResult<()> {
+  pub async fn duplicate_row(&self, view_id: &str, row_id: &str) -> FlowyResult<()> {
+    if let Some(row) = self.get_row_rev(row_id).await? {
+      let cell_data_by_field_id = row
+        .cells
+        .iter()
+        .map(|(field_id, cell)| {
+          (
+            field_id.clone(),
+            TypeCellData::try_from(cell)
+              .map(|value| value.cell_str)
+              .unwrap_or_default(),
+          )
+        })
+        .collect::<HashMap<String, String>>();
+
+      tracing::trace!("cell_data_by_field_id :{:?}", cell_data_by_field_id);
+      let params = CreateRowParams {
+        view_id: view_id.to_string(),
+        start_row_id: Some(row.id.clone()),
+        group_id: None,
+        cell_data_by_field_id: Some(cell_data_by_field_id),
+      };
+
+      self.create_row(params).await?;
+    }
     Ok(())
   }
 

+ 1 - 2
frontend/rust-lib/flowy-database/src/services/field/type_options/date_type_option/date_type_option_entities.rs

@@ -157,8 +157,7 @@ impl FromCellString for DateCellData {
   where
     Self: Sized,
   {
-    let result: DateCellData = serde_json::from_str(s).unwrap();
-    Ok(result)
+    Ok(serde_json::from_str::<DateCellData>(s).unwrap_or_default())
   }
 }
 

+ 9 - 9
frontend/rust-lib/flowy-database/src/services/row/row_builder.rs

@@ -4,7 +4,7 @@ use crate::services::cell::{
 };
 
 use crate::entities::FieldType;
-use crate::services::field::{CheckboxCellData, SelectOptionIds};
+use crate::services::field::{CheckboxCellData, DateCellData, SelectOptionIds};
 use database_model::{gen_row_id, CellRevision, FieldRevision, RowRevision, DEFAULT_ROW_HEIGHT};
 use indexmap::IndexMap;
 use std::collections::HashMap;
@@ -52,12 +52,12 @@ impl RowRevisionBuilder {
           FieldType::RichText => builder.insert_text_cell(&field_id, cell_data),
           FieldType::Number => {
             if let Ok(num) = cell_data.parse::<i64>() {
-              builder.insert_date_cell(&field_id, num)
+              builder.insert_number_cell(&field_id, num)
             }
           },
           FieldType::DateTime => {
-            if let Ok(timestamp) = cell_data.parse::<i64>() {
-              builder.insert_date_cell(&field_id, timestamp)
+            if let Ok(date_cell_data) = DateCellData::from_cell_str(&cell_data) {
+              builder.insert_date_cell(&field_id, date_cell_data)
             }
           },
           FieldType::MultiSelect | FieldType::SingleSelect => {
@@ -132,14 +132,14 @@ impl RowRevisionBuilder {
     }
   }
 
-  pub fn insert_date_cell(&mut self, field_id: &str, timestamp: i64) {
+  pub fn insert_date_cell(&mut self, field_id: &str, date_cell_data: DateCellData) {
     match self.field_rev_map.get(&field_id.to_owned()) {
       None => tracing::warn!("Can't find the date field with id: {}", field_id),
       Some(field_rev) => {
-        self
-          .payload
-          .cell_by_field_id
-          .insert(field_id.to_owned(), insert_date_cell(timestamp, field_rev));
+        self.payload.cell_by_field_id.insert(
+          field_id.to_owned(),
+          insert_date_cell(date_cell_data, field_rev),
+        );
       },
     }
   }