From 066a511dc586c811c38d4333d531daf827272880 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Wed, 10 Apr 2024 20:39:20 +0800 Subject: [PATCH] feat: relation field improvements (#5070) * feat: keyboard navigation for relation cell editor * feat: open related rows * feat: separated selected and unselected rows * chore: apply suggestions from code review Co-authored-by: Lucas.Xu * chore: fix launch review issues * chore: add documentation --------- Co-authored-by: Lucas.Xu --- .../cell/bloc/relation_row_search_bloc.dart | 71 ++- .../row/related_row_detail_bloc.dart | 124 ++++ .../desktop_grid_relation_cell.dart | 4 +- .../desktop_row_detail_relation_cell.dart | 4 +- .../cell_editor/relation_cell_editor.dart | 536 ++++++++++++++---- .../select_option_cell_editor.dart | 2 +- .../widgets/row/relation_row_detail.dart | 39 ++ frontend/resources/translations/en.json | 4 +- 8 files changed, 664 insertions(+), 120 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/plugins/database/application/row/related_row_detail_bloc.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/database/widgets/row/relation_row_detail.dart diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_row_search_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_row_search_bloc.dart index 995e5c85d4..2e07af6511 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_row_search_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_row_search_bloc.dart @@ -1,7 +1,9 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:bloc/bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -24,11 +26,22 @@ class RelationRowSearchBloc (event, emit) { event.when( didUpdateRowList: (List rowList) { - allRows.clear(); - allRows.addAll(rowList); - emit(state.copyWith(filteredRows: allRows)); + allRows + ..clear() + ..addAll(rowList); + emit( + state.copyWith( + filteredRows: allRows, + focusedRowId: state.focusedRowId ?? allRows.firstOrNull?.rowId, + ), + ); }, updateFilter: (String filter) => _updateFilter(filter, emit), + updateFocusedOption: (String rowId) { + emit(state.copyWith(focusedRowId: rowId)); + }, + focusPreviousOption: () => _focusOption(true, emit), + focusNextOption: () => _focusOption(false, emit), ); }, ); @@ -45,12 +58,50 @@ class RelationRowSearchBloc void _updateFilter(String filter, Emitter emit) { final rows = [...allRows]; + if (filter.isNotEmpty) { rows.retainWhere( - (row) => row.name.toLowerCase().contains(filter.toLowerCase()), + (row) => + row.name.toLowerCase().contains(filter.toLowerCase()) || + (row.name.isEmpty && + LocaleKeys.grid_row_titlePlaceholder + .tr() + .toLowerCase() + .contains(filter.toLowerCase())), ); } - emit(state.copyWith(filter: filter, filteredRows: rows)); + + final focusedRowId = rows.isEmpty + ? null + : rows.any((row) => row.rowId == state.focusedRowId) + ? state.focusedRowId + : rows.first.rowId; + + emit( + state.copyWith( + filteredRows: rows, + focusedRowId: focusedRowId, + ), + ); + } + + void _focusOption(bool previous, Emitter emit) { + if (state.filteredRows.isEmpty) { + return; + } + + final rowIds = state.filteredRows.map((e) => e.rowId).toList(); + final currentIndex = state.focusedRowId == null + ? -1 + : rowIds.indexWhere((id) => id == state.focusedRowId); + + // If the current index is -1, it means that the focused row is not in the list of row ids. + // In this case, we set the new index to the last index if previous is true, otherwise to 0. + final newIndex = currentIndex == -1 + ? (previous ? rowIds.length - 1 : 0) + : (currentIndex + (previous ? -1 : 1)) % rowIds.length; + + emit(state.copyWith(focusedRowId: rowIds[newIndex])); } } @@ -61,17 +112,23 @@ class RelationRowSearchEvent with _$RelationRowSearchEvent { ) = _DidUpdateRowList; const factory RelationRowSearchEvent.updateFilter(String filter) = _UpdateFilter; + const factory RelationRowSearchEvent.updateFocusedOption( + String rowId, + ) = _UpdateFocusedOption; + const factory RelationRowSearchEvent.focusPreviousOption() = + _FocusPreviousOption; + const factory RelationRowSearchEvent.focusNextOption() = _FocusNextOption; } @freezed class RelationRowSearchState with _$RelationRowSearchState { const factory RelationRowSearchState({ - required String filter, required List filteredRows, + required String? focusedRowId, }) = _RelationRowSearchState; factory RelationRowSearchState.initial() => const RelationRowSearchState( - filter: "", filteredRows: [], + focusedRowId: null, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/row/related_row_detail_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/row/related_row_detail_bloc.dart new file mode 100644 index 0000000000..02fd79f466 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/row/related_row_detail_bloc.dart @@ -0,0 +1,124 @@ +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:bloc/bloc.dart'; +import 'package:collection/collection.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../database_controller.dart'; +import 'row_controller.dart'; + +part 'related_row_detail_bloc.freezed.dart'; + +class RelatedRowDetailPageBloc + extends Bloc { + RelatedRowDetailPageBloc({ + required String databaseId, + required String initialRowId, + }) : super(const RelatedRowDetailPageState.loading()) { + _dispatch(); + _init(databaseId, initialRowId); + } + + @override + Future close() { + state.whenOrNull( + ready: (databaseController, rowController) { + rowController.dispose(); + databaseController.dispose(); + }, + ); + return super.close(); + } + + void _dispatch() { + on((event, emit) async { + event.when( + didInitialize: (databaseController, rowController) { + state.maybeWhen( + ready: (_, oldRowController) { + oldRowController.dispose(); + emit( + RelatedRowDetailPageState.ready( + databaseController: databaseController, + rowController: rowController, + ), + ); + }, + orElse: () { + emit( + RelatedRowDetailPageState.ready( + databaseController: databaseController, + rowController: rowController, + ), + ); + }, + ); + }, + ); + }); + } + + /// initialize bloc through the `database_id` and `row_id`. The process is as + /// follows: + /// 1. use the `database_id` to get the database meta, which contains the + /// `inline_view_id` + /// 2. use the `inline_view_id` to instantiate a `DatabaseController`. + /// 3. use the `row_id` with the DatabaseController` to create `RowController` + void _init(String databaseId, String initialRowId) async { + final databaseMeta = await DatabaseEventGetDatabases() + .send() + .fold( + (s) => s.items + .firstWhereOrNull((metaPB) => metaPB.databaseId == databaseId), + (f) => null, + ); + if (databaseMeta == null) { + return; + } + final inlineView = + await ViewBackendService.getView(databaseMeta.inlineViewId) + .fold((viewPB) => viewPB, (f) => null); + if (inlineView == null) { + return; + } + final databaseController = DatabaseController(view: inlineView); + await databaseController.open().fold( + (s) => databaseController.setIsLoading(false), + (f) => null, + ); + final rowInfo = databaseController.rowCache.getRow(initialRowId); + if (rowInfo == null) { + return; + } + final rowController = RowController( + rowMeta: rowInfo.rowMeta, + viewId: inlineView.id, + rowCache: databaseController.rowCache, + ); + add( + RelatedRowDetailPageEvent.didInitialize( + databaseController, + rowController, + ), + ); + } +} + +@freezed +class RelatedRowDetailPageEvent with _$RelatedRowDetailPageEvent { + const factory RelatedRowDetailPageEvent.didInitialize( + DatabaseController databaseController, + RowController rowController, + ) = _DidInitialize; +} + +@freezed +class RelatedRowDetailPageState with _$RelatedRowDetailPageState { + const factory RelatedRowDetailPageState.loading() = _LoadingState; + const factory RelatedRowDetailPageState.ready({ + required DatabaseController databaseController, + required RowController rowController, + }) = _ReadyState; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_relation_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_relation_cell.dart index a2e6c9fa8b..471edd364a 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_relation_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_relation_cell.dart @@ -29,9 +29,7 @@ class DesktopGridRelationCellSkin extends IEditableRelationCellSkin { popupBuilder: (context) { return BlocProvider.value( value: bloc, - child: RelationCellEditor( - selectedRowIds: state.rows.map((row) => row.rowId).toList(), - ), + child: const RelationCellEditor(), ); }, child: Container( diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_relation_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_relation_cell.dart index 63b70c0f78..e481d33cd5 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_relation_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_relation_cell.dart @@ -29,9 +29,7 @@ class DesktopRowDetailRelationCellSkin extends IEditableRelationCellSkin { popupBuilder: (context) { return BlocProvider.value( value: bloc, - child: RelationCellEditor( - selectedRowIds: state.rows.map((row) => row.rowId).toList(), - ), + child: const RelationCellEditor(), ); }, child: Container( diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/relation_cell_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/relation_cell_editor.dart index d3bf428ed8..70383361d7 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/relation_cell_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/relation_cell_editor.dart @@ -1,11 +1,18 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/field/type_option/relation_type_option_cubit.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.dart'; +import 'package:appflowy/plugins/database/widgets/row/relation_row_detail.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../application/cell/bloc/relation_cell_bloc.dart'; @@ -14,132 +21,451 @@ import '../../application/cell/bloc/relation_row_search_bloc.dart'; class RelationCellEditor extends StatelessWidget { const RelationCellEditor({ super.key, - required this.selectedRowIds, }); - final List selectedRowIds; - @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, cellState) { - if (cellState.relatedDatabaseMeta == null) { - return const _RelationCellEditorDatabaseList(); + return cellState.relatedDatabaseMeta == null + ? const _RelationCellEditorDatabasePicker() + : _RelationCellEditorContent( + relatedDatabaseMeta: cellState.relatedDatabaseMeta!, + selectedRowIds: cellState.rows.map((e) => e.rowId).toList(), + ); + }, + ); + } +} + +class _RelationCellEditorContent extends StatefulWidget { + const _RelationCellEditorContent({ + required this.relatedDatabaseMeta, + required this.selectedRowIds, + }); + + final DatabaseMeta relatedDatabaseMeta; + final List selectedRowIds; + + @override + State<_RelationCellEditorContent> createState() => + _RelationCellEditorContentState(); +} + +class _RelationCellEditorContentState + extends State<_RelationCellEditorContent> { + final textEditingController = TextEditingController(); + late final FocusNode focusNode; + late final bloc = RelationRowSearchBloc( + databaseId: widget.relatedDatabaseMeta.databaseId, + ); + + @override + void initState() { + super.initState(); + focusNode = FocusNode( + onKeyEvent: (node, event) { + switch (event.logicalKey) { + case LogicalKeyboardKey.arrowUp when event is! KeyUpEvent: + if (textEditingController.value.composing.isCollapsed) { + bloc.add(const RelationRowSearchEvent.focusPreviousOption()); + return KeyEventResult.handled; + } + break; + case LogicalKeyboardKey.arrowDown when event is! KeyUpEvent: + if (textEditingController.value.composing.isCollapsed) { + bloc.add(const RelationRowSearchEvent.focusNextOption()); + return KeyEventResult.handled; + } + break; + case LogicalKeyboardKey.escape when event is! KeyUpEvent: + if (!textEditingController.value.composing.isCollapsed) { + final end = textEditingController.value.composing.end; + final text = textEditingController.text; + + textEditingController.value = TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: end), + ); + return KeyEventResult.handled; + } + break; } + return KeyEventResult.ignored; + }, + ); + } - return BlocProvider( - create: (context) => RelationRowSearchBloc( - databaseId: cellState.relatedDatabaseMeta!.databaseId, - ), - child: BlocBuilder( - builder: (context, state) { - final children = state.filteredRows - .map( - (row) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 6.0), - child: FlowyButton( - text: FlowyText.medium( - row.name.trim().isEmpty - ? LocaleKeys.grid_title_placeholder.tr() - : row.name, - color: row.name.trim().isEmpty - ? Theme.of(context).hintColor - : null, - overflow: TextOverflow.ellipsis, - ), - rightIcon: cellState.rows - .map((e) => e.rowId) - .contains(row.rowId) - ? const FlowySvg( - FlowySvgs.check_s, - ) - : null, - onTap: () => context - .read() - .add(RelationCellEvent.selectRow(row.rowId)), - ), - ), - ) - .toList(); + @override + void dispose() { + textEditingController.dispose(); + focusNode.dispose(); + bloc.close(); + super.dispose(); + } - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 6.0) + - GridSize.typeOptionContentInsets, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - FlowyText.regular( - LocaleKeys.grid_relation_inRelatedDatabase.tr(), - fontSize: 11, - color: Theme.of(context).hintColor, - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 2, - ), - child: FlowyText.regular( - cellState.relatedDatabaseMeta!.databaseName, - fontSize: 11, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 6.0), - child: FlowyTextField( - hintText: LocaleKeys - .grid_relation_rowSearchTextFieldPlaceholder - .tr(), - hintStyle: Theme.of(context) - .textTheme - .bodySmall - ?.copyWith(color: Theme.of(context).hintColor), - onChanged: (text) => context - .read() - .add(RelationRowSearchEvent.updateFilter(text)), - ), - ), - const VSpace(6.0), - const TypeOptionSeparator(spacing: 0.0), - if (state.filteredRows.isEmpty) - Padding( + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: bloc, + child: BlocBuilder( + buildWhen: (previous, current) => + !listEquals(previous.filteredRows, current.filteredRows), + builder: (context, state) { + final selected = []; + final unselected = []; + for (final row in state.filteredRows) { + if (widget.selectedRowIds.contains(row.rowId)) { + selected.add(row); + } else { + unselected.add(row); + } + } + return TextFieldTapRegion( + child: CustomScrollView( + shrinkWrap: true, + slivers: [ + _CellEditorTitle( + databaseName: widget.relatedDatabaseMeta.databaseName, + ), + _SearchField( + focusNode: focusNode, + textEditingController: textEditingController, + ), + const SliverToBoxAdapter( + child: TypeOptionSeparator(spacing: 0.0), + ), + if (state.filteredRows.isEmpty) + SliverToBoxAdapter( + child: Padding( padding: const EdgeInsets.all(6.0) + GridSize.typeOptionContentInsets, child: FlowyText.regular( LocaleKeys.grid_relation_emptySearchResult.tr(), color: Theme.of(context).hintColor, ), - ) - else - Flexible( - child: ListView.separated( - shrinkWrap: true, - padding: const EdgeInsets.symmetric(vertical: 6.0), - separatorBuilder: (context, index) => - VSpace(GridSize.typeOptionSeparatorHeight), - itemCount: children.length, - itemBuilder: (context, index) => children[index], + ), + ), + if (selected.isNotEmpty) ...[ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6.0) + + GridSize.typeOptionContentInsets, + child: FlowyText.regular( + LocaleKeys.grid_relation_linkedRowListLabel.plural( + selected.length, + namedArgs: {'count': '${selected.length}'}, + ), + fontSize: 11, + overflow: TextOverflow.ellipsis, + color: Theme.of(context).hintColor, ), ), + ), + _RowList( + databaseId: widget.relatedDatabaseMeta.databaseId, + rows: selected, + isSelected: true, + ), + const SliverToBoxAdapter( + child: VSpace(4.0), + ), ], - ); - }, - ), - ); - }, + if (unselected.isNotEmpty) ...[ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6.0) + + GridSize.typeOptionContentInsets, + child: FlowyText.regular( + LocaleKeys.grid_relation_unlinkedRowListLabel.tr(), + fontSize: 11, + overflow: TextOverflow.ellipsis, + color: Theme.of(context).hintColor, + ), + ), + ), + _RowList( + databaseId: widget.relatedDatabaseMeta.databaseId, + rows: unselected, + isSelected: false, + ), + const SliverToBoxAdapter( + child: VSpace(4.0), + ), + ], + ], + ), + ); + }, + ), ); } } -class _RelationCellEditorDatabaseList extends StatelessWidget { - const _RelationCellEditorDatabaseList(); +class _CellEditorTitle extends StatelessWidget { + const _CellEditorTitle({ + required this.databaseName, + }); + + final String databaseName; + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6.0) + + GridSize.typeOptionContentInsets, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText.regular( + LocaleKeys.grid_relation_inRelatedDatabase.tr(), + fontSize: 11, + color: Theme.of(context).hintColor, + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 2, + ), + child: FlowyText.regular( + databaseName, + fontSize: 11, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + } +} + +class _SearchField extends StatelessWidget { + const _SearchField({ + required this.focusNode, + required this.textEditingController, + }); + + final FocusNode focusNode; + final TextEditingController textEditingController; + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(left: 6.0, bottom: 6.0, right: 6.0), + child: FlowyTextField( + focusNode: focusNode, + controller: textEditingController, + hintText: LocaleKeys.grid_relation_rowSearchTextFieldPlaceholder.tr(), + hintStyle: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: Theme.of(context).hintColor), + onChanged: (text) { + if (textEditingController.value.composing.isCollapsed) { + context + .read() + .add(RelationRowSearchEvent.updateFilter(text)); + } + }, + onSubmitted: (_) { + final focusedRowId = + context.read().state.focusedRowId; + if (focusedRowId != null) { + final row = context + .read() + .state + .rows + .firstWhereOrNull((e) => e.rowId == focusedRowId); + if (row != null) { + FlowyOverlay.show( + context: context, + builder: (BuildContext overlayContext) { + return RelatedRowDetailPage( + databaseId: context + .read() + .state + .relatedDatabaseMeta! + .databaseId, + rowId: row.rowId, + ); + }, + ); + PopoverContainer.of(context).close(); + } else { + context + .read() + .add(RelationCellEvent.selectRow(focusedRowId)); + } + } + focusNode.requestFocus(); + }, + ), + ), + ); + } +} + +class _RowList extends StatelessWidget { + const _RowList({ + required this.databaseId, + required this.rows, + required this.isSelected, + }); + + final String databaseId; + final List rows; + final bool isSelected; + + @override + Widget build(BuildContext context) { + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => _RowListItem( + row: rows[index], + databaseId: databaseId, + isSelected: isSelected, + ), + childCount: rows.length, + ), + ); + } +} + +class _RowListItem extends StatelessWidget { + const _RowListItem({ + required this.row, + required this.isSelected, + required this.databaseId, + }); + + final RelatedRowDataPB row; + final String databaseId; + final bool isSelected; + + @override + Widget build(BuildContext context) { + final isHovered = + context.watch().state.focusedRowId == row.rowId; + return Container( + height: 28, + margin: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 2.0), + decoration: BoxDecoration( + color: isHovered ? AFThemeExtension.of(context).lightGreyHover : null, + borderRadius: const BorderRadius.all(Radius.circular(6)), + ), + child: GestureDetector( + onTap: () { + if (isSelected) { + FlowyOverlay.show( + context: context, + builder: (BuildContext overlayContext) { + return RelatedRowDetailPage( + databaseId: databaseId, + rowId: row.rowId, + ); + }, + ); + PopoverContainer.of(context).close(); + } else { + context + .read() + .add(RelationCellEvent.selectRow(row.rowId)); + } + }, + child: MouseRegion( + cursor: SystemMouseCursors.click, + onHover: (_) => context + .read() + .add(RelationRowSearchEvent.updateFocusedOption(row.rowId)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 4.0), + child: Row( + children: [ + Expanded( + child: FlowyText.medium( + row.name.trim().isEmpty + ? LocaleKeys.grid_title_placeholder.tr() + : row.name, + color: row.name.trim().isEmpty + ? Theme.of(context).hintColor + : null, + overflow: TextOverflow.ellipsis, + ), + ), + if (isSelected && isHovered) + _UnselectRowButton( + onPressed: () => context + .read() + .add(RelationCellEvent.selectRow(row.rowId)), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _UnselectRowButton extends StatefulWidget { + const _UnselectRowButton({ + required this.onPressed, + }); + + final VoidCallback onPressed; + + @override + State<_UnselectRowButton> createState() => _UnselectRowButtonState(); +} + +class _UnselectRowButtonState extends State<_UnselectRowButton> { + final _materialStatesController = MaterialStatesController(); + + @override + void dispose() { + _materialStatesController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TextButton( + onPressed: widget.onPressed, + onHover: (_) => setState(() {}), + onFocusChange: (_) => setState(() {}), + style: ButtonStyle( + fixedSize: const MaterialStatePropertyAll(Size.square(32)), + minimumSize: const MaterialStatePropertyAll(Size.square(32)), + maximumSize: const MaterialStatePropertyAll(Size.square(32)), + overlayColor: MaterialStateProperty.resolveWith((state) { + if (state.contains(MaterialState.focused)) { + return AFThemeExtension.of(context).greyHover; + } + return Colors.transparent; + }), + shape: const MaterialStatePropertyAll( + RoundedRectangleBorder(borderRadius: Corners.s6Border), + ), + ), + statesController: _materialStatesController, + child: Container( + color: _materialStatesController.value + .contains(MaterialState.hovered) || + _materialStatesController.value.contains(MaterialState.focused) + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onBackground, + width: 12, + height: 1, + ), + ); + } +} + +class _RelationCellEditorDatabasePicker extends StatelessWidget { + const _RelationCellEditorDatabasePicker(); @override Widget build(BuildContext context) { diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/select_option_cell_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/select_option_cell_editor.dart index 25f85232ed..3094c97887 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/select_option_cell_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/select_option_cell_editor.dart @@ -479,7 +479,7 @@ class SelectOptionTagCell extends StatelessWidget { alignment: AlignmentDirectional.centerStart, child: Padding( padding: const EdgeInsets.symmetric( - horizontal: 5.0, + horizontal: 6.0, vertical: 4.0, ), child: SelectOptionTag( diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/relation_row_detail.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/relation_row_detail.dart new file mode 100644 index 0000000000..e2c45ea8e5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/relation_row_detail.dart @@ -0,0 +1,39 @@ +import 'package:appflowy/plugins/database/application/row/related_row_detail_bloc.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'row_detail.dart'; + +class RelatedRowDetailPage extends StatelessWidget { + const RelatedRowDetailPage({ + super.key, + required this.databaseId, + required this.rowId, + }); + + final String databaseId; + final String rowId; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => RelatedRowDetailPageBloc( + databaseId: databaseId, + initialRowId: rowId, + ), + child: BlocBuilder( + builder: (context, state) { + return state.when( + loading: () => const SizedBox.shrink(), + ready: (databaseController, rowController) { + return RowDetailPage( + databaseController: databaseController, + rowController: rowController, + ); + }, + ); + }, + ), + ); + } +} diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index ea998fd585..15047bae15 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -774,7 +774,9 @@ "inRelatedDatabase": "In", "rowSearchTextFieldPlaceholder": "Search", "noDatabaseSelected": "No database selected, please select one first from the list below:", - "emptySearchResult": "No records found" + "emptySearchResult": "No records found", + "linkedRowListLabel": "{count} linked rows", + "unlinkedRowListLabel": "Link another row" }, "menuName": "Grid", "referencedGridPrefix": "View of",