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/dispatch/dispatch.dart';
|
||||||
import 'package:appflowy_backend/log.dart';
|
import 'package:appflowy_backend/log.dart';
|
||||||
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
|
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
|
||||||
import 'package:bloc/bloc.dart';
|
import 'package:bloc/bloc.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
@ -24,11 +26,22 @@ class RelationRowSearchBloc
|
|||||||
(event, emit) {
|
(event, emit) {
|
||||||
event.when(
|
event.when(
|
||||||
didUpdateRowList: (List<RelatedRowDataPB> rowList) {
|
didUpdateRowList: (List<RelatedRowDataPB> rowList) {
|
||||||
allRows.clear();
|
allRows
|
||||||
allRows.addAll(rowList);
|
..clear()
|
||||||
emit(state.copyWith(filteredRows: allRows));
|
..addAll(rowList);
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
filteredRows: allRows,
|
||||||
|
focusedRowId: state.focusedRowId ?? allRows.firstOrNull?.rowId,
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
updateFilter: (String filter) => _updateFilter(filter, emit),
|
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) {
|
void _updateFilter(String filter, Emitter<RelationRowSearchState> emit) {
|
||||||
final rows = [...allRows];
|
final rows = [...allRows];
|
||||||
|
|
||||||
if (filter.isNotEmpty) {
|
if (filter.isNotEmpty) {
|
||||||
rows.retainWhere(
|
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;
|
) = _DidUpdateRowList;
|
||||||
const factory RelationRowSearchEvent.updateFilter(String filter) =
|
const factory RelationRowSearchEvent.updateFilter(String filter) =
|
||||||
_UpdateFilter;
|
_UpdateFilter;
|
||||||
|
const factory RelationRowSearchEvent.updateFocusedOption(
|
||||||
|
String rowId,
|
||||||
|
) = _UpdateFocusedOption;
|
||||||
|
const factory RelationRowSearchEvent.focusPreviousOption() =
|
||||||
|
_FocusPreviousOption;
|
||||||
|
const factory RelationRowSearchEvent.focusNextOption() = _FocusNextOption;
|
||||||
}
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
class RelationRowSearchState with _$RelationRowSearchState {
|
class RelationRowSearchState with _$RelationRowSearchState {
|
||||||
const factory RelationRowSearchState({
|
const factory RelationRowSearchState({
|
||||||
required String filter,
|
|
||||||
required List<RelatedRowDataPB> filteredRows,
|
required List<RelatedRowDataPB> filteredRows,
|
||||||
|
required String? focusedRowId,
|
||||||
}) = _RelationRowSearchState;
|
}) = _RelationRowSearchState;
|
||||||
|
|
||||||
factory RelationRowSearchState.initial() => const RelationRowSearchState(
|
factory RelationRowSearchState.initial() => const RelationRowSearchState(
|
||||||
filter: "",
|
|
||||||
filteredRows: [],
|
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) {
|
popupBuilder: (context) {
|
||||||
return BlocProvider.value(
|
return BlocProvider.value(
|
||||||
value: bloc,
|
value: bloc,
|
||||||
child: RelationCellEditor(
|
child: const RelationCellEditor(),
|
||||||
selectedRowIds: state.rows.map((row) => row.rowId).toList(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
|
@ -29,9 +29,7 @@ class DesktopRowDetailRelationCellSkin extends IEditableRelationCellSkin {
|
|||||||
popupBuilder: (context) {
|
popupBuilder: (context) {
|
||||||
return BlocProvider.value(
|
return BlocProvider.value(
|
||||||
value: bloc,
|
value: bloc,
|
||||||
child: RelationCellEditor(
|
child: const RelationCellEditor(),
|
||||||
selectedRowIds: state.rows.map((row) => row.rowId).toList(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: Container(
|
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/generated/locale_keys.g.dart';
|
||||||
import 'package:appflowy/plugins/database/application/field/type_option/relation_type_option_cubit.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/layout/sizes.dart';
|
||||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.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: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:flowy_infra_ui/flowy_infra_ui.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
import '../../application/cell/bloc/relation_cell_bloc.dart';
|
import '../../application/cell/bloc/relation_cell_bloc.dart';
|
||||||
@ -14,59 +21,198 @@ import '../../application/cell/bloc/relation_row_search_bloc.dart';
|
|||||||
class RelationCellEditor extends StatelessWidget {
|
class RelationCellEditor extends StatelessWidget {
|
||||||
const RelationCellEditor({
|
const RelationCellEditor({
|
||||||
super.key,
|
super.key,
|
||||||
required this.selectedRowIds,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<String> selectedRowIds;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocBuilder<RelationCellBloc, RelationCellState>(
|
return BlocBuilder<RelationCellBloc, RelationCellState>(
|
||||||
builder: (context, cellState) {
|
builder: (context, cellState) {
|
||||||
if (cellState.relatedDatabaseMeta == null) {
|
return cellState.relatedDatabaseMeta == null
|
||||||
return const _RelationCellEditorDatabaseList();
|
? const _RelationCellEditorDatabasePicker()
|
||||||
|
: _RelationCellEditorContent(
|
||||||
|
relatedDatabaseMeta: cellState.relatedDatabaseMeta!,
|
||||||
|
selectedRowIds: cellState.rows.map((e) => e.rowId).toList(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return BlocProvider<RelationRowSearchBloc>(
|
class _RelationCellEditorContent extends StatefulWidget {
|
||||||
create: (context) => RelationRowSearchBloc(
|
const _RelationCellEditorContent({
|
||||||
databaseId: cellState.relatedDatabaseMeta!.databaseId,
|
required this.relatedDatabaseMeta,
|
||||||
),
|
required this.selectedRowIds,
|
||||||
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();
|
|
||||||
|
|
||||||
return Column(
|
final DatabaseMeta relatedDatabaseMeta;
|
||||||
mainAxisSize: MainAxisSize.min,
|
final List<String> selectedRowIds;
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
@override
|
||||||
Padding(
|
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;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
textEditingController.dispose();
|
||||||
|
focusNode.dispose();
|
||||||
|
bloc.close();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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 _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) +
|
padding: const EdgeInsets.symmetric(horizontal: 6.0) +
|
||||||
GridSize.typeOptionContentInsets,
|
GridSize.typeOptionContentInsets,
|
||||||
child: Row(
|
child: Row(
|
||||||
@ -83,7 +229,7 @@ class RelationCellEditor extends StatelessWidget {
|
|||||||
vertical: 2,
|
vertical: 2,
|
||||||
),
|
),
|
||||||
child: FlowyText.regular(
|
child: FlowyText.regular(
|
||||||
cellState.relatedDatabaseMeta!.databaseName,
|
databaseName,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
@ -91,55 +237,235 @@ class RelationCellEditor extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
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(
|
|
||||||
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],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RelationCellEditorDatabaseList extends StatelessWidget {
|
class _SearchField extends StatelessWidget {
|
||||||
const _RelationCellEditorDatabaseList();
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
@ -479,7 +479,7 @@ class SelectOptionTagCell extends StatelessWidget {
|
|||||||
alignment: AlignmentDirectional.centerStart,
|
alignment: AlignmentDirectional.centerStart,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 5.0,
|
horizontal: 6.0,
|
||||||
vertical: 4.0,
|
vertical: 4.0,
|
||||||
),
|
),
|
||||||
child: SelectOptionTag(
|
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",
|
"inRelatedDatabase": "In",
|
||||||
"rowSearchTextFieldPlaceholder": "Search",
|
"rowSearchTextFieldPlaceholder": "Search",
|
||||||
"noDatabaseSelected": "No database selected, please select one first from the list below:",
|
"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",
|
"menuName": "Grid",
|
||||||
"referencedGridPrefix": "View of",
|
"referencedGridPrefix": "View of",
|
||||||
|
Loading…
Reference in New Issue
Block a user