From 1ca130d7de87ec39b93696fb07f711ddd9900489 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Wed, 13 Sep 2023 19:10:08 +0800 Subject: [PATCH] feat: revamp row detail page UI (#3328) * feat: revamp row detail page UI * chore: some minor details and fix tests * fix: fix tests * chore: remove unused field * chore: code cleanup * test: add reordering fields in row page tests * chore: remove duplicate and delete row events * chore: timestamp cell ui adjustment * chore: remove unused code * test: fix new integration tests --- .../database_calendar_test.dart | 5 +- .../database_row_page_test.dart | 39 ++- .../util/database_test_op.dart | 44 +++- .../application/database_controller.dart | 8 +- .../application/row/row_banner_bloc.dart | 6 +- .../grid/application/row/row_detail_bloc.dart | 50 ++-- .../widgets/header/field_cell.dart | 8 +- .../grid/presentation/widgets/row/action.dart | 5 +- .../widgets/row/accessory/cell_accessory.dart | 79 ++---- .../widgets/row/cell_builder.dart | 2 + .../cells/checkbox_cell/checkbox_cell.dart | 22 +- .../row/cells/date_cell/date_cell.dart | 39 ++- .../row/cells/number_cell/number_cell.dart | 27 +- .../cells/select_option_cell/extension.dart | 3 +- .../select_option_cell.dart | 18 +- .../row/cells/text_cell/text_cell.dart | 11 +- .../cells/timestamp_cell/timestamp_cell.dart | 59 ++--- .../database_view/widgets/row/row_action.dart | 143 +++------- .../database_view/widgets/row/row_banner.dart | 175 ++++++++----- .../database_view/widgets/row/row_detail.dart | 134 ++-------- .../widgets/row/row_property.dart | 244 +++++++++++++++--- .../flowy_icons/16x/details_horizontal.svg | 5 + frontend/resources/flowy_icons/16x/emoji.svg | 13 + 23 files changed, 650 insertions(+), 489 deletions(-) create mode 100644 frontend/resources/flowy_icons/16x/details_horizontal.svg create mode 100644 frontend/resources/flowy_icons/16x/emoji.svg diff --git a/frontend/appflowy_flutter/integration_test/database_calendar_test.dart b/frontend/appflowy_flutter/integration_test/database_calendar_test.dart index dfd42fed19..ca16f801d5 100644 --- a/frontend/appflowy_flutter/integration_test/database_calendar_test.dart +++ b/frontend/appflowy_flutter/integration_test/database_calendar_test.dart @@ -9,7 +9,7 @@ import 'util/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - group('calendar database view', () { + group('calendar', () { testWidgets('update calendar layout', (tester) async { await tester.initializeAppFlowy(); await tester.tapGoButton(); @@ -116,6 +116,7 @@ void main() { tester.assertRowDetailPageOpened(); // Duplicate the event + await tester.tapRowDetailPageRowActionButton(); await tester.tapRowDetailPageDuplicateRowButton(); await tester.dismissRowDetailPage(); @@ -125,6 +126,7 @@ void main() { // Delete an event await tester.openCalendarEvent(index: 1); + await tester.tapRowDetailPageRowActionButton(); await tester.tapRowDetailPageDeleteRowButton(); // Check that there is 1 event @@ -155,6 +157,7 @@ void main() { // Delete the event await tester.openCalendarEvent(index: 0, date: sameDayNextWeek); + await tester.tapRowDetailPageRowActionButton(); await tester.tapRowDetailPageDeleteRowButton(); // Create a new event in today's calendar cell diff --git a/frontend/appflowy_flutter/integration_test/database_row_page_test.dart b/frontend/appflowy_flutter/integration_test/database_row_page_test.dart index 373ef7b0de..6b88fafab8 100644 --- a/frontend/appflowy_flutter/integration_test/database_row_page_test.dart +++ b/frontend/appflowy_flutter/integration_test/database_row_page_test.dart @@ -135,7 +135,40 @@ void main() { } }); - testWidgets('check document is exist in row detail page', (tester) async { + testWidgets('change order of fields and cells', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapGoButton(); + + // Create a new grid + await tester.createNewPageWithName(layout: ViewLayoutPB.Grid); + + // Hover first row and then open the row page + await tester.openFirstRowDetailPage(); + + // Assert that the first field in the row details page is the select + // option tyoe + tester.assertFirstFieldInRowDetailByType(FieldType.SingleSelect); + + // Reorder first field in list + final gesture = await tester.hoverOnFieldInRowDetail(index: 0); + await tester.pumpAndSettle(); + await tester.reorderFieldInRowDetail(offset: 30); + + // Orders changed, now the checkbox is first + tester.assertFirstFieldInRowDetailByType(FieldType.Checkbox); + await gesture.removePointer(); + await tester.pumpAndSettle(); + + // Reorder second field in list + await tester.hoverOnFieldInRowDetail(index: 1); + await tester.pumpAndSettle(); + await tester.reorderFieldInRowDetail(offset: -30); + + // First field is now back to select option + tester.assertFirstFieldInRowDetailByType(FieldType.SingleSelect); + }); + + testWidgets('check document exists in row detail page', (tester) async { await tester.initializeAppFlowy(); await tester.tapGoButton(); @@ -149,7 +182,7 @@ void main() { await tester.assertDocumentExistInRowDetailPage(); }); - testWidgets('update the content of the document and re-open it', + testWidgets('update the contents of the document and re-open it', (tester) async { await tester.initializeAppFlowy(); await tester.tapGoButton(); @@ -239,6 +272,7 @@ void main() { // Hover first row and then open the row page await tester.openFirstRowDetailPage(); + await tester.tapRowDetailPageRowActionButton(); await tester.tapRowDetailPageDeleteRowButton(); await tester.tapEscButton(); @@ -255,6 +289,7 @@ void main() { // Hover first row and then open the row page await tester.openFirstRowDetailPage(); + await tester.tapRowDetailPageRowActionButton(); await tester.tapRowDetailPageDuplicateRowButton(); await tester.tapEscButton(); diff --git a/frontend/appflowy_flutter/integration_test/util/database_test_op.dart b/frontend/appflowy_flutter/integration_test/util/database_test_op.dart index db6433b510..81ca2e4953 100644 --- a/frontend/appflowy_flutter/integration_test/util/database_test_op.dart +++ b/frontend/appflowy_flutter/integration_test/util/database_test_op.dart @@ -48,6 +48,7 @@ import 'package:appflowy/plugins/database_view/widgets/row/row_action.dart'; import 'package:appflowy/plugins/database_view/widgets/row/row_banner.dart'; import 'package:appflowy/plugins/database_view/widgets/row/row_detail.dart'; import 'package:appflowy/plugins/database_view/widgets/row/row_document.dart'; +import 'package:appflowy/plugins/database_view/widgets/row/row_property.dart'; import 'package:appflowy/plugins/database_view/widgets/setting/database_setting.dart'; import 'package:appflowy/plugins/database_view/widgets/setting/setting_button.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/emoji_picker/emoji_menu_item.dart'; @@ -485,7 +486,7 @@ extension AppFlowyDatabaseTest on WidgetTester { expect(banner, findsOneWidget); await startGesture( - getTopLeft(banner), + getCenter(banner), kind: PointerDeviceKind.mouse, ); @@ -524,6 +525,31 @@ extension AppFlowyDatabaseTest on WidgetTester { await tapButton(deleteButton); } + Future hoverOnFieldInRowDetail({required int index}) async { + final fieldButtons = find.byType(FieldCellButton); + final button = find + .descendant(of: find.byType(RowDetailPage), matching: fieldButtons) + .at(index); + return startGesture( + getCenter(button), + kind: PointerDeviceKind.mouse, + ); + } + + Future reorderFieldInRowDetail({required double offset}) async { + final thumb = find + .byWidgetPredicate( + (widget) => widget is ReorderableDragStartListener && widget.enabled, + ) + .first; + await drag( + thumb, + Offset(0, offset), + kind: PointerDeviceKind.mouse, + ); + await pumpAndSettle(); + } + Future scrollGridByOffset(Offset offset) async { await drag(find.byType(GridPage), offset); await pumpAndSettle(); @@ -601,6 +627,10 @@ extension AppFlowyDatabaseTest on WidgetTester { await tapButton(button); } + Future tapRowDetailPageRowActionButton() async { + await tapButton(find.byType(RowActionButton)); + } + Future tapRowDetailPageCreatePropertyButton() async { await tapButton(find.byType(CreateRowFieldButton)); } @@ -670,6 +700,18 @@ extension AppFlowyDatabaseTest on WidgetTester { expect(field, findsOneWidget); } + void assertFirstFieldInRowDetailByType(FieldType fieldType) { + final firstField = find + .descendant( + of: find.byType(RowDetailPage), + matching: find.byType(FieldCellButton), + ) + .first; + + final widget = this.widget(firstField); + expect(widget.field.fieldType, fieldType); + } + Future findFieldWithName(String name) async { final field = find.byWidgetPredicate( (widget) => widget is FieldCellButton && widget.field.name == name, diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/application/database_controller.dart b/frontend/appflowy_flutter/lib/plugins/database_view/application/database_controller.dart index 2df72b84f4..940cf788b9 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/application/database_controller.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/application/database_controller.dart @@ -395,13 +395,7 @@ class RowDataBuilder { } void insertDate(FieldInfo fieldInfo, DateTime date) { - assert( - [ - FieldType.DateTime, - FieldType.LastEditedTime, - FieldType.CreatedTime, - ].contains(fieldInfo.fieldType), - ); + assert(FieldType.DateTime == fieldInfo.fieldType); final timestamp = date.millisecondsSinceEpoch ~/ 1000; _cellDataByFieldId[fieldInfo.field.id] = timestamp.toString(); } diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/application/row/row_banner_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database_view/application/row/row_banner_bloc.dart index 3fc60ec775..e06bd21c21 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/application/row/row_banner_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/application/row/row_banner_bloc.dart @@ -32,11 +32,7 @@ class RowBannerBloc extends Bloc { await _listenRowMeteChanged(); }, didReceiveRowMeta: (RowMetaPB rowMeta) { - emit( - state.copyWith( - rowMeta: rowMeta, - ), - ); + emit(state.copyWith(rowMeta: rowMeta)); }, setCover: (String coverURL) { _updateMeta(coverURL: coverURL); diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/grid/application/row/row_detail_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database_view/grid/application/row/row_detail_bloc.dart index d37e3ac026..6023e3a891 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/grid/application/row/row_detail_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/grid/application/row/row_detail_bloc.dart @@ -1,23 +1,20 @@ +import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart'; +import 'package:appflowy/plugins/database_view/application/field/field_service.dart'; import 'package:appflowy/plugins/database_view/application/field_settings/field_settings_service.dart'; -import 'package:appflowy/plugins/database_view/application/row/row_service.dart'; +import 'package:appflowy/plugins/database_view/application/row/row_controller.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_settings_entities.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; -import 'dart:async'; -import '../../../application/cell/cell_service.dart'; -import '../../../application/field/field_service.dart'; -import '../../../application/row/row_controller.dart'; + part 'row_detail_bloc.freezed.dart'; class RowDetailBloc extends Bloc { - final RowBackendService rowService; final RowController rowController; RowDetailBloc({ required this.rowController, - }) : rowService = RowBackendService(viewId: rowController.viewId), - super(RowDetailState.initial()) { + }) : super(RowDetailState.initial()) { on( (event, emit) async { await event.when( @@ -58,14 +55,8 @@ class RowDetailBloc extends Bloc { (err) => Log.error(err), ); }, - deleteRow: (rowId) async { - await rowService.deleteRow(rowId); - }, - duplicateRow: (String rowId, String? groupId) async { - await rowService.duplicateRow( - rowId: rowId, - groupId: groupId, - ); + reorderField: (fieldId, fromIndex, toIndex) async { + await _reorderField(fieldId, fromIndex, toIndex, emit); }, ); }, @@ -94,6 +85,25 @@ class RowDetailBloc extends Bloc { fieldId: fieldId, ); } + + Future _reorderField( + String fieldId, + int fromIndex, + int toIndex, + Emitter emit, + ) async { + final cells = List.from(state.cells); + cells.insert(toIndex, cells.removeAt(fromIndex)); + emit(state.copyWith(cells: cells)); + + final fieldService = + FieldBackendService(viewId: rowController.viewId, fieldId: fieldId); + final result = await fieldService.moveField( + fromIndex, + toIndex, + ); + result.fold((l) {}, (err) => Log.error(err)); + } } @freezed @@ -102,9 +112,11 @@ class RowDetailEvent with _$RowDetailEvent { const factory RowDetailEvent.deleteField(String fieldId) = _DeleteField; const factory RowDetailEvent.showField(String fieldId) = _ShowField; const factory RowDetailEvent.hideField(String fieldId) = _HideField; - const factory RowDetailEvent.deleteRow(String rowId) = _DeleteRow; - const factory RowDetailEvent.duplicateRow(String rowId, String? groupId) = - _DuplicateRow; + const factory RowDetailEvent.reorderField( + String fieldId, + int fromIndex, + int toIndex, + ) = _ReorderField; const factory RowDetailEvent.didReceiveCellDatas( List gridCells, ) = _DidReceiveCellDatas; diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/field_cell.dart b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/field_cell.dart index 8a232adf25..737cae19cb 100755 --- a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/field_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/field_cell.dart @@ -154,29 +154,33 @@ class FieldCellButton extends StatelessWidget { final FieldPB field; final int? maxLines; final BorderRadius? radius; + final EdgeInsets? margin; const FieldCellButton({ required this.field, required this.onTap, this.maxLines = 1, this.radius = BorderRadius.zero, + this.margin, Key? key, }) : super(key: key); @override Widget build(BuildContext context) { return FlowyButton( - hoverColor: AFThemeExtension.of(context).greyHover, + hoverColor: AFThemeExtension.of(context).lightGreyHover, onTap: onTap, leftIcon: FlowySvg( field.fieldType.icon(), + color: Theme.of(context).iconTheme.color, ), radius: radius, text: FlowyText.medium( field.name, maxLines: maxLines, overflow: TextOverflow.ellipsis, + color: AFThemeExtension.of(context).textColor, ), - margin: GridSize.cellContentInsets, + margin: margin ?? GridSize.cellContentInsets, ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/row/action.dart b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/row/action.dart index 9da9bc523a..32c8134168 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/row/action.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/row/action.dart @@ -40,8 +40,7 @@ class RowActions extends StatelessWidget { .map((action) => _ActionCell(action: action)) .toList(); - // - final list = ListView.separated( + return ListView.separated( shrinkWrap: true, controller: ScrollController(), itemCount: cells.length, @@ -53,7 +52,6 @@ class RowActions extends StatelessWidget { return cells[index]; }, ); - return list; }, ), ); @@ -70,6 +68,7 @@ class _ActionCell extends StatelessWidget { height: GridSize.popoverItemHeight, child: FlowyButton( hoverColor: AFThemeExtension.of(context).lightGreyHover, + useIntrinsicWidth: true, text: FlowyText.medium( action.title(), color: action.enable() diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/accessory/cell_accessory.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/accessory/cell_accessory.dart index d756601bbd..eb12292e0f 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/accessory/cell_accessory.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/accessory/cell_accessory.dart @@ -1,9 +1,9 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.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'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -91,50 +91,31 @@ class _PrimaryCellAccessoryState extends State class AccessoryHover extends StatefulWidget { final CellAccessory child; - final EdgeInsets contentPadding; - const AccessoryHover({ - required this.child, - this.contentPadding = EdgeInsets.zero, - Key? key, - }) : super(key: key); + const AccessoryHover({required this.child, super.key}); @override State createState() => _AccessoryHoverState(); } class _AccessoryHoverState extends State { - late AccessoryHoverState _hoverState; - VoidCallback? _listenerFn; - - @override - void initState() { - _hoverState = AccessoryHoverState(); - _listenerFn = () => - _hoverState.onHover = widget.child.onAccessoryHover?.value ?? false; - widget.child.onAccessoryHover?.addListener(_listenerFn!); - - super.initState(); - } - - @override - void dispose() { - _hoverState.dispose(); - - if (_listenerFn != null) { - widget.child.onAccessoryHover?.removeListener(_listenerFn!); - _listenerFn = null; - } - super.dispose(); - } + bool _isHover = false; @override Widget build(BuildContext context) { final List children = [ - Padding(padding: widget.contentPadding, child: widget.child), + DecoratedBox( + decoration: BoxDecoration( + color: _isHover + ? AFThemeExtension.of(context).lightGreyHover + : Colors.transparent, + borderRadius: Corners.s6Border, + ), + child: widget.child, + ), ]; final accessoryBuilder = widget.child.accessoryBuilder; - if (accessoryBuilder != null) { + if (accessoryBuilder != null && _isHover) { final accessories = accessoryBuilder( (GridCellAccessoryBuildContext( anchorContext: context, @@ -149,36 +130,20 @@ class _AccessoryHoverState extends State { ); } - return ChangeNotifierProvider.value( - value: _hoverState, - child: MouseRegion( - cursor: SystemMouseCursors.click, - opaque: false, - onEnter: (p) => setState(() => _hoverState.onHover = true), - onExit: (p) => setState(() => _hoverState.onHover = false), - child: Stack( - fit: StackFit.loose, - alignment: AlignmentDirectional.center, - children: children, - ), + return MouseRegion( + cursor: SystemMouseCursors.click, + opaque: false, + onEnter: (p) => setState(() => _isHover = true), + onExit: (p) => setState(() => _isHover = false), + child: Stack( + fit: StackFit.loose, + alignment: AlignmentDirectional.center, + children: children, ), ); } } -class AccessoryHoverState extends ChangeNotifier { - bool _onHover = false; - - set onHover(bool value) { - if (_onHover != value) { - _onHover = value; - notifyListeners(); - } - } - - bool get onHover => _onHover; -} - class CellAccessoryContainer extends StatelessWidget { final List accessories; const CellAccessoryContainer({required this.accessories, Key? key}) diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cell_builder.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cell_builder.dart index d5a0fee160..e17a5f104e 100755 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cell_builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cell_builder.dart @@ -35,6 +35,7 @@ class GridCellBuilder { case FieldType.Checkbox: return GridCheckboxCell( cellControllerBuilder: cellControllerBuilder, + style: style, key: key, ); case FieldType.DateTime: @@ -71,6 +72,7 @@ class GridCellBuilder { case FieldType.Number: return GridNumberCell( cellControllerBuilder: cellControllerBuilder, + style: style, key: key, ); case FieldType.RichText: diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/checkbox_cell/checkbox_cell.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/checkbox_cell/checkbox_cell.dart index e064b39b9b..86dce6ca54 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/checkbox_cell/checkbox_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/checkbox_cell/checkbox_cell.dart @@ -9,12 +9,29 @@ import 'checkbox_cell_bloc.dart'; import '../../../../grid/presentation/layout/sizes.dart'; import '../../cell_builder.dart'; +class GridCheckboxCellStyle extends GridCellStyle { + EdgeInsets? cellPadding; + + GridCheckboxCellStyle({ + this.cellPadding, + }); +} + class GridCheckboxCell extends GridCellWidget { final CellControllerBuilder cellControllerBuilder; + late final GridCheckboxCellStyle cellStyle; + GridCheckboxCell({ required this.cellControllerBuilder, + GridCellStyle? style, Key? key, - }) : super(key: key); + }) : super(key: key) { + if (style != null) { + cellStyle = (style as GridCheckboxCellStyle); + } else { + cellStyle = GridCheckboxCellStyle(); + } + } @override GridCellState createState() => _CheckboxCellState(); @@ -46,7 +63,8 @@ class _CheckboxCellState extends GridCellState { return Align( alignment: Alignment.centerLeft, child: Padding( - padding: GridSize.cellContentInsets, + padding: + widget.cellStyle.cellPadding ?? GridSize.cellContentInsets, child: FlowyIconButton( hoverColor: Colors.transparent, onPressed: () => context diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/date_cell/date_cell.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/date_cell/date_cell.dart index 1eb14193ce..041d5073e7 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/date_cell/date_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/date_cell/date_cell.dart @@ -1,7 +1,8 @@ import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../grid/presentation/layout/sizes.dart'; @@ -10,9 +11,15 @@ import 'date_cell_bloc.dart'; import 'date_editor.dart'; class DateCellStyle extends GridCellStyle { + String? placeholder; Alignment alignment; + EdgeInsets? cellPadding; - DateCellStyle({this.alignment = Alignment.center}); + DateCellStyle({ + this.placeholder, + this.alignment = Alignment.center, + this.cellPadding, + }); } abstract class GridCellDelegate { @@ -71,7 +78,10 @@ class _DateCellState extends GridCellState { margin: EdgeInsets.zero, child: GridDateCellText( dateStr: state.dateStr, + placeholder: widget.cellStyle?.placeholder ?? "", alignment: alignment, + cellPadding: + widget.cellStyle?.cellPadding ?? GridSize.cellContentInsets, ), popupBuilder: (BuildContext popoverContent) { return DateCellEditor( @@ -107,24 +117,31 @@ class _DateCellState extends GridCellState { class GridDateCellText extends StatelessWidget { final String dateStr; + final String placeholder; final Alignment alignment; + final EdgeInsets cellPadding; const GridDateCellText({ required this.dateStr, + required this.placeholder, required this.alignment, + required this.cellPadding, super.key, }); @override Widget build(BuildContext context) { - return SizedBox.expand( - child: Align( - alignment: alignment, - child: Padding( - padding: GridSize.cellContentInsets, - child: FlowyText.medium( - dateStr, - maxLines: null, - ), + final isPlaceholder = dateStr.isEmpty; + final text = isPlaceholder ? placeholder : dateStr; + return Align( + alignment: alignment, + child: Padding( + padding: cellPadding, + child: FlowyText.medium( + text, + color: isPlaceholder + ? Theme.of(context).hintColor + : AFThemeExtension.of(context).textColor, + maxLines: null, ), ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/number_cell/number_cell.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/number_cell/number_cell.dart index 3f00c76214..4dee176bbe 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/number_cell/number_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/number_cell/number_cell.dart @@ -7,13 +7,33 @@ import 'number_cell_bloc.dart'; import '../../../../grid/presentation/layout/sizes.dart'; import '../../cell_builder.dart'; +class GridNumberCellStyle extends GridCellStyle { + String? placeholder; + TextStyle? textStyle; + EdgeInsets? cellPadding; + + GridNumberCellStyle({ + this.placeholder, + this.textStyle, + this.cellPadding, + }); +} + class GridNumberCell extends GridCellWidget { final CellControllerBuilder cellControllerBuilder; + late final GridNumberCellStyle cellStyle; GridNumberCell({ required this.cellControllerBuilder, - Key? key, - }) : super(key: key); + required GridCellStyle? style, + super.key, + }) { + if (style != null) { + cellStyle = (style as GridNumberCellStyle); + } else { + cellStyle = GridNumberCellStyle(); + } + } @override GridEditableTextCell createState() => _NumberCellState(); @@ -57,9 +77,10 @@ class _NumberCellState extends GridEditableTextCell { maxLines: null, style: Theme.of(context).textTheme.bodyMedium, textInputAction: TextInputAction.done, - decoration: const InputDecoration( + decoration: InputDecoration( contentPadding: EdgeInsets.zero, border: InputBorder.none, + hintText: widget.cellStyle.placeholder, isDense: true, ), ), diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/select_option_cell/extension.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/select_option_cell/extension.dart index 6e25de23b5..5b4ec4f037 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/select_option_cell/extension.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/select_option_cell/extension.dart @@ -93,7 +93,7 @@ class SelectOptionTag extends StatelessWidget { @override Widget build(BuildContext context) { EdgeInsets padding = - const EdgeInsets.symmetric(vertical: 2.0, horizontal: 8.0); + const EdgeInsets.symmetric(vertical: 1.5, horizontal: 8.0); if (onRemove != null) { padding = padding.copyWith(right: 2.0); } @@ -110,6 +110,7 @@ class SelectOptionTag extends StatelessWidget { Flexible( child: FlowyText.medium( name, + fontSize: FontSizes.s11, overflow: TextOverflow.ellipsis, color: AFThemeExtension.of(context).textColor, ), diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/select_option_cell/select_option_cell.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/select_option_cell/select_option_cell.dart index d965a55e86..38f05212ee 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/select_option_cell/select_option_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/select_option_cell/select_option_cell.dart @@ -13,9 +13,11 @@ import 'select_option_editor.dart'; class SelectOptionCellStyle extends GridCellStyle { String placeholder; + EdgeInsets? cellPadding; SelectOptionCellStyle({ required this.placeholder, + this.cellPadding, }); } @@ -170,10 +172,7 @@ class _SelectOptionWrapState extends State { final Widget child = _buildOptions(context); final constraints = BoxConstraints.loose( - Size( - SelectOptionCellEditor.editorPanelWidth, - 300, - ), + Size(SelectOptionCellEditor.editorPanelWidth, 300), ); return AppFlowyPopover( controller: widget.popoverController, @@ -191,7 +190,7 @@ class _SelectOptionWrapState extends State { }, onClose: () => widget.onCellEditing.value = false, child: Padding( - padding: GridSize.cellContentInsets, + padding: widget.cellStyle?.cellPadding ?? GridSize.cellContentInsets, child: child, ), ); @@ -200,9 +199,12 @@ class _SelectOptionWrapState extends State { Widget _buildOptions(BuildContext context) { final Widget child; if (widget.selectOptions.isEmpty && widget.cellStyle != null) { - child = FlowyText.medium( - widget.cellStyle!.placeholder, - color: Theme.of(context).hintColor, + child = Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: FlowyText.medium( + widget.cellStyle!.placeholder, + color: Theme.of(context).hintColor, + ), ); } else { final children = widget.selectOptions.map( diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/text_cell/text_cell.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/text_cell/text_cell.dart index b74e23712c..efc03a6838 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/text_cell/text_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/text_cell/text_cell.dart @@ -14,6 +14,7 @@ class GridTextCellStyle extends GridCellStyle { double emojiFontSize; double emojiHPadding; bool showEmoji; + EdgeInsets? cellPadding; GridTextCellStyle({ this.placeholder, @@ -22,6 +23,7 @@ class GridTextCellStyle extends GridCellStyle { this.showEmoji = true, this.emojiFontSize = 16, this.emojiHPadding = 0, + this.cellPadding, }); } @@ -72,10 +74,11 @@ class _GridTextCellState extends GridEditableTextCell { } }, child: Padding( - padding: EdgeInsets.only( - left: GridSize.cellContentInsets.left, - right: GridSize.cellContentInsets.right, - ), + padding: widget.cellStyle.cellPadding ?? + EdgeInsets.only( + left: GridSize.cellContentInsets.left, + right: GridSize.cellContentInsets.right, + ), child: Row( children: [ if (widget.cellStyle.showEmoji) diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/timestamp_cell/timestamp_cell.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/timestamp_cell/timestamp_cell.dart index 7f155c6eae..3dbcb3e194 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/timestamp_cell/timestamp_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/timestamp_cell/timestamp_cell.dart @@ -3,14 +3,21 @@ import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.da import 'package:appflowy/plugins/database_view/widgets/row/cell_builder.dart'; import 'package:appflowy/plugins/database_view/widgets/row/cells/timestamp_cell/timestamp_cell_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; +import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class TimestampCellStyle extends GridCellStyle { + String? placeholder; Alignment alignment; + EdgeInsets? cellPadding; - TimestampCellStyle({this.alignment = Alignment.center}); + TimestampCellStyle({ + this.placeholder, + this.alignment = Alignment.center, + this.cellPadding, + }); } class GridTimestampCell extends GridCellWidget { @@ -51,16 +58,28 @@ class _TimestampCellState extends GridCellState { @override Widget build(BuildContext context) { - final alignment = widget.cellStyle != null - ? widget.cellStyle!.alignment - : Alignment.centerLeft; + final alignment = widget.cellStyle?.alignment ?? Alignment.centerLeft; + final placeholder = widget.cellStyle?.placeholder ?? ""; + final padding = widget.cellStyle?.cellPadding ?? GridSize.cellContentInsets; + return BlocProvider.value( value: _cellBloc, child: BlocBuilder( builder: (context, state) { - return GridTimestampCellText( - dateStr: state.dateStr, + final isEmpty = state.dateStr.isEmpty; + final text = isEmpty ? placeholder : state.dateStr; + return Align( alignment: alignment, + child: Padding( + padding: padding, + child: FlowyText.medium( + text, + color: isEmpty + ? Theme.of(context).hintColor + : AFThemeExtension.of(context).textColor, + maxLines: null, + ), + ), ); }, ), @@ -81,29 +100,3 @@ class _TimestampCellState extends GridCellState { return; } } - -class GridTimestampCellText extends StatelessWidget { - final String dateStr; - final Alignment alignment; - const GridTimestampCellText({ - required this.dateStr, - required this.alignment, - super.key, - }); - - @override - Widget build(BuildContext context) { - return SizedBox.expand( - child: Align( - alignment: alignment, - child: Padding( - padding: GridSize.cellContentInsets, - child: FlowyText.medium( - dateStr, - maxLines: null, - ), - ), - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_action.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_action.dart index 15f04a2503..27b6404734 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_action.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_action.dart @@ -1,18 +1,10 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart'; -import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_service.dart'; import 'package:appflowy/plugins/database_view/application/row/row_controller.dart'; -import 'package:appflowy/plugins/database_view/grid/application/row/row_detail_bloc.dart'; +import 'package:appflowy/plugins/database_view/grid/application/row/row_action_sheet_bloc.dart'; import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart'; -import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_editor.dart'; -import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; -import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -20,36 +12,39 @@ import 'package:flutter_bloc/flutter_bloc.dart'; class RowActionList extends StatelessWidget { final RowController rowController; const RowActionList({ - required String viewId, required this.rowController, - Key? key, - }) : super(key: key); + super.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()), + return BlocProvider( + create: (context) => RowActionSheetBloc( + viewId: rowController.viewId, + rowId: rowController.rowId, + groupId: rowController.groupId, + ), + child: IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + RowDetailPageDuplicateButton( + rowId: rowController.rowId, + groupId: rowController.groupId, + ), + const VSpace(4.0), + RowDetailPageDeleteButton(rowId: rowController.rowId), + ], ), - const VSpace(15), - RowDetailPageDeleteButton(rowId: rowController.rowId), - RowDetailPageDuplicateButton( - rowId: rowController.rowId, - groupId: rowController.groupId, - ), - ], + ), ); } } class RowDetailPageDeleteButton extends StatelessWidget { final String rowId; - const RowDetailPageDeleteButton({required this.rowId, Key? key}) - : super(key: key); + const RowDetailPageDeleteButton({required this.rowId, super.key}); @override Widget build(BuildContext context) { @@ -59,7 +54,9 @@ class RowDetailPageDeleteButton extends StatelessWidget { text: FlowyText.regular(LocaleKeys.grid_row_delete.tr()), leftIcon: const FlowySvg(FlowySvgs.trash_m), onTap: () { - context.read().add(RowDetailEvent.deleteRow(rowId)); + context + .read() + .add(const RowActionSheetEvent.deleteRow()); FlowyOverlay.pop(context); }, ), @@ -73,8 +70,8 @@ class RowDetailPageDuplicateButton extends StatelessWidget { const RowDetailPageDuplicateButton({ required this.rowId, this.groupId, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context) { @@ -85,91 +82,11 @@ class RowDetailPageDuplicateButton extends StatelessWidget { leftIcon: const FlowySvg(FlowySvgs.copy_s), onTap: () { context - .read() - .add(RowDetailEvent.duplicateRow(rowId, groupId)); + .read() + .add(const RowActionSheetEvent.duplicateRow()); FlowyOverlay.pop(context); }, ), ); } } - -class CreateRowFieldButton extends StatefulWidget { - final String viewId; - - const CreateRowFieldButton({ - required this.viewId, - Key? key, - }) : super(key: key); - - @override - State createState() => _CreateRowFieldButtonState(); -} - -class _CreateRowFieldButtonState extends State { - late PopoverController popoverController; - late TypeOptionPB typeOption; - - @override - void initState() { - popoverController = PopoverController(); - super.initState(); - } - - @override - Widget build(BuildContext context) { - return AppFlowyPopover( - constraints: BoxConstraints.loose(const Size(240, 200)), - controller: popoverController, - direction: PopoverDirection.topWithLeftAligned, - triggerActions: PopoverTriggerFlags.none, - margin: EdgeInsets.zero, - child: SizedBox( - height: 40, - child: FlowyButton( - text: FlowyText.medium( - LocaleKeys.grid_field_newProperty.tr(), - color: AFThemeExtension.of(context).textColor, - ), - hoverColor: AFThemeExtension.of(context).lightGreyHover, - onTap: () async { - final result = await TypeOptionBackendService.createFieldTypeOption( - viewId: widget.viewId, - ); - result.fold( - (l) { - typeOption = l; - popoverController.show(); - }, - (r) => Log.error("Failed to create field type option: $r"), - ); - }, - leftIcon: FlowySvg( - FlowySvgs.add_m, - color: AFThemeExtension.of(context).textColor, - ), - ), - ), - popupBuilder: (BuildContext popOverContext) { - return FieldEditor( - viewId: widget.viewId, - typeOptionLoader: FieldTypeOptionLoader( - viewId: widget.viewId, - field: typeOption.field_2, - ), - onDeleted: (fieldId) { - popoverController.close(); - NavigatorAlertDialog( - title: LocaleKeys.grid_field_deleteFieldPromptMessage.tr(), - confirm: () { - context - .read() - .add(RowDetailEvent.deleteField(fieldId)); - }, - ).show(context); - }, - ); - }, - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_banner.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_banner.dart index ff922e83c4..e31b1e3291 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_banner.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_banner.dart @@ -1,23 +1,27 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart'; +import 'package:appflowy/plugins/database_view/application/field/field_info.dart'; import 'package:appflowy/plugins/database_view/application/row/row_banner_bloc.dart'; +import 'package:appflowy/plugins/database_view/application/row/row_controller.dart'; +import 'package:appflowy/plugins/database_view/widgets/row/row_action.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/emoji_picker/emoji_picker.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -typedef RowBannerCellBuilder = Widget Function(String fieldId); +import 'cell_builder.dart'; +import 'cells/cells.dart'; class RowBanner extends StatefulWidget { - final String viewId; - final RowMetaPB rowMeta; - final RowBannerCellBuilder cellBuilder; + final RowController rowController; + final GridCellBuilder cellBuilder; + const RowBanner({ - required this.viewId, - required this.rowMeta, + required this.rowController, required this.cellBuilder, super.key, }); @@ -34,25 +38,39 @@ class _RowBannerState extends State { Widget build(BuildContext context) { return BlocProvider( create: (context) => RowBannerBloc( - viewId: widget.viewId, - rowMeta: widget.rowMeta, + viewId: widget.rowController.viewId, + rowMeta: widget.rowController.rowMeta, )..add(const RowBannerEvent.initial()), child: MouseRegion( onEnter: (event) => _isHovering.value = true, onExit: (event) => _isHovering.value = false, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Stack( children: [ - SizedBox( - height: 30, - child: _BannerAction( - isHovering: _isHovering, - popoverController: popoverController, + Padding( + padding: const EdgeInsets.fromLTRB(60, 34, 60, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 30, + child: _BannerAction( + isHovering: _isHovering, + popoverController: popoverController, + ), + ), + const HSpace(4), + _BannerTitle( + cellBuilder: widget.cellBuilder, + popoverController: popoverController, + rowController: widget.rowController, + ), + ], ), ), - _BannerTitle( - cellBuilder: widget.cellBuilder, - popoverController: popoverController, + Positioned( + top: 12, + right: 12, + child: RowActionButton(rowController: widget.rowController), ), ], ), @@ -74,50 +92,54 @@ class _BannerAction extends StatelessWidget { return ValueListenableBuilder( valueListenable: isHovering, builder: (BuildContext context, bool value, Widget? child) { - if (value) { - return BlocBuilder( - builder: (context, state) { - final children = []; - final rowMeta = state.rowMeta; - if (rowMeta.icon.isEmpty) { - children.add( - EmojiPickerButton( - showEmojiPicker: () => popoverController.show(), - ), - ); - } else { - children.add( - RemoveEmojiButton( - onRemoved: () { - context - .read() - .add(const RowBannerEvent.setIcon('')); - }, - ), - ); - } - return Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: children, - ); - }, - ); - } else { + if (!value) { return const SizedBox(height: _kBannerActionHeight); } + + return BlocBuilder( + builder: (context, state) { + final children = []; + final rowMeta = state.rowMeta; + if (rowMeta.icon.isEmpty) { + children.add( + EmojiPickerButton( + showEmojiPicker: () => popoverController.show(), + ), + ); + } else { + children.add( + RemoveEmojiButton( + onRemoved: () { + context + .read() + .add(const RowBannerEvent.setIcon('')); + }, + ), + ); + } + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ); + }, + ); }, ); } } class _BannerTitle extends StatefulWidget { - final RowBannerCellBuilder cellBuilder; + final GridCellBuilder cellBuilder; final PopoverController popoverController; + final RowController rowController; + const _BannerTitle({ required this.cellBuilder, required this.popoverController, - }); + required this.rowController, + Key? key, + }) : super(key: key); @override State<_BannerTitle> createState() => _BannerTitleState(); @@ -139,10 +161,24 @@ class _BannerTitleState extends State<_BannerTitle> { ); } + children.add(const HSpace(4)); + if (state.primaryField != null) { + final style = GridTextCellStyle( + placeholder: LocaleKeys.grid_row_titlePlaceholder.tr(), + textStyle: Theme.of(context).textTheme.titleLarge, + showEmoji: false, + autofocus: true, + cellPadding: EdgeInsets.zero, + ); + final cellContext = DatabaseCellContext( + viewId: widget.rowController.viewId, + rowMeta: widget.rowController.rowMeta, + fieldInfo: FieldInfo.initial(state.primaryField!), + ); children.add( Expanded( - child: widget.cellBuilder(state.primaryField!.id), + child: widget.cellBuilder.build(cellContext, style: style), ), ); } @@ -211,16 +247,14 @@ class _EmojiPickerButtonState extends State { Widget build(BuildContext context) { return SizedBox( height: 26, - width: 160, child: FlowyButton( + useIntrinsicWidth: true, text: FlowyText.medium( LocaleKeys.document_plugins_cover_addIcon.tr(), ), - leftIcon: const Icon( - Icons.emoji_emotions, - size: 16, - ), + leftIcon: const FlowySvg(FlowySvgs.emoji_s), onTap: widget.showEmojiPicker, + margin: const EdgeInsets.all(4), ), ); } @@ -239,16 +273,14 @@ class RemoveEmojiButton extends StatelessWidget { Widget build(BuildContext context) { return SizedBox( height: 26, - width: 160, child: FlowyButton( + useIntrinsicWidth: true, text: FlowyText.medium( LocaleKeys.document_plugins_cover_removeIcon.tr(), ), - leftIcon: const Icon( - Icons.emoji_emotions, - size: 16, - ), + leftIcon: const FlowySvg(FlowySvgs.emoji_s), onTap: onRemoved, + margin: const EdgeInsets.all(4), ), ); } @@ -263,3 +295,22 @@ Widget _buildEmojiPicker(OnSubmittedEmoji onSubmitted) { ), ); } + +class RowActionButton extends StatelessWidget { + final RowController rowController; + const RowActionButton({super.key, required this.rowController}); + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + direction: PopoverDirection.bottomWithLeftAligned, + popupBuilder: (context) => RowActionList(rowController: rowController), + child: FlowyIconButton( + width: 20, + height: 20, + icon: const FlowySvg(FlowySvgs.details_horizontal_s), + iconColorOnHover: Theme.of(context).colorScheme.onSecondary, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_detail.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_detail.dart index ac6cca1388..4ab52f899e 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_detail.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_detail.dart @@ -1,18 +1,12 @@ -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart'; import 'package:appflowy/plugins/database_view/application/row/row_controller.dart'; import 'package:appflowy/plugins/database_view/grid/application/row/row_detail_bloc.dart'; import 'package:appflowy/plugins/database_view/widgets/row/row_document.dart'; -import 'package:collection/collection.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'cell_builder.dart'; -import 'cells/text_cell/text_cell.dart'; -import 'row_action.dart'; import 'row_banner.dart'; import 'row_property.dart'; @@ -47,17 +41,29 @@ class _RowDetailPageState extends State { Widget build(BuildContext context) { return FlowyDialog( child: BlocProvider( - create: (context) { - return RowDetailBloc(rowController: widget.rowController) - ..add(const RowDetailEvent.initial()); - }, + create: (context) => RowDetailBloc(rowController: widget.rowController) + ..add(const RowDetailEvent.initial()), child: ListView( controller: scrollController, children: [ - _rowBanner(), - IntrinsicHeight(child: _responsiveRowInfo()), - const Divider(height: 1.0), - const VSpace(10), + RowBanner( + rowController: widget.rowController, + cellBuilder: widget.cellBuilder, + ), + const VSpace(16), + Padding( + padding: const EdgeInsets.only(left: 40, right: 60), + child: RowPropertyList( + cellBuilder: widget.cellBuilder, + viewId: widget.rowController.viewId, + ), + ), + const VSpace(20), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 60), + child: Divider(height: 1.0), + ), + const VSpace(20), RowDocument( viewId: widget.rowController.viewId, rowId: widget.rowController.rowId, @@ -68,104 +74,4 @@ class _RowDetailPageState extends State { ), ); } - - Widget _rowBanner() { - return BlocBuilder( - builder: (context, state) { - final paddingOffset = getHorizontalPadding(context); - return Padding( - padding: EdgeInsets.only( - left: paddingOffset, - right: paddingOffset, - top: 20, - ), - child: RowBanner( - rowMeta: widget.rowController.rowMeta, - viewId: widget.rowController.viewId, - cellBuilder: (fieldId) { - final fieldInfo = state.cells - .firstWhereOrNull( - (e) => e.fieldInfo.field.id == fieldId, - ) - ?.fieldInfo; - - if (fieldInfo != null) { - final style = GridTextCellStyle( - placeholder: LocaleKeys.grid_row_titlePlaceholder.tr(), - textStyle: Theme.of(context).textTheme.titleLarge, - showEmoji: false, - autofocus: true, - ); - final cellContext = DatabaseCellContext( - viewId: widget.rowController.viewId, - rowMeta: widget.rowController.rowMeta, - fieldInfo: fieldInfo, - ); - return widget.cellBuilder.build(cellContext, style: style); - } else { - return const SizedBox.shrink(); - } - }, - ), - ); - }, - ); - } - - Widget _responsiveRowInfo() { - final rowDataColumn = RowPropertyList( - cellBuilder: widget.cellBuilder, - viewId: widget.rowController.viewId, - ); - final rowOptionColumn = RowActionList( - viewId: widget.rowController.viewId, - rowController: widget.rowController, - ); - final paddingOffset = getHorizontalPadding(context); - if (MediaQuery.of(context).size.width > 800) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Flexible( - flex: 3, - child: Padding( - padding: EdgeInsets.fromLTRB(paddingOffset, 0, 20, 20), - child: rowDataColumn, - ), - ), - const VerticalDivider(width: 1.0), - Flexible( - child: Padding( - padding: EdgeInsets.fromLTRB(20, 0, paddingOffset, 0), - child: rowOptionColumn, - ), - ), - ], - ); - } else { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: EdgeInsets.fromLTRB(paddingOffset, 0, 20, 20), - child: rowDataColumn, - ), - const Divider(height: 1.0), - Padding( - padding: EdgeInsets.symmetric(horizontal: paddingOffset), - child: rowOptionColumn, - ) - ], - ); - } - } -} - -double getHorizontalPadding(BuildContext context) { - if (MediaQuery.of(context).size.width > 800) { - return 50; - } else { - return 20; - } } diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_property.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_property.dart index e3dd7f3f04..ef73c74062 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_property.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_property.dart @@ -1,21 +1,27 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart'; import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart'; +import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_service.dart'; import 'package:appflowy/plugins/database_view/grid/application/row/row_detail_bloc.dart'; import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_cell.dart'; import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_editor.dart'; -import 'package:appflowy/plugins/database_view/widgets/row/row_action.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'accessory/cell_accessory.dart'; import 'cell_builder.dart'; +import 'cells/checkbox_cell/checkbox_cell.dart'; import 'cells/date_cell/date_cell.dart'; +import 'cells/number_cell/number_cell.dart'; import 'cells/select_option_cell/select_option_cell.dart'; import 'cells/text_cell/text_cell.dart'; import 'cells/timestamp_cell/timestamp_cell.dart'; @@ -23,7 +29,6 @@ import 'cells/url_cell/url_cell.dart'; /// Display the row properties in a list. Only use this widget in the /// [RowDetailPage]. -/// class RowPropertyList extends StatelessWidget { final String viewId; final GridCellBuilder cellBuilder; @@ -38,39 +43,88 @@ class RowPropertyList extends StatelessWidget { return BlocBuilder( buildWhen: (previous, current) => previous.cells != current.cells, builder: (context, state) { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // The rest of the fields are displayed in the order of the field - // list - ...state.cells - .where((element) => !element.fieldInfo.field.isPrimary) - .map( - (cell) => _PropertyCell( - cellContext: cell, - cellBuilder: cellBuilder, - ), - ) - .toList(), - const VSpace(20), - - // Create a new property(field) button - CreateRowFieldButton(viewId: viewId), - ], + final children = state.cells + .where((element) => !element.fieldInfo.field.isPrimary) + .mapIndexed( + (index, cell) => _PropertyCell( + key: ValueKey('row_detail_${cell.fieldId}'), + cellContext: cell, + cellBuilder: cellBuilder, + index: index, + ), + ) + .toList(); + return ReorderableListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + onReorder: (oldIndex, newIndex) { + final reorderedField = children[oldIndex].cellContext.fieldId; + _reorderField( + context, + state.cells, + reorderedField, + oldIndex, + newIndex, + ); + }, + buildDefaultDragHandles: false, + proxyDecorator: (child, index, animation) => Material( + color: Colors.transparent, + child: Stack( + children: [ + child, + const MouseRegion(cursor: SystemMouseCursors.grabbing), + ], + ), + ), + footer: Padding( + padding: const EdgeInsets.only(left: 20), + child: CreateRowFieldButton(viewId: viewId), + ), + children: children, ); }, ); } + + void _reorderField( + BuildContext context, + List cells, + String reorderedFieldId, + int oldIndex, + int newIndex, + ) { + // when reorderiing downwards, need to update index + if (oldIndex < newIndex) { + newIndex--; + } + + // also update index when the index is after the index of the primary field + // in the original list of DatabaseCellContext's + final primaryFieldIndex = + cells.indexWhere((element) => element.fieldInfo.isPrimary); + if (oldIndex >= primaryFieldIndex) { + oldIndex++; + } + if (newIndex >= primaryFieldIndex) { + newIndex++; + } + + context.read().add( + RowDetailEvent.reorderField(reorderedFieldId, oldIndex, newIndex), + ); + } } class _PropertyCell extends StatefulWidget { final DatabaseCellContext cellContext; final GridCellBuilder cellBuilder; + final int index; const _PropertyCell({ required this.cellContext, required this.cellBuilder, Key? key, + required this.index, }) : super(key: key); @override @@ -78,45 +132,65 @@ class _PropertyCell extends StatefulWidget { } class _PropertyCellState extends State<_PropertyCell> { - final PopoverController popover = PopoverController(); + final PopoverController _popoverController = PopoverController(); + bool _isFieldHover = false; @override Widget build(BuildContext context) { final style = _customCellStyle(widget.cellContext.fieldType); final cell = widget.cellBuilder.build(widget.cellContext, style: style); - final gesture = GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => cell.requestFocus.notify(), - child: AccessoryHover( - contentPadding: const EdgeInsets.symmetric(horizontal: 3, vertical: 3), - child: cell, + final dragThumb = MouseRegion( + cursor: SystemMouseCursors.grab, + child: SizedBox( + width: 16, + height: 30, + child: _isFieldHover ? const FlowySvg(FlowySvgs.drag_element_s) : null, ), ); - return IntrinsicHeight( - child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: 30), + final gesture = GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => cell.requestFocus.notify(), + child: AccessoryHover(child: cell), + ); + + return Container( + margin: const EdgeInsets.only(bottom: 8), + constraints: const BoxConstraints(minHeight: 30), + child: MouseRegion( + onEnter: (event) => setState(() => _isFieldHover = true), + onExit: (event) => setState(() => _isFieldHover = false), child: Row( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start, children: [ + ReorderableDragStartListener( + index: widget.index, + enabled: _isFieldHover, + child: dragThumb, + ), + const HSpace(4), AppFlowyPopover( - controller: popover, + controller: _popoverController, constraints: BoxConstraints.loose(const Size(240, 600)), margin: EdgeInsets.zero, triggerActions: PopoverTriggerFlags.none, + direction: PopoverDirection.bottomWithLeftAligned, popupBuilder: (popoverContext) => buildFieldEditor(), child: SizedBox( - width: 150, - height: 40, + width: 160, + height: 30, child: FieldCellButton( field: widget.cellContext.fieldInfo.field, - onTap: () => popover.show(), + onTap: () => _popoverController.show(), radius: BorderRadius.circular(6), + margin: + const EdgeInsets.symmetric(horizontal: 4, vertical: 6), ), ), ), + const HSpace(8), Expanded(child: gesture), ], ), @@ -133,11 +207,11 @@ class _PropertyCellState extends State<_PropertyCell> { field: widget.cellContext.fieldInfo.field, ), onHidden: (fieldId) { - popover.close(); + _popoverController.close(); context.read().add(RowDetailEvent.hideField(fieldId)); }, onDeleted: (fieldId) { - popover.close(); + _popoverController.close(); NavigatorAlertDialog( title: LocaleKeys.grid_field_deleteFieldPromptMessage.tr(), @@ -155,26 +229,35 @@ class _PropertyCellState extends State<_PropertyCell> { GridCellStyle? _customCellStyle(FieldType fieldType) { switch (fieldType) { case FieldType.Checkbox: - return null; + return GridCheckboxCellStyle( + cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + ); case FieldType.DateTime: return DateCellStyle( + placeholder: LocaleKeys.grid_row_textPlaceholder.tr(), alignment: Alignment.centerLeft, + cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), ); case FieldType.LastEditedTime: case FieldType.CreatedTime: return TimestampCellStyle( + placeholder: LocaleKeys.grid_row_textPlaceholder.tr(), alignment: Alignment.centerLeft, + cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), ); case FieldType.MultiSelect: return SelectOptionCellStyle( placeholder: LocaleKeys.grid_row_textPlaceholder.tr(), + cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), ); case FieldType.Checklist: return SelectOptionCellStyle( placeholder: LocaleKeys.grid_row_textPlaceholder.tr(), ); case FieldType.Number: - return null; + return GridNumberCellStyle( + placeholder: LocaleKeys.grid_row_textPlaceholder.tr(), + ); case FieldType.RichText: return GridTextCellStyle( placeholder: LocaleKeys.grid_row_textPlaceholder.tr(), @@ -182,6 +265,7 @@ GridCellStyle? _customCellStyle(FieldType fieldType) { case FieldType.SingleSelect: return SelectOptionCellStyle( placeholder: LocaleKeys.grid_row_textPlaceholder.tr(), + cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), ); case FieldType.URL: @@ -195,3 +279,81 @@ GridCellStyle? _customCellStyle(FieldType fieldType) { } throw UnimplementedError; } + +class CreateRowFieldButton extends StatefulWidget { + final String viewId; + + const CreateRowFieldButton({required this.viewId, super.key}); + + @override + State createState() => _CreateRowFieldButtonState(); +} + +class _CreateRowFieldButtonState extends State { + late PopoverController popoverController; + late TypeOptionPB typeOption; + + @override + void initState() { + popoverController = PopoverController(); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + constraints: BoxConstraints.loose(const Size(240, 200)), + controller: popoverController, + direction: PopoverDirection.topWithLeftAligned, + triggerActions: PopoverTriggerFlags.none, + margin: EdgeInsets.zero, + child: SizedBox( + height: 30, + child: FlowyButton( + margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), + text: FlowyText.medium( + LocaleKeys.grid_field_newProperty.tr(), + color: Theme.of(context).hintColor, + ), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + onTap: () async { + final result = await TypeOptionBackendService.createFieldTypeOption( + viewId: widget.viewId, + ); + result.fold( + (l) { + typeOption = l; + popoverController.show(); + }, + (r) => Log.error("Failed to create field type option: $r"), + ); + }, + leftIcon: FlowySvg( + FlowySvgs.add_m, + color: Theme.of(context).hintColor, + ), + ), + ), + popupBuilder: (BuildContext popOverContext) { + return FieldEditor( + viewId: widget.viewId, + typeOptionLoader: FieldTypeOptionLoader( + viewId: widget.viewId, + field: typeOption.field_2, + ), + onDeleted: (fieldId) { + popoverController.close(); + NavigatorAlertDialog( + title: LocaleKeys.grid_field_deleteFieldPromptMessage.tr(), + confirm: () { + context + .read() + .add(RowDetailEvent.deleteField(fieldId)); + }, + ).show(context); + }, + ); + }, + ); + } +} diff --git a/frontend/resources/flowy_icons/16x/details_horizontal.svg b/frontend/resources/flowy_icons/16x/details_horizontal.svg new file mode 100644 index 0000000000..1d3d07b915 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/details_horizontal.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/emoji.svg b/frontend/resources/flowy_icons/16x/emoji.svg new file mode 100644 index 0000000000..b5fda7ea74 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/emoji.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + +