mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
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 <lucas.xu@appflowy.io> * chore: fix launch review issues * chore: add documentation --------- Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
This commit is contained in:
parent
3bbba2eeb4
commit
066a511dc5
@ -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<RelatedRowDataPB> 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<RelationRowSearchState> 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<RelationRowSearchState> 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<RelatedRowDataPB> filteredRows,
|
||||
required String? focusedRowId,
|
||||
}) = _RelationRowSearchState;
|
||||
|
||||
factory RelationRowSearchState.initial() => const RelationRowSearchState(
|
||||
filter: "",
|
||||
filteredRows: [],
|
||||
focusedRowId: null,
|
||||
);
|
||||
}
|
||||
|
@ -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<RelatedRowDetailPageEvent, RelatedRowDetailPageState> {
|
||||
RelatedRowDetailPageBloc({
|
||||
required String databaseId,
|
||||
required String initialRowId,
|
||||
}) : super(const RelatedRowDetailPageState.loading()) {
|
||||
_dispatch();
|
||||
_init(databaseId, initialRowId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
state.whenOrNull(
|
||||
ready: (databaseController, rowController) {
|
||||
rowController.dispose();
|
||||
databaseController.dispose();
|
||||
},
|
||||
);
|
||||
return super.close();
|
||||
}
|
||||
|
||||
void _dispatch() {
|
||||
on<RelatedRowDetailPageEvent>((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<DatabaseMetaPB?>(
|
||||
(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;
|
||||
}
|
@ -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(
|
||||
|
@ -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(
|
||||
|
@ -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<String> selectedRowIds;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<RelationCellBloc, RelationCellState>(
|
||||
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<String> 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<RelationRowSearchBloc>(
|
||||
create: (context) => RelationRowSearchBloc(
|
||||
databaseId: cellState.relatedDatabaseMeta!.databaseId,
|
||||
),
|
||||
child: BlocBuilder<RelationRowSearchBloc, RelationRowSearchState>(
|
||||
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<RelationCellBloc>()
|
||||
.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<RelationRowSearchBloc>()
|
||||
.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<RelationRowSearchBloc, RelationRowSearchState>(
|
||||
buildWhen: (previous, current) =>
|
||||
!listEquals(previous.filteredRows, current.filteredRows),
|
||||
builder: (context, state) {
|
||||
final selected = <RelatedRowDataPB>[];
|
||||
final unselected = <RelatedRowDataPB>[];
|
||||
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<RelationRowSearchBloc>()
|
||||
.add(RelationRowSearchEvent.updateFilter(text));
|
||||
}
|
||||
},
|
||||
onSubmitted: (_) {
|
||||
final focusedRowId =
|
||||
context.read<RelationRowSearchBloc>().state.focusedRowId;
|
||||
if (focusedRowId != null) {
|
||||
final row = context
|
||||
.read<RelationCellBloc>()
|
||||
.state
|
||||
.rows
|
||||
.firstWhereOrNull((e) => e.rowId == focusedRowId);
|
||||
if (row != null) {
|
||||
FlowyOverlay.show(
|
||||
context: context,
|
||||
builder: (BuildContext overlayContext) {
|
||||
return RelatedRowDetailPage(
|
||||
databaseId: context
|
||||
.read<RelationCellBloc>()
|
||||
.state
|
||||
.relatedDatabaseMeta!
|
||||
.databaseId,
|
||||
rowId: row.rowId,
|
||||
);
|
||||
},
|
||||
);
|
||||
PopoverContainer.of(context).close();
|
||||
} else {
|
||||
context
|
||||
.read<RelationCellBloc>()
|
||||
.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<RelatedRowDataPB> 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<RelationRowSearchBloc>().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<RelationCellBloc>()
|
||||
.add(RelationCellEvent.selectRow(row.rowId));
|
||||
}
|
||||
},
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onHover: (_) => context
|
||||
.read<RelationRowSearchBloc>()
|
||||
.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<RelationCellBloc>()
|
||||
.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) {
|
||||
|
@ -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(
|
||||
|
@ -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<RelatedRowDetailPageBloc, RelatedRowDetailPageState>(
|
||||
builder: (context, state) {
|
||||
return state.when(
|
||||
loading: () => const SizedBox.shrink(),
|
||||
ready: (databaseController, rowController) {
|
||||
return RowDetailPage(
|
||||
databaseController: databaseController,
|
||||
rowController: rowController,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user