feat: add basic relation field (#4397)

* feat: add basic relation field

* fix: clippy

* fix: tauri build 🤞

* chore: merge changes

* fix: merge main

* chore: initial code review pass

* fix: rust-lib test

* chore: code cleanup

* fix: unwrap or default
This commit is contained in:
Richard Shiue
2024-02-29 14:38:18 +08:00
committed by GitHub
parent f826d05f03
commit f4ca3ef782
54 changed files with 1804 additions and 34 deletions

View File

@ -1,5 +1,6 @@
import 'package:appflowy/plugins/database/application/cell/cell_controller.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/relation_card_cell.dart';
import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/timestamp_card_cell.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:flutter/widgets.dart';
@ -84,6 +85,12 @@ class CardCellBuilder {
databaseController: databaseController,
cellContext: cellContext,
),
FieldType.Relation => RelationCardCell(
key: key,
style: isStyleOrNull(style),
databaseController: databaseController,
cellContext: cellContext,
),
_ => throw UnimplementedError,
};
}

View File

@ -0,0 +1,82 @@
import 'package:appflowy/plugins/database/application/cell/cell_controller.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'card_cell.dart';
class RelationCardCellStyle extends CardCellStyle {
RelationCardCellStyle({
required super.padding,
required this.textStyle,
required this.wrap,
});
final TextStyle textStyle;
final bool wrap;
}
class RelationCardCell extends CardCell<RelationCardCellStyle> {
const RelationCardCell({
super.key,
required super.style,
required this.databaseController,
required this.cellContext,
});
final DatabaseController databaseController;
final CellContext cellContext;
@override
State<RelationCardCell> createState() => _RelationCellState();
}
class _RelationCellState extends State<RelationCardCell> {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) {
return RelationCellBloc(
cellController: makeCellController(
widget.databaseController,
widget.cellContext,
).as(),
);
},
child: BlocBuilder<RelationCellBloc, RelationCellState>(
builder: (context, state) {
if (state.rows.isEmpty) {
return const SizedBox.shrink();
}
final children = state.rows
.map(
(row) => FlowyText.medium(
row.name,
decoration: TextDecoration.underline,
overflow: TextOverflow.ellipsis,
),
)
.toList();
return Container(
alignment: AlignmentDirectional.topStart,
padding: widget.style.padding,
child: widget.style.wrap
? Wrap(spacing: 4, runSpacing: 4, children: children)
: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: children,
),
),
);
},
),
);
}
}

View File

@ -6,6 +6,7 @@ import '../card_cell_skeleton/checkbox_card_cell.dart';
import '../card_cell_skeleton/checklist_card_cell.dart';
import '../card_cell_skeleton/date_card_cell.dart';
import '../card_cell_skeleton/number_card_cell.dart';
import '../card_cell_skeleton/relation_card_cell.dart';
import '../card_cell_skeleton/select_option_card_cell.dart';
import '../card_cell_skeleton/text_card_cell.dart';
import '../card_cell_skeleton/timestamp_card_cell.dart';
@ -73,5 +74,10 @@ CardCellStyleMap desktopCalendarCardCellStyleMap(BuildContext context) {
decoration: TextDecoration.underline,
),
),
FieldType.Relation: RelationCardCellStyle(
padding: padding,
wrap: true,
textStyle: textStyle,
),
};
}

View File

@ -6,6 +6,7 @@ import '../card_cell_skeleton/checkbox_card_cell.dart';
import '../card_cell_skeleton/checklist_card_cell.dart';
import '../card_cell_skeleton/date_card_cell.dart';
import '../card_cell_skeleton/number_card_cell.dart';
import '../card_cell_skeleton/relation_card_cell.dart';
import '../card_cell_skeleton/select_option_card_cell.dart';
import '../card_cell_skeleton/text_card_cell.dart';
import '../card_cell_skeleton/timestamp_card_cell.dart';
@ -73,5 +74,10 @@ CardCellStyleMap desktopBoardCardCellStyleMap(BuildContext context) {
decoration: TextDecoration.underline,
),
),
FieldType.Relation: RelationCardCellStyle(
padding: padding,
wrap: true,
textStyle: textStyle,
),
};
}

View File

@ -6,6 +6,7 @@ import '../card_cell_skeleton/checkbox_card_cell.dart';
import '../card_cell_skeleton/checklist_card_cell.dart';
import '../card_cell_skeleton/date_card_cell.dart';
import '../card_cell_skeleton/number_card_cell.dart';
import '../card_cell_skeleton/relation_card_cell.dart';
import '../card_cell_skeleton/select_option_card_cell.dart';
import '../card_cell_skeleton/text_card_cell.dart';
import '../card_cell_skeleton/timestamp_card_cell.dart';
@ -72,5 +73,10 @@ CardCellStyleMap mobileBoardCardCellStyleMap(BuildContext context) {
decoration: TextDecoration.underline,
),
),
FieldType.Relation: RelationCardCellStyle(
padding: padding,
textStyle: textStyle,
wrap: true,
),
};
}

View File

@ -0,0 +1,58 @@
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/relation_cell_editor.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../editable_cell_skeleton/relation.dart';
class DesktopGridRelationCellSkin extends IEditableRelationCellSkin {
@override
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
RelationCellBloc bloc,
RelationCellState state,
PopoverController popoverController,
) {
return AppFlowyPopover(
controller: popoverController,
direction: PopoverDirection.bottomWithLeftAligned,
constraints: const BoxConstraints(maxWidth: 400, maxHeight: 400),
margin: EdgeInsets.zero,
onClose: () => cellContainerNotifier.isFocus = false,
popupBuilder: (context) {
return BlocProvider.value(
value: bloc,
child: RelationCellEditor(
selectedRowIds: state.rows.map((row) => row.rowId).toList(),
databaseId: state.relatedDatabaseId,
onSelectRow: (rowId) {
bloc.add(RelationCellEvent.selectRow(rowId));
},
),
);
},
child: Container(
alignment: AlignmentDirectional.centerStart,
padding: GridSize.cellContentInsets,
child: Wrap(
runSpacing: 4.0,
spacing: 4.0,
children: state.rows
.map(
(row) => FlowyText.medium(
row.name,
decoration: TextDecoration.underline,
overflow: TextOverflow.ellipsis,
),
)
.toList(),
),
),
);
}
}

View File

@ -0,0 +1,61 @@
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/relation_cell_editor.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../editable_cell_skeleton/relation.dart';
class DesktopRowDetailRelationCellSkin extends IEditableRelationCellSkin {
@override
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
RelationCellBloc bloc,
RelationCellState state,
PopoverController popoverController,
) {
return AppFlowyPopover(
controller: popoverController,
direction: PopoverDirection.bottomWithLeftAligned,
constraints: const BoxConstraints(maxWidth: 400, maxHeight: 400),
margin: EdgeInsets.zero,
onClose: () => cellContainerNotifier.isFocus = false,
popupBuilder: (context) {
return BlocProvider.value(
value: bloc,
child: BlocBuilder<RelationCellBloc, RelationCellState>(
builder: (context, state) => RelationCellEditor(
selectedRowIds: state.rows.map((row) => row.rowId).toList(),
databaseId: state.relatedDatabaseId,
onSelectRow: (rowId) {
context
.read<RelationCellBloc>()
.add(RelationCellEvent.selectRow(rowId));
},
),
),
);
},
child: Container(
alignment: AlignmentDirectional.centerStart,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Wrap(
runSpacing: 4.0,
spacing: 4.0,
children: state.rows
.map(
(row) => FlowyText.medium(
row.name,
decoration: TextDecoration.underline,
overflow: TextOverflow.ellipsis,
),
)
.toList(),
),
),
);
}
}

View File

@ -13,6 +13,7 @@ import 'editable_cell_skeleton/checkbox.dart';
import 'editable_cell_skeleton/checklist.dart';
import 'editable_cell_skeleton/date.dart';
import 'editable_cell_skeleton/number.dart';
import 'editable_cell_skeleton/relation.dart';
import 'editable_cell_skeleton/select_option.dart';
import 'editable_cell_skeleton/text.dart';
import 'editable_cell_skeleton/timestamp.dart';
@ -106,6 +107,12 @@ class EditableCellBuilder {
skin: IEditableURLCellSkin.fromStyle(style),
key: key,
),
FieldType.Relation => EditableRelationCell(
databaseController: databaseController,
cellContext: cellContext,
skin: IEditableRelationCellSkin.fromStyle(style),
key: key,
),
_ => throw UnimplementedError(),
};
}
@ -186,6 +193,12 @@ class EditableCellBuilder {
skin: skinMap.urlSkin!,
key: key,
),
FieldType.Relation => EditableRelationCell(
databaseController: databaseController,
cellContext: cellContext,
skin: skinMap.relationSkin!,
key: key,
),
_ => throw UnimplementedError(),
};
}
@ -340,6 +353,7 @@ class EditableCellSkinMap {
this.numberSkin,
this.textSkin,
this.urlSkin,
this.relationSkin,
});
final IEditableCheckboxCellSkin? checkboxSkin;
@ -350,6 +364,7 @@ class EditableCellSkinMap {
final IEditableNumberCellSkin? numberSkin;
final IEditableTextCellSkin? textSkin;
final IEditableURLCellSkin? urlSkin;
final IEditableRelationCellSkin? relationSkin;
bool has(FieldType fieldType) {
return switch (fieldType) {

View File

@ -0,0 +1,94 @@
import 'package:appflowy/plugins/database/application/cell/cell_controller.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../desktop_grid/desktop_grid_relation_cell.dart';
import '../desktop_row_detail/desktop_row_detail_relation_cell.dart';
import '../mobile_grid/mobile_grid_relation_cell.dart';
import '../mobile_row_detail/mobile_row_detail_relation_cell.dart';
abstract class IEditableRelationCellSkin {
factory IEditableRelationCellSkin.fromStyle(EditableCellStyle style) {
return switch (style) {
EditableCellStyle.desktopGrid => DesktopGridRelationCellSkin(),
EditableCellStyle.desktopRowDetail => DesktopRowDetailRelationCellSkin(),
EditableCellStyle.mobileGrid => MobileGridRelationCellSkin(),
EditableCellStyle.mobileRowDetail => MobileRowDetailRelationCellSkin(),
};
}
const IEditableRelationCellSkin();
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
RelationCellBloc bloc,
RelationCellState state,
PopoverController popoverController,
);
}
class EditableRelationCell extends EditableCellWidget {
EditableRelationCell({
super.key,
required this.databaseController,
required this.cellContext,
required this.skin,
});
final DatabaseController databaseController;
final CellContext cellContext;
final IEditableRelationCellSkin skin;
@override
GridCellState<EditableRelationCell> createState() => _RelationCellState();
}
class _RelationCellState extends GridCellState<EditableRelationCell> {
final PopoverController _popover = PopoverController();
late final cellBloc = RelationCellBloc(
cellController: makeCellController(
widget.databaseController,
widget.cellContext,
).as(),
);
@override
void dispose() {
cellBloc.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: cellBloc,
child: BlocBuilder<RelationCellBloc, RelationCellState>(
builder: (context, state) {
return widget.skin.build(
context,
widget.cellContainerNotifier,
cellBloc,
state,
_popover,
);
},
),
);
}
@override
void onRequestFocus() {
_popover.show();
widget.cellContainerNotifier.isFocus = true;
}
@override
String? onCopy() => "";
}

View File

@ -0,0 +1,54 @@
import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import '../editable_cell_skeleton/relation.dart';
class MobileGridRelationCellSkin extends IEditableRelationCellSkin {
@override
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
RelationCellBloc bloc,
RelationCellState state,
PopoverController popoverController,
) {
return FlowyButton(
radius: BorderRadius.zero,
hoverColor: Colors.transparent,
margin: EdgeInsets.zero,
text: Align(
alignment: AlignmentDirectional.centerStart,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
children: state.rows
.map(
(row) => FlowyText(
row.name,
fontSize: 15,
decoration: TextDecoration.underline,
),
)
.toList(),
),
),
),
onTap: () {
showMobileBottomSheet(
context,
padding: EdgeInsets.zero,
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
builder: (context) {
return const FlowyText("Coming soon");
},
);
},
);
}
}

View File

@ -0,0 +1,56 @@
import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart';
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/relation.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
class MobileRowDetailRelationCellSkin extends IEditableRelationCellSkin {
@override
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
RelationCellBloc bloc,
RelationCellState state,
PopoverController popoverController,
) {
return InkWell(
borderRadius: const BorderRadius.all(Radius.circular(14)),
onTap: () => showMobileBottomSheet(
context,
padding: EdgeInsets.zero,
builder: (context) {
return const FlowyText("Coming soon");
},
),
child: Container(
constraints: const BoxConstraints(
minHeight: 48,
minWidth: double.infinity,
),
decoration: BoxDecoration(
border: Border.fromBorderSide(
BorderSide(color: Theme.of(context).colorScheme.outline),
),
borderRadius: const BorderRadius.all(Radius.circular(14)),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 13),
child: Wrap(
runSpacing: 4.0,
spacing: 4.0,
children: state.rows
.map(
(row) => FlowyText(
row.name,
fontSize: 16,
decoration: TextDecoration.underline,
overflow: TextOverflow.ellipsis,
),
)
.toList(),
),
),
);
}
}

View File

@ -0,0 +1,148 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.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: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 '../../application/cell/bloc/relation_cell_bloc.dart';
import '../../application/cell/bloc/relation_row_search_bloc.dart';
class RelationCellEditor extends StatelessWidget {
const RelationCellEditor({
super.key,
required this.databaseId,
required this.selectedRowIds,
required this.onSelectRow,
});
final String databaseId;
final List<String> selectedRowIds;
final void Function(String rowId) onSelectRow;
@override
Widget build(BuildContext context) {
if (databaseId.isEmpty) {
// no i18n here because UX needs thorough checking.
return const Center(
child: FlowyText(
'''
No database has been selected,
please select one first in the field editor.
''',
maxLines: null,
textAlign: TextAlign.center,
),
);
}
return BlocProvider<RelationRowSearchBloc>(
create: (context) => RelationRowSearchBloc(
databaseId: databaseId,
),
child: BlocBuilder<RelationCellBloc, RelationCellState>(
builder: (context, cellState) {
return 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)
? FlowySvg(
FlowySvgs.check_s,
color: Theme.of(context).primaryColor,
)
: null,
onTap: () => onSelectRow(row.rowId),
),
),
)
.toList();
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const VSpace(6.0),
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,
),
const HSpace(2.0),
FlowyButton(
useIntrinsicWidth: true,
margin: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
text: FlowyText.regular(
cellState.relatedDatabaseId,
fontSize: 11,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
VSpace(GridSize.typeOptionSeparatorHeight),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6.0),
child: FlowyTextField(
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],
),
),
],
);
},
);
},
),
);
}
}

View File

@ -75,6 +75,7 @@ class _SelectOptionTextFieldState extends State<SelectOptionTextField> {
@override
void dispose() {
widget.textController.removeListener(_onChanged);
focusNode.dispose();
super.dispose();
}