feat: revamp row detail page UI (#3328)

* feat: revamp row detail page UI

* chore: some minor details and fix tests

* fix: fix tests

* chore: remove unused field

* chore: code cleanup

* test: add reordering fields in row page tests

* chore: remove duplicate and delete row events

* chore: timestamp cell ui adjustment

* chore: remove unused code

* test: fix new integration tests
This commit is contained in:
Richard Shiue 2023-09-13 19:10:08 +08:00 committed by GitHub
parent ef6f9a3175
commit 1ca130d7de
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 650 additions and 489 deletions

View File

@ -9,7 +9,7 @@ import 'util/util.dart';
void main() { void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized(); IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('calendar database view', () { group('calendar', () {
testWidgets('update calendar layout', (tester) async { testWidgets('update calendar layout', (tester) async {
await tester.initializeAppFlowy(); await tester.initializeAppFlowy();
await tester.tapGoButton(); await tester.tapGoButton();
@ -116,6 +116,7 @@ void main() {
tester.assertRowDetailPageOpened(); tester.assertRowDetailPageOpened();
// Duplicate the event // Duplicate the event
await tester.tapRowDetailPageRowActionButton();
await tester.tapRowDetailPageDuplicateRowButton(); await tester.tapRowDetailPageDuplicateRowButton();
await tester.dismissRowDetailPage(); await tester.dismissRowDetailPage();
@ -125,6 +126,7 @@ void main() {
// Delete an event // Delete an event
await tester.openCalendarEvent(index: 1); await tester.openCalendarEvent(index: 1);
await tester.tapRowDetailPageRowActionButton();
await tester.tapRowDetailPageDeleteRowButton(); await tester.tapRowDetailPageDeleteRowButton();
// Check that there is 1 event // Check that there is 1 event
@ -155,6 +157,7 @@ void main() {
// Delete the event // Delete the event
await tester.openCalendarEvent(index: 0, date: sameDayNextWeek); await tester.openCalendarEvent(index: 0, date: sameDayNextWeek);
await tester.tapRowDetailPageRowActionButton();
await tester.tapRowDetailPageDeleteRowButton(); await tester.tapRowDetailPageDeleteRowButton();
// Create a new event in today's calendar cell // Create a new event in today's calendar cell

View File

@ -135,7 +135,40 @@ void main() {
} }
}); });
testWidgets('check document is exist in row detail page', (tester) async { testWidgets('change order of fields and cells', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// Create a new grid
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
// Hover first row and then open the row page
await tester.openFirstRowDetailPage();
// Assert that the first field in the row details page is the select
// option tyoe
tester.assertFirstFieldInRowDetailByType(FieldType.SingleSelect);
// Reorder first field in list
final gesture = await tester.hoverOnFieldInRowDetail(index: 0);
await tester.pumpAndSettle();
await tester.reorderFieldInRowDetail(offset: 30);
// Orders changed, now the checkbox is first
tester.assertFirstFieldInRowDetailByType(FieldType.Checkbox);
await gesture.removePointer();
await tester.pumpAndSettle();
// Reorder second field in list
await tester.hoverOnFieldInRowDetail(index: 1);
await tester.pumpAndSettle();
await tester.reorderFieldInRowDetail(offset: -30);
// First field is now back to select option
tester.assertFirstFieldInRowDetailByType(FieldType.SingleSelect);
});
testWidgets('check document exists in row detail page', (tester) async {
await tester.initializeAppFlowy(); await tester.initializeAppFlowy();
await tester.tapGoButton(); await tester.tapGoButton();
@ -149,7 +182,7 @@ void main() {
await tester.assertDocumentExistInRowDetailPage(); await tester.assertDocumentExistInRowDetailPage();
}); });
testWidgets('update the content of the document and re-open it', testWidgets('update the contents of the document and re-open it',
(tester) async { (tester) async {
await tester.initializeAppFlowy(); await tester.initializeAppFlowy();
await tester.tapGoButton(); await tester.tapGoButton();
@ -239,6 +272,7 @@ void main() {
// Hover first row and then open the row page // Hover first row and then open the row page
await tester.openFirstRowDetailPage(); await tester.openFirstRowDetailPage();
await tester.tapRowDetailPageRowActionButton();
await tester.tapRowDetailPageDeleteRowButton(); await tester.tapRowDetailPageDeleteRowButton();
await tester.tapEscButton(); await tester.tapEscButton();
@ -255,6 +289,7 @@ void main() {
// Hover first row and then open the row page // Hover first row and then open the row page
await tester.openFirstRowDetailPage(); await tester.openFirstRowDetailPage();
await tester.tapRowDetailPageRowActionButton();
await tester.tapRowDetailPageDuplicateRowButton(); await tester.tapRowDetailPageDuplicateRowButton();
await tester.tapEscButton(); await tester.tapEscButton();

View File

@ -48,6 +48,7 @@ import 'package:appflowy/plugins/database_view/widgets/row/row_action.dart';
import 'package:appflowy/plugins/database_view/widgets/row/row_banner.dart'; import 'package:appflowy/plugins/database_view/widgets/row/row_banner.dart';
import 'package:appflowy/plugins/database_view/widgets/row/row_detail.dart'; import 'package:appflowy/plugins/database_view/widgets/row/row_detail.dart';
import 'package:appflowy/plugins/database_view/widgets/row/row_document.dart'; import 'package:appflowy/plugins/database_view/widgets/row/row_document.dart';
import 'package:appflowy/plugins/database_view/widgets/row/row_property.dart';
import 'package:appflowy/plugins/database_view/widgets/setting/database_setting.dart'; import 'package:appflowy/plugins/database_view/widgets/setting/database_setting.dart';
import 'package:appflowy/plugins/database_view/widgets/setting/setting_button.dart'; import 'package:appflowy/plugins/database_view/widgets/setting/setting_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/emoji_picker/emoji_menu_item.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/emoji_picker/emoji_menu_item.dart';
@ -485,7 +486,7 @@ extension AppFlowyDatabaseTest on WidgetTester {
expect(banner, findsOneWidget); expect(banner, findsOneWidget);
await startGesture( await startGesture(
getTopLeft(banner), getCenter(banner),
kind: PointerDeviceKind.mouse, kind: PointerDeviceKind.mouse,
); );
@ -524,6 +525,31 @@ extension AppFlowyDatabaseTest on WidgetTester {
await tapButton(deleteButton); await tapButton(deleteButton);
} }
Future<TestGesture> hoverOnFieldInRowDetail({required int index}) async {
final fieldButtons = find.byType(FieldCellButton);
final button = find
.descendant(of: find.byType(RowDetailPage), matching: fieldButtons)
.at(index);
return startGesture(
getCenter(button),
kind: PointerDeviceKind.mouse,
);
}
Future<void> reorderFieldInRowDetail({required double offset}) async {
final thumb = find
.byWidgetPredicate(
(widget) => widget is ReorderableDragStartListener && widget.enabled,
)
.first;
await drag(
thumb,
Offset(0, offset),
kind: PointerDeviceKind.mouse,
);
await pumpAndSettle();
}
Future<void> scrollGridByOffset(Offset offset) async { Future<void> scrollGridByOffset(Offset offset) async {
await drag(find.byType(GridPage), offset); await drag(find.byType(GridPage), offset);
await pumpAndSettle(); await pumpAndSettle();
@ -601,6 +627,10 @@ extension AppFlowyDatabaseTest on WidgetTester {
await tapButton(button); await tapButton(button);
} }
Future<void> tapRowDetailPageRowActionButton() async {
await tapButton(find.byType(RowActionButton));
}
Future<void> tapRowDetailPageCreatePropertyButton() async { Future<void> tapRowDetailPageCreatePropertyButton() async {
await tapButton(find.byType(CreateRowFieldButton)); await tapButton(find.byType(CreateRowFieldButton));
} }
@ -670,6 +700,18 @@ extension AppFlowyDatabaseTest on WidgetTester {
expect(field, findsOneWidget); expect(field, findsOneWidget);
} }
void assertFirstFieldInRowDetailByType(FieldType fieldType) {
final firstField = find
.descendant(
of: find.byType(RowDetailPage),
matching: find.byType(FieldCellButton),
)
.first;
final widget = this.widget<FieldCellButton>(firstField);
expect(widget.field.fieldType, fieldType);
}
Future<void> findFieldWithName(String name) async { Future<void> findFieldWithName(String name) async {
final field = find.byWidgetPredicate( final field = find.byWidgetPredicate(
(widget) => widget is FieldCellButton && widget.field.name == name, (widget) => widget is FieldCellButton && widget.field.name == name,

View File

@ -395,13 +395,7 @@ class RowDataBuilder {
} }
void insertDate(FieldInfo fieldInfo, DateTime date) { void insertDate(FieldInfo fieldInfo, DateTime date) {
assert( assert(FieldType.DateTime == fieldInfo.fieldType);
[
FieldType.DateTime,
FieldType.LastEditedTime,
FieldType.CreatedTime,
].contains(fieldInfo.fieldType),
);
final timestamp = date.millisecondsSinceEpoch ~/ 1000; final timestamp = date.millisecondsSinceEpoch ~/ 1000;
_cellDataByFieldId[fieldInfo.field.id] = timestamp.toString(); _cellDataByFieldId[fieldInfo.field.id] = timestamp.toString();
} }

View File

@ -32,11 +32,7 @@ class RowBannerBloc extends Bloc<RowBannerEvent, RowBannerState> {
await _listenRowMeteChanged(); await _listenRowMeteChanged();
}, },
didReceiveRowMeta: (RowMetaPB rowMeta) { didReceiveRowMeta: (RowMetaPB rowMeta) {
emit( emit(state.copyWith(rowMeta: rowMeta));
state.copyWith(
rowMeta: rowMeta,
),
);
}, },
setCover: (String coverURL) { setCover: (String coverURL) {
_updateMeta(coverURL: coverURL); _updateMeta(coverURL: coverURL);

View File

@ -1,23 +1,20 @@
import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart';
import 'package:appflowy/plugins/database_view/application/field/field_service.dart';
import 'package:appflowy/plugins/database_view/application/field_settings/field_settings_service.dart'; import 'package:appflowy/plugins/database_view/application/field_settings/field_settings_service.dart';
import 'package:appflowy/plugins/database_view/application/row/row_service.dart'; import 'package:appflowy/plugins/database_view/application/row/row_controller.dart';
import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_settings_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_settings_entities.pb.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'dart:async';
import '../../../application/cell/cell_service.dart';
import '../../../application/field/field_service.dart';
import '../../../application/row/row_controller.dart';
part 'row_detail_bloc.freezed.dart'; part 'row_detail_bloc.freezed.dart';
class RowDetailBloc extends Bloc<RowDetailEvent, RowDetailState> { class RowDetailBloc extends Bloc<RowDetailEvent, RowDetailState> {
final RowBackendService rowService;
final RowController rowController; final RowController rowController;
RowDetailBloc({ RowDetailBloc({
required this.rowController, required this.rowController,
}) : rowService = RowBackendService(viewId: rowController.viewId), }) : super(RowDetailState.initial()) {
super(RowDetailState.initial()) {
on<RowDetailEvent>( on<RowDetailEvent>(
(event, emit) async { (event, emit) async {
await event.when( await event.when(
@ -58,14 +55,8 @@ class RowDetailBloc extends Bloc<RowDetailEvent, RowDetailState> {
(err) => Log.error(err), (err) => Log.error(err),
); );
}, },
deleteRow: (rowId) async { reorderField: (fieldId, fromIndex, toIndex) async {
await rowService.deleteRow(rowId); await _reorderField(fieldId, fromIndex, toIndex, emit);
},
duplicateRow: (String rowId, String? groupId) async {
await rowService.duplicateRow(
rowId: rowId,
groupId: groupId,
);
}, },
); );
}, },
@ -94,6 +85,25 @@ class RowDetailBloc extends Bloc<RowDetailEvent, RowDetailState> {
fieldId: fieldId, fieldId: fieldId,
); );
} }
Future<void> _reorderField(
String fieldId,
int fromIndex,
int toIndex,
Emitter<RowDetailState> emit,
) async {
final cells = List<DatabaseCellContext>.from(state.cells);
cells.insert(toIndex, cells.removeAt(fromIndex));
emit(state.copyWith(cells: cells));
final fieldService =
FieldBackendService(viewId: rowController.viewId, fieldId: fieldId);
final result = await fieldService.moveField(
fromIndex,
toIndex,
);
result.fold((l) {}, (err) => Log.error(err));
}
} }
@freezed @freezed
@ -102,9 +112,11 @@ class RowDetailEvent with _$RowDetailEvent {
const factory RowDetailEvent.deleteField(String fieldId) = _DeleteField; const factory RowDetailEvent.deleteField(String fieldId) = _DeleteField;
const factory RowDetailEvent.showField(String fieldId) = _ShowField; const factory RowDetailEvent.showField(String fieldId) = _ShowField;
const factory RowDetailEvent.hideField(String fieldId) = _HideField; const factory RowDetailEvent.hideField(String fieldId) = _HideField;
const factory RowDetailEvent.deleteRow(String rowId) = _DeleteRow; const factory RowDetailEvent.reorderField(
const factory RowDetailEvent.duplicateRow(String rowId, String? groupId) = String fieldId,
_DuplicateRow; int fromIndex,
int toIndex,
) = _ReorderField;
const factory RowDetailEvent.didReceiveCellDatas( const factory RowDetailEvent.didReceiveCellDatas(
List<DatabaseCellContext> gridCells, List<DatabaseCellContext> gridCells,
) = _DidReceiveCellDatas; ) = _DidReceiveCellDatas;

View File

@ -154,29 +154,33 @@ class FieldCellButton extends StatelessWidget {
final FieldPB field; final FieldPB field;
final int? maxLines; final int? maxLines;
final BorderRadius? radius; final BorderRadius? radius;
final EdgeInsets? margin;
const FieldCellButton({ const FieldCellButton({
required this.field, required this.field,
required this.onTap, required this.onTap,
this.maxLines = 1, this.maxLines = 1,
this.radius = BorderRadius.zero, this.radius = BorderRadius.zero,
this.margin,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return FlowyButton( return FlowyButton(
hoverColor: AFThemeExtension.of(context).greyHover, hoverColor: AFThemeExtension.of(context).lightGreyHover,
onTap: onTap, onTap: onTap,
leftIcon: FlowySvg( leftIcon: FlowySvg(
field.fieldType.icon(), field.fieldType.icon(),
color: Theme.of(context).iconTheme.color,
), ),
radius: radius, radius: radius,
text: FlowyText.medium( text: FlowyText.medium(
field.name, field.name,
maxLines: maxLines, maxLines: maxLines,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
color: AFThemeExtension.of(context).textColor,
), ),
margin: GridSize.cellContentInsets, margin: margin ?? GridSize.cellContentInsets,
); );
} }
} }

View File

@ -40,8 +40,7 @@ class RowActions extends StatelessWidget {
.map((action) => _ActionCell(action: action)) .map((action) => _ActionCell(action: action))
.toList(); .toList();
// return ListView.separated(
final list = ListView.separated(
shrinkWrap: true, shrinkWrap: true,
controller: ScrollController(), controller: ScrollController(),
itemCount: cells.length, itemCount: cells.length,
@ -53,7 +52,6 @@ class RowActions extends StatelessWidget {
return cells[index]; return cells[index];
}, },
); );
return list;
}, },
), ),
); );
@ -70,6 +68,7 @@ class _ActionCell extends StatelessWidget {
height: GridSize.popoverItemHeight, height: GridSize.popoverItemHeight,
child: FlowyButton( child: FlowyButton(
hoverColor: AFThemeExtension.of(context).lightGreyHover, hoverColor: AFThemeExtension.of(context).lightGreyHover,
useIntrinsicWidth: true,
text: FlowyText.medium( text: FlowyText.medium(
action.title(), action.title(),
color: action.enable() color: action.enable()

View File

@ -1,9 +1,9 @@
import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
@ -91,50 +91,31 @@ class _PrimaryCellAccessoryState extends State<PrimaryCellAccessory>
class AccessoryHover extends StatefulWidget { class AccessoryHover extends StatefulWidget {
final CellAccessory child; final CellAccessory child;
final EdgeInsets contentPadding; const AccessoryHover({required this.child, super.key});
const AccessoryHover({
required this.child,
this.contentPadding = EdgeInsets.zero,
Key? key,
}) : super(key: key);
@override @override
State<AccessoryHover> createState() => _AccessoryHoverState(); State<AccessoryHover> createState() => _AccessoryHoverState();
} }
class _AccessoryHoverState extends State<AccessoryHover> { class _AccessoryHoverState extends State<AccessoryHover> {
late AccessoryHoverState _hoverState; bool _isHover = false;
VoidCallback? _listenerFn;
@override
void initState() {
_hoverState = AccessoryHoverState();
_listenerFn = () =>
_hoverState.onHover = widget.child.onAccessoryHover?.value ?? false;
widget.child.onAccessoryHover?.addListener(_listenerFn!);
super.initState();
}
@override
void dispose() {
_hoverState.dispose();
if (_listenerFn != null) {
widget.child.onAccessoryHover?.removeListener(_listenerFn!);
_listenerFn = null;
}
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final List<Widget> children = [ final List<Widget> children = [
Padding(padding: widget.contentPadding, child: widget.child), DecoratedBox(
decoration: BoxDecoration(
color: _isHover
? AFThemeExtension.of(context).lightGreyHover
: Colors.transparent,
borderRadius: Corners.s6Border,
),
child: widget.child,
),
]; ];
final accessoryBuilder = widget.child.accessoryBuilder; final accessoryBuilder = widget.child.accessoryBuilder;
if (accessoryBuilder != null) { if (accessoryBuilder != null && _isHover) {
final accessories = accessoryBuilder( final accessories = accessoryBuilder(
(GridCellAccessoryBuildContext( (GridCellAccessoryBuildContext(
anchorContext: context, anchorContext: context,
@ -149,36 +130,20 @@ class _AccessoryHoverState extends State<AccessoryHover> {
); );
} }
return ChangeNotifierProvider.value( return MouseRegion(
value: _hoverState, cursor: SystemMouseCursors.click,
child: MouseRegion( opaque: false,
cursor: SystemMouseCursors.click, onEnter: (p) => setState(() => _isHover = true),
opaque: false, onExit: (p) => setState(() => _isHover = false),
onEnter: (p) => setState(() => _hoverState.onHover = true), child: Stack(
onExit: (p) => setState(() => _hoverState.onHover = false), fit: StackFit.loose,
child: Stack( alignment: AlignmentDirectional.center,
fit: StackFit.loose, children: children,
alignment: AlignmentDirectional.center,
children: children,
),
), ),
); );
} }
} }
class AccessoryHoverState extends ChangeNotifier {
bool _onHover = false;
set onHover(bool value) {
if (_onHover != value) {
_onHover = value;
notifyListeners();
}
}
bool get onHover => _onHover;
}
class CellAccessoryContainer extends StatelessWidget { class CellAccessoryContainer extends StatelessWidget {
final List<GridCellAccessoryBuilder> accessories; final List<GridCellAccessoryBuilder> accessories;
const CellAccessoryContainer({required this.accessories, Key? key}) const CellAccessoryContainer({required this.accessories, Key? key})

View File

@ -35,6 +35,7 @@ class GridCellBuilder {
case FieldType.Checkbox: case FieldType.Checkbox:
return GridCheckboxCell( return GridCheckboxCell(
cellControllerBuilder: cellControllerBuilder, cellControllerBuilder: cellControllerBuilder,
style: style,
key: key, key: key,
); );
case FieldType.DateTime: case FieldType.DateTime:
@ -71,6 +72,7 @@ class GridCellBuilder {
case FieldType.Number: case FieldType.Number:
return GridNumberCell( return GridNumberCell(
cellControllerBuilder: cellControllerBuilder, cellControllerBuilder: cellControllerBuilder,
style: style,
key: key, key: key,
); );
case FieldType.RichText: case FieldType.RichText:

View File

@ -9,12 +9,29 @@ import 'checkbox_cell_bloc.dart';
import '../../../../grid/presentation/layout/sizes.dart'; import '../../../../grid/presentation/layout/sizes.dart';
import '../../cell_builder.dart'; import '../../cell_builder.dart';
class GridCheckboxCellStyle extends GridCellStyle {
EdgeInsets? cellPadding;
GridCheckboxCellStyle({
this.cellPadding,
});
}
class GridCheckboxCell extends GridCellWidget { class GridCheckboxCell extends GridCellWidget {
final CellControllerBuilder cellControllerBuilder; final CellControllerBuilder cellControllerBuilder;
late final GridCheckboxCellStyle cellStyle;
GridCheckboxCell({ GridCheckboxCell({
required this.cellControllerBuilder, required this.cellControllerBuilder,
GridCellStyle? style,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key) {
if (style != null) {
cellStyle = (style as GridCheckboxCellStyle);
} else {
cellStyle = GridCheckboxCellStyle();
}
}
@override @override
GridCellState<GridCheckboxCell> createState() => _CheckboxCellState(); GridCellState<GridCheckboxCell> createState() => _CheckboxCellState();
@ -46,7 +63,8 @@ class _CheckboxCellState extends GridCellState<GridCheckboxCell> {
return Align( return Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Padding( child: Padding(
padding: GridSize.cellContentInsets, padding:
widget.cellStyle.cellPadding ?? GridSize.cellContentInsets,
child: FlowyIconButton( child: FlowyIconButton(
hoverColor: Colors.transparent, hoverColor: Colors.transparent,
onPressed: () => context onPressed: () => context

View File

@ -1,7 +1,8 @@
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../grid/presentation/layout/sizes.dart'; import '../../../../grid/presentation/layout/sizes.dart';
@ -10,9 +11,15 @@ import 'date_cell_bloc.dart';
import 'date_editor.dart'; import 'date_editor.dart';
class DateCellStyle extends GridCellStyle { class DateCellStyle extends GridCellStyle {
String? placeholder;
Alignment alignment; Alignment alignment;
EdgeInsets? cellPadding;
DateCellStyle({this.alignment = Alignment.center}); DateCellStyle({
this.placeholder,
this.alignment = Alignment.center,
this.cellPadding,
});
} }
abstract class GridCellDelegate { abstract class GridCellDelegate {
@ -71,7 +78,10 @@ class _DateCellState extends GridCellState<GridDateCell> {
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
child: GridDateCellText( child: GridDateCellText(
dateStr: state.dateStr, dateStr: state.dateStr,
placeholder: widget.cellStyle?.placeholder ?? "",
alignment: alignment, alignment: alignment,
cellPadding:
widget.cellStyle?.cellPadding ?? GridSize.cellContentInsets,
), ),
popupBuilder: (BuildContext popoverContent) { popupBuilder: (BuildContext popoverContent) {
return DateCellEditor( return DateCellEditor(
@ -107,24 +117,31 @@ class _DateCellState extends GridCellState<GridDateCell> {
class GridDateCellText extends StatelessWidget { class GridDateCellText extends StatelessWidget {
final String dateStr; final String dateStr;
final String placeholder;
final Alignment alignment; final Alignment alignment;
final EdgeInsets cellPadding;
const GridDateCellText({ const GridDateCellText({
required this.dateStr, required this.dateStr,
required this.placeholder,
required this.alignment, required this.alignment,
required this.cellPadding,
super.key, super.key,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SizedBox.expand( final isPlaceholder = dateStr.isEmpty;
child: Align( final text = isPlaceholder ? placeholder : dateStr;
alignment: alignment, return Align(
child: Padding( alignment: alignment,
padding: GridSize.cellContentInsets, child: Padding(
child: FlowyText.medium( padding: cellPadding,
dateStr, child: FlowyText.medium(
maxLines: null, text,
), color: isPlaceholder
? Theme.of(context).hintColor
: AFThemeExtension.of(context).textColor,
maxLines: null,
), ),
), ),
); );

View File

@ -7,13 +7,33 @@ import 'number_cell_bloc.dart';
import '../../../../grid/presentation/layout/sizes.dart'; import '../../../../grid/presentation/layout/sizes.dart';
import '../../cell_builder.dart'; import '../../cell_builder.dart';
class GridNumberCellStyle extends GridCellStyle {
String? placeholder;
TextStyle? textStyle;
EdgeInsets? cellPadding;
GridNumberCellStyle({
this.placeholder,
this.textStyle,
this.cellPadding,
});
}
class GridNumberCell extends GridCellWidget { class GridNumberCell extends GridCellWidget {
final CellControllerBuilder cellControllerBuilder; final CellControllerBuilder cellControllerBuilder;
late final GridNumberCellStyle cellStyle;
GridNumberCell({ GridNumberCell({
required this.cellControllerBuilder, required this.cellControllerBuilder,
Key? key, required GridCellStyle? style,
}) : super(key: key); super.key,
}) {
if (style != null) {
cellStyle = (style as GridNumberCellStyle);
} else {
cellStyle = GridNumberCellStyle();
}
}
@override @override
GridEditableTextCell<GridNumberCell> createState() => _NumberCellState(); GridEditableTextCell<GridNumberCell> createState() => _NumberCellState();
@ -57,9 +77,10 @@ class _NumberCellState extends GridEditableTextCell<GridNumberCell> {
maxLines: null, maxLines: null,
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
decoration: const InputDecoration( decoration: InputDecoration(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
border: InputBorder.none, border: InputBorder.none,
hintText: widget.cellStyle.placeholder,
isDense: true, isDense: true,
), ),
), ),

View File

@ -93,7 +93,7 @@ class SelectOptionTag extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
EdgeInsets padding = EdgeInsets padding =
const EdgeInsets.symmetric(vertical: 2.0, horizontal: 8.0); const EdgeInsets.symmetric(vertical: 1.5, horizontal: 8.0);
if (onRemove != null) { if (onRemove != null) {
padding = padding.copyWith(right: 2.0); padding = padding.copyWith(right: 2.0);
} }
@ -110,6 +110,7 @@ class SelectOptionTag extends StatelessWidget {
Flexible( Flexible(
child: FlowyText.medium( child: FlowyText.medium(
name, name,
fontSize: FontSizes.s11,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
color: AFThemeExtension.of(context).textColor, color: AFThemeExtension.of(context).textColor,
), ),

View File

@ -13,9 +13,11 @@ import 'select_option_editor.dart';
class SelectOptionCellStyle extends GridCellStyle { class SelectOptionCellStyle extends GridCellStyle {
String placeholder; String placeholder;
EdgeInsets? cellPadding;
SelectOptionCellStyle({ SelectOptionCellStyle({
required this.placeholder, required this.placeholder,
this.cellPadding,
}); });
} }
@ -170,10 +172,7 @@ class _SelectOptionWrapState extends State<SelectOptionWrap> {
final Widget child = _buildOptions(context); final Widget child = _buildOptions(context);
final constraints = BoxConstraints.loose( final constraints = BoxConstraints.loose(
Size( Size(SelectOptionCellEditor.editorPanelWidth, 300),
SelectOptionCellEditor.editorPanelWidth,
300,
),
); );
return AppFlowyPopover( return AppFlowyPopover(
controller: widget.popoverController, controller: widget.popoverController,
@ -191,7 +190,7 @@ class _SelectOptionWrapState extends State<SelectOptionWrap> {
}, },
onClose: () => widget.onCellEditing.value = false, onClose: () => widget.onCellEditing.value = false,
child: Padding( child: Padding(
padding: GridSize.cellContentInsets, padding: widget.cellStyle?.cellPadding ?? GridSize.cellContentInsets,
child: child, child: child,
), ),
); );
@ -200,9 +199,12 @@ class _SelectOptionWrapState extends State<SelectOptionWrap> {
Widget _buildOptions(BuildContext context) { Widget _buildOptions(BuildContext context) {
final Widget child; final Widget child;
if (widget.selectOptions.isEmpty && widget.cellStyle != null) { if (widget.selectOptions.isEmpty && widget.cellStyle != null) {
child = FlowyText.medium( child = Padding(
widget.cellStyle!.placeholder, padding: const EdgeInsets.symmetric(vertical: 1),
color: Theme.of(context).hintColor, child: FlowyText.medium(
widget.cellStyle!.placeholder,
color: Theme.of(context).hintColor,
),
); );
} else { } else {
final children = widget.selectOptions.map( final children = widget.selectOptions.map(

View File

@ -14,6 +14,7 @@ class GridTextCellStyle extends GridCellStyle {
double emojiFontSize; double emojiFontSize;
double emojiHPadding; double emojiHPadding;
bool showEmoji; bool showEmoji;
EdgeInsets? cellPadding;
GridTextCellStyle({ GridTextCellStyle({
this.placeholder, this.placeholder,
@ -22,6 +23,7 @@ class GridTextCellStyle extends GridCellStyle {
this.showEmoji = true, this.showEmoji = true,
this.emojiFontSize = 16, this.emojiFontSize = 16,
this.emojiHPadding = 0, this.emojiHPadding = 0,
this.cellPadding,
}); });
} }
@ -72,10 +74,11 @@ class _GridTextCellState extends GridEditableTextCell<GridTextCell> {
} }
}, },
child: Padding( child: Padding(
padding: EdgeInsets.only( padding: widget.cellStyle.cellPadding ??
left: GridSize.cellContentInsets.left, EdgeInsets.only(
right: GridSize.cellContentInsets.right, left: GridSize.cellContentInsets.left,
), right: GridSize.cellContentInsets.right,
),
child: Row( child: Row(
children: [ children: [
if (widget.cellStyle.showEmoji) if (widget.cellStyle.showEmoji)

View File

@ -3,14 +3,21 @@ import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.da
import 'package:appflowy/plugins/database_view/widgets/row/cell_builder.dart'; import 'package:appflowy/plugins/database_view/widgets/row/cell_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/timestamp_cell/timestamp_cell_bloc.dart'; import 'package:appflowy/plugins/database_view/widgets/row/cells/timestamp_cell/timestamp_cell_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
class TimestampCellStyle extends GridCellStyle { class TimestampCellStyle extends GridCellStyle {
String? placeholder;
Alignment alignment; Alignment alignment;
EdgeInsets? cellPadding;
TimestampCellStyle({this.alignment = Alignment.center}); TimestampCellStyle({
this.placeholder,
this.alignment = Alignment.center,
this.cellPadding,
});
} }
class GridTimestampCell extends GridCellWidget { class GridTimestampCell extends GridCellWidget {
@ -51,16 +58,28 @@ class _TimestampCellState extends GridCellState<GridTimestampCell> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final alignment = widget.cellStyle != null final alignment = widget.cellStyle?.alignment ?? Alignment.centerLeft;
? widget.cellStyle!.alignment final placeholder = widget.cellStyle?.placeholder ?? "";
: Alignment.centerLeft; final padding = widget.cellStyle?.cellPadding ?? GridSize.cellContentInsets;
return BlocProvider.value( return BlocProvider.value(
value: _cellBloc, value: _cellBloc,
child: BlocBuilder<TimestampCellBloc, TimestampCellState>( child: BlocBuilder<TimestampCellBloc, TimestampCellState>(
builder: (context, state) { builder: (context, state) {
return GridTimestampCellText( final isEmpty = state.dateStr.isEmpty;
dateStr: state.dateStr, final text = isEmpty ? placeholder : state.dateStr;
return Align(
alignment: alignment, alignment: alignment,
child: Padding(
padding: padding,
child: FlowyText.medium(
text,
color: isEmpty
? Theme.of(context).hintColor
: AFThemeExtension.of(context).textColor,
maxLines: null,
),
),
); );
}, },
), ),
@ -81,29 +100,3 @@ class _TimestampCellState extends GridCellState<GridTimestampCell> {
return; return;
} }
} }
class GridTimestampCellText extends StatelessWidget {
final String dateStr;
final Alignment alignment;
const GridTimestampCellText({
required this.dateStr,
required this.alignment,
super.key,
});
@override
Widget build(BuildContext context) {
return SizedBox.expand(
child: Align(
alignment: alignment,
child: Padding(
padding: GridSize.cellContentInsets,
child: FlowyText.medium(
dateStr,
maxLines: null,
),
),
),
);
}
}

View File

@ -1,18 +1,10 @@
import 'package:appflowy/generated/flowy_svgs.g.dart'; 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_view/application/field/type_option/type_option_context.dart';
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_service.dart';
import 'package:appflowy/plugins/database_view/application/row/row_controller.dart'; import 'package:appflowy/plugins/database_view/application/row/row_controller.dart';
import 'package:appflowy/plugins/database_view/grid/application/row/row_detail_bloc.dart'; import 'package:appflowy/plugins/database_view/grid/application/row/row_action_sheet_bloc.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_editor.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.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/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@ -20,36 +12,39 @@ import 'package:flutter_bloc/flutter_bloc.dart';
class RowActionList extends StatelessWidget { class RowActionList extends StatelessWidget {
final RowController rowController; final RowController rowController;
const RowActionList({ const RowActionList({
required String viewId,
required this.rowController, required this.rowController,
Key? key, super.key,
}) : super(key: key); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return BlocProvider<RowActionSheetBloc>(
crossAxisAlignment: CrossAxisAlignment.start, create: (context) => RowActionSheetBloc(
mainAxisSize: MainAxisSize.min, viewId: rowController.viewId,
children: [ rowId: rowController.rowId,
Padding( groupId: rowController.groupId,
padding: const EdgeInsets.only(left: 10), ),
child: FlowyText(LocaleKeys.grid_row_action.tr()), child: IntrinsicWidth(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
RowDetailPageDuplicateButton(
rowId: rowController.rowId,
groupId: rowController.groupId,
),
const VSpace(4.0),
RowDetailPageDeleteButton(rowId: rowController.rowId),
],
), ),
const VSpace(15), ),
RowDetailPageDeleteButton(rowId: rowController.rowId),
RowDetailPageDuplicateButton(
rowId: rowController.rowId,
groupId: rowController.groupId,
),
],
); );
} }
} }
class RowDetailPageDeleteButton extends StatelessWidget { class RowDetailPageDeleteButton extends StatelessWidget {
final String rowId; final String rowId;
const RowDetailPageDeleteButton({required this.rowId, Key? key}) const RowDetailPageDeleteButton({required this.rowId, super.key});
: super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -59,7 +54,9 @@ class RowDetailPageDeleteButton extends StatelessWidget {
text: FlowyText.regular(LocaleKeys.grid_row_delete.tr()), text: FlowyText.regular(LocaleKeys.grid_row_delete.tr()),
leftIcon: const FlowySvg(FlowySvgs.trash_m), leftIcon: const FlowySvg(FlowySvgs.trash_m),
onTap: () { onTap: () {
context.read<RowDetailBloc>().add(RowDetailEvent.deleteRow(rowId)); context
.read<RowActionSheetBloc>()
.add(const RowActionSheetEvent.deleteRow());
FlowyOverlay.pop(context); FlowyOverlay.pop(context);
}, },
), ),
@ -73,8 +70,8 @@ class RowDetailPageDuplicateButton extends StatelessWidget {
const RowDetailPageDuplicateButton({ const RowDetailPageDuplicateButton({
required this.rowId, required this.rowId,
this.groupId, this.groupId,
Key? key, super.key,
}) : super(key: key); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -85,91 +82,11 @@ class RowDetailPageDuplicateButton extends StatelessWidget {
leftIcon: const FlowySvg(FlowySvgs.copy_s), leftIcon: const FlowySvg(FlowySvgs.copy_s),
onTap: () { onTap: () {
context context
.read<RowDetailBloc>() .read<RowActionSheetBloc>()
.add(RowDetailEvent.duplicateRow(rowId, groupId)); .add(const RowActionSheetEvent.duplicateRow());
FlowyOverlay.pop(context); FlowyOverlay.pop(context);
}, },
), ),
); );
} }
} }
class CreateRowFieldButton extends StatefulWidget {
final String viewId;
const CreateRowFieldButton({
required this.viewId,
Key? key,
}) : super(key: key);
@override
State<CreateRowFieldButton> createState() => _CreateRowFieldButtonState();
}
class _CreateRowFieldButtonState extends State<CreateRowFieldButton> {
late PopoverController popoverController;
late TypeOptionPB typeOption;
@override
void initState() {
popoverController = PopoverController();
super.initState();
}
@override
Widget build(BuildContext context) {
return AppFlowyPopover(
constraints: BoxConstraints.loose(const Size(240, 200)),
controller: popoverController,
direction: PopoverDirection.topWithLeftAligned,
triggerActions: PopoverTriggerFlags.none,
margin: EdgeInsets.zero,
child: SizedBox(
height: 40,
child: FlowyButton(
text: FlowyText.medium(
LocaleKeys.grid_field_newProperty.tr(),
color: AFThemeExtension.of(context).textColor,
),
hoverColor: AFThemeExtension.of(context).lightGreyHover,
onTap: () async {
final result = await TypeOptionBackendService.createFieldTypeOption(
viewId: widget.viewId,
);
result.fold(
(l) {
typeOption = l;
popoverController.show();
},
(r) => Log.error("Failed to create field type option: $r"),
);
},
leftIcon: FlowySvg(
FlowySvgs.add_m,
color: AFThemeExtension.of(context).textColor,
),
),
),
popupBuilder: (BuildContext popOverContext) {
return FieldEditor(
viewId: widget.viewId,
typeOptionLoader: FieldTypeOptionLoader(
viewId: widget.viewId,
field: typeOption.field_2,
),
onDeleted: (fieldId) {
popoverController.close();
NavigatorAlertDialog(
title: LocaleKeys.grid_field_deleteFieldPromptMessage.tr(),
confirm: () {
context
.read<RowDetailBloc>()
.add(RowDetailEvent.deleteField(fieldId));
},
).show(context);
},
);
},
);
}
}

View File

@ -1,23 +1,27 @@
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_view/application/cell/cell_service.dart';
import 'package:appflowy/plugins/database_view/application/field/field_info.dart';
import 'package:appflowy/plugins/database_view/application/row/row_banner_bloc.dart'; import 'package:appflowy/plugins/database_view/application/row/row_banner_bloc.dart';
import 'package:appflowy/plugins/database_view/application/row/row_controller.dart';
import 'package:appflowy/plugins/database_view/widgets/row/row_action.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/emoji_picker/emoji_picker.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/emoji_picker/emoji_picker.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart';
import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
typedef RowBannerCellBuilder = Widget Function(String fieldId); import 'cell_builder.dart';
import 'cells/cells.dart';
class RowBanner extends StatefulWidget { class RowBanner extends StatefulWidget {
final String viewId; final RowController rowController;
final RowMetaPB rowMeta; final GridCellBuilder cellBuilder;
final RowBannerCellBuilder cellBuilder;
const RowBanner({ const RowBanner({
required this.viewId, required this.rowController,
required this.rowMeta,
required this.cellBuilder, required this.cellBuilder,
super.key, super.key,
}); });
@ -34,25 +38,39 @@ class _RowBannerState extends State<RowBanner> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider<RowBannerBloc>( return BlocProvider<RowBannerBloc>(
create: (context) => RowBannerBloc( create: (context) => RowBannerBloc(
viewId: widget.viewId, viewId: widget.rowController.viewId,
rowMeta: widget.rowMeta, rowMeta: widget.rowController.rowMeta,
)..add(const RowBannerEvent.initial()), )..add(const RowBannerEvent.initial()),
child: MouseRegion( child: MouseRegion(
onEnter: (event) => _isHovering.value = true, onEnter: (event) => _isHovering.value = true,
onExit: (event) => _isHovering.value = false, onExit: (event) => _isHovering.value = false,
child: Column( child: Stack(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SizedBox( Padding(
height: 30, padding: const EdgeInsets.fromLTRB(60, 34, 60, 0),
child: _BannerAction( child: Column(
isHovering: _isHovering, crossAxisAlignment: CrossAxisAlignment.start,
popoverController: popoverController, children: [
SizedBox(
height: 30,
child: _BannerAction(
isHovering: _isHovering,
popoverController: popoverController,
),
),
const HSpace(4),
_BannerTitle(
cellBuilder: widget.cellBuilder,
popoverController: popoverController,
rowController: widget.rowController,
),
],
), ),
), ),
_BannerTitle( Positioned(
cellBuilder: widget.cellBuilder, top: 12,
popoverController: popoverController, right: 12,
child: RowActionButton(rowController: widget.rowController),
), ),
], ],
), ),
@ -74,50 +92,54 @@ class _BannerAction extends StatelessWidget {
return ValueListenableBuilder( return ValueListenableBuilder(
valueListenable: isHovering, valueListenable: isHovering,
builder: (BuildContext context, bool value, Widget? child) { builder: (BuildContext context, bool value, Widget? child) {
if (value) { if (!value) {
return BlocBuilder<RowBannerBloc, RowBannerState>(
builder: (context, state) {
final children = <Widget>[];
final rowMeta = state.rowMeta;
if (rowMeta.icon.isEmpty) {
children.add(
EmojiPickerButton(
showEmojiPicker: () => popoverController.show(),
),
);
} else {
children.add(
RemoveEmojiButton(
onRemoved: () {
context
.read<RowBannerBloc>()
.add(const RowBannerEvent.setIcon(''));
},
),
);
}
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
);
},
);
} else {
return const SizedBox(height: _kBannerActionHeight); return const SizedBox(height: _kBannerActionHeight);
} }
return BlocBuilder<RowBannerBloc, RowBannerState>(
builder: (context, state) {
final children = <Widget>[];
final rowMeta = state.rowMeta;
if (rowMeta.icon.isEmpty) {
children.add(
EmojiPickerButton(
showEmojiPicker: () => popoverController.show(),
),
);
} else {
children.add(
RemoveEmojiButton(
onRemoved: () {
context
.read<RowBannerBloc>()
.add(const RowBannerEvent.setIcon(''));
},
),
);
}
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
);
},
);
}, },
); );
} }
} }
class _BannerTitle extends StatefulWidget { class _BannerTitle extends StatefulWidget {
final RowBannerCellBuilder cellBuilder; final GridCellBuilder cellBuilder;
final PopoverController popoverController; final PopoverController popoverController;
final RowController rowController;
const _BannerTitle({ const _BannerTitle({
required this.cellBuilder, required this.cellBuilder,
required this.popoverController, required this.popoverController,
}); required this.rowController,
Key? key,
}) : super(key: key);
@override @override
State<_BannerTitle> createState() => _BannerTitleState(); State<_BannerTitle> createState() => _BannerTitleState();
@ -139,10 +161,24 @@ class _BannerTitleState extends State<_BannerTitle> {
); );
} }
children.add(const HSpace(4));
if (state.primaryField != null) { if (state.primaryField != null) {
final style = GridTextCellStyle(
placeholder: LocaleKeys.grid_row_titlePlaceholder.tr(),
textStyle: Theme.of(context).textTheme.titleLarge,
showEmoji: false,
autofocus: true,
cellPadding: EdgeInsets.zero,
);
final cellContext = DatabaseCellContext(
viewId: widget.rowController.viewId,
rowMeta: widget.rowController.rowMeta,
fieldInfo: FieldInfo.initial(state.primaryField!),
);
children.add( children.add(
Expanded( Expanded(
child: widget.cellBuilder(state.primaryField!.id), child: widget.cellBuilder.build(cellContext, style: style),
), ),
); );
} }
@ -211,16 +247,14 @@ class _EmojiPickerButtonState extends State<EmojiPickerButton> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SizedBox( return SizedBox(
height: 26, height: 26,
width: 160,
child: FlowyButton( child: FlowyButton(
useIntrinsicWidth: true,
text: FlowyText.medium( text: FlowyText.medium(
LocaleKeys.document_plugins_cover_addIcon.tr(), LocaleKeys.document_plugins_cover_addIcon.tr(),
), ),
leftIcon: const Icon( leftIcon: const FlowySvg(FlowySvgs.emoji_s),
Icons.emoji_emotions,
size: 16,
),
onTap: widget.showEmojiPicker, onTap: widget.showEmojiPicker,
margin: const EdgeInsets.all(4),
), ),
); );
} }
@ -239,16 +273,14 @@ class RemoveEmojiButton extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SizedBox( return SizedBox(
height: 26, height: 26,
width: 160,
child: FlowyButton( child: FlowyButton(
useIntrinsicWidth: true,
text: FlowyText.medium( text: FlowyText.medium(
LocaleKeys.document_plugins_cover_removeIcon.tr(), LocaleKeys.document_plugins_cover_removeIcon.tr(),
), ),
leftIcon: const Icon( leftIcon: const FlowySvg(FlowySvgs.emoji_s),
Icons.emoji_emotions,
size: 16,
),
onTap: onRemoved, onTap: onRemoved,
margin: const EdgeInsets.all(4),
), ),
); );
} }
@ -263,3 +295,22 @@ Widget _buildEmojiPicker(OnSubmittedEmoji onSubmitted) {
), ),
); );
} }
class RowActionButton extends StatelessWidget {
final RowController rowController;
const RowActionButton({super.key, required this.rowController});
@override
Widget build(BuildContext context) {
return AppFlowyPopover(
direction: PopoverDirection.bottomWithLeftAligned,
popupBuilder: (context) => RowActionList(rowController: rowController),
child: FlowyIconButton(
width: 20,
height: 20,
icon: const FlowySvg(FlowySvgs.details_horizontal_s),
iconColorOnHover: Theme.of(context).colorScheme.onSecondary,
),
);
}
}

View File

@ -1,18 +1,12 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart';
import 'package:appflowy/plugins/database_view/application/row/row_controller.dart'; import 'package:appflowy/plugins/database_view/application/row/row_controller.dart';
import 'package:appflowy/plugins/database_view/grid/application/row/row_detail_bloc.dart'; import 'package:appflowy/plugins/database_view/grid/application/row/row_detail_bloc.dart';
import 'package:appflowy/plugins/database_view/widgets/row/row_document.dart'; import 'package:appflowy/plugins/database_view/widgets/row/row_document.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'cell_builder.dart'; import 'cell_builder.dart';
import 'cells/text_cell/text_cell.dart';
import 'row_action.dart';
import 'row_banner.dart'; import 'row_banner.dart';
import 'row_property.dart'; import 'row_property.dart';
@ -47,17 +41,29 @@ class _RowDetailPageState extends State<RowDetailPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return FlowyDialog( return FlowyDialog(
child: BlocProvider( child: BlocProvider(
create: (context) { create: (context) => RowDetailBloc(rowController: widget.rowController)
return RowDetailBloc(rowController: widget.rowController) ..add(const RowDetailEvent.initial()),
..add(const RowDetailEvent.initial());
},
child: ListView( child: ListView(
controller: scrollController, controller: scrollController,
children: [ children: [
_rowBanner(), RowBanner(
IntrinsicHeight(child: _responsiveRowInfo()), rowController: widget.rowController,
const Divider(height: 1.0), cellBuilder: widget.cellBuilder,
const VSpace(10), ),
const VSpace(16),
Padding(
padding: const EdgeInsets.only(left: 40, right: 60),
child: RowPropertyList(
cellBuilder: widget.cellBuilder,
viewId: widget.rowController.viewId,
),
),
const VSpace(20),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 60),
child: Divider(height: 1.0),
),
const VSpace(20),
RowDocument( RowDocument(
viewId: widget.rowController.viewId, viewId: widget.rowController.viewId,
rowId: widget.rowController.rowId, rowId: widget.rowController.rowId,
@ -68,104 +74,4 @@ class _RowDetailPageState extends State<RowDetailPage> {
), ),
); );
} }
Widget _rowBanner() {
return BlocBuilder<RowDetailBloc, RowDetailState>(
builder: (context, state) {
final paddingOffset = getHorizontalPadding(context);
return Padding(
padding: EdgeInsets.only(
left: paddingOffset,
right: paddingOffset,
top: 20,
),
child: RowBanner(
rowMeta: widget.rowController.rowMeta,
viewId: widget.rowController.viewId,
cellBuilder: (fieldId) {
final fieldInfo = state.cells
.firstWhereOrNull(
(e) => e.fieldInfo.field.id == fieldId,
)
?.fieldInfo;
if (fieldInfo != null) {
final style = GridTextCellStyle(
placeholder: LocaleKeys.grid_row_titlePlaceholder.tr(),
textStyle: Theme.of(context).textTheme.titleLarge,
showEmoji: false,
autofocus: true,
);
final cellContext = DatabaseCellContext(
viewId: widget.rowController.viewId,
rowMeta: widget.rowController.rowMeta,
fieldInfo: fieldInfo,
);
return widget.cellBuilder.build(cellContext, style: style);
} else {
return const SizedBox.shrink();
}
},
),
);
},
);
}
Widget _responsiveRowInfo() {
final rowDataColumn = RowPropertyList(
cellBuilder: widget.cellBuilder,
viewId: widget.rowController.viewId,
);
final rowOptionColumn = RowActionList(
viewId: widget.rowController.viewId,
rowController: widget.rowController,
);
final paddingOffset = getHorizontalPadding(context);
if (MediaQuery.of(context).size.width > 800) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
flex: 3,
child: Padding(
padding: EdgeInsets.fromLTRB(paddingOffset, 0, 20, 20),
child: rowDataColumn,
),
),
const VerticalDivider(width: 1.0),
Flexible(
child: Padding(
padding: EdgeInsets.fromLTRB(20, 0, paddingOffset, 0),
child: rowOptionColumn,
),
),
],
);
} else {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.fromLTRB(paddingOffset, 0, 20, 20),
child: rowDataColumn,
),
const Divider(height: 1.0),
Padding(
padding: EdgeInsets.symmetric(horizontal: paddingOffset),
child: rowOptionColumn,
)
],
);
}
}
}
double getHorizontalPadding(BuildContext context) {
if (MediaQuery.of(context).size.width > 800) {
return 50;
} else {
return 20;
}
} }

View File

@ -1,21 +1,27 @@
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_view/application/cell/cell_service.dart'; import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart';
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart'; import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart';
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_service.dart';
import 'package:appflowy/plugins/database_view/grid/application/row/row_detail_bloc.dart'; import 'package:appflowy/plugins/database_view/grid/application/row/row_detail_bloc.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_cell.dart'; import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_cell.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_editor.dart'; import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_editor.dart';
import 'package:appflowy/plugins/database_view/widgets/row/row_action.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:appflowy_popover/appflowy_popover.dart'; import 'package: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/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'accessory/cell_accessory.dart'; import 'accessory/cell_accessory.dart';
import 'cell_builder.dart'; import 'cell_builder.dart';
import 'cells/checkbox_cell/checkbox_cell.dart';
import 'cells/date_cell/date_cell.dart'; import 'cells/date_cell/date_cell.dart';
import 'cells/number_cell/number_cell.dart';
import 'cells/select_option_cell/select_option_cell.dart'; import 'cells/select_option_cell/select_option_cell.dart';
import 'cells/text_cell/text_cell.dart'; import 'cells/text_cell/text_cell.dart';
import 'cells/timestamp_cell/timestamp_cell.dart'; import 'cells/timestamp_cell/timestamp_cell.dart';
@ -23,7 +29,6 @@ import 'cells/url_cell/url_cell.dart';
/// Display the row properties in a list. Only use this widget in the /// Display the row properties in a list. Only use this widget in the
/// [RowDetailPage]. /// [RowDetailPage].
///
class RowPropertyList extends StatelessWidget { class RowPropertyList extends StatelessWidget {
final String viewId; final String viewId;
final GridCellBuilder cellBuilder; final GridCellBuilder cellBuilder;
@ -38,39 +43,88 @@ class RowPropertyList extends StatelessWidget {
return BlocBuilder<RowDetailBloc, RowDetailState>( return BlocBuilder<RowDetailBloc, RowDetailState>(
buildWhen: (previous, current) => previous.cells != current.cells, buildWhen: (previous, current) => previous.cells != current.cells,
builder: (context, state) { builder: (context, state) {
return Column( final children = state.cells
mainAxisSize: MainAxisSize.min, .where((element) => !element.fieldInfo.field.isPrimary)
crossAxisAlignment: CrossAxisAlignment.start, .mapIndexed(
children: [ (index, cell) => _PropertyCell(
// The rest of the fields are displayed in the order of the field key: ValueKey('row_detail_${cell.fieldId}'),
// list cellContext: cell,
...state.cells cellBuilder: cellBuilder,
.where((element) => !element.fieldInfo.field.isPrimary) index: index,
.map( ),
(cell) => _PropertyCell( )
cellContext: cell, .toList();
cellBuilder: cellBuilder, return ReorderableListView(
), shrinkWrap: true,
) physics: const NeverScrollableScrollPhysics(),
.toList(), onReorder: (oldIndex, newIndex) {
const VSpace(20), final reorderedField = children[oldIndex].cellContext.fieldId;
_reorderField(
// Create a new property(field) button context,
CreateRowFieldButton(viewId: viewId), state.cells,
], reorderedField,
oldIndex,
newIndex,
);
},
buildDefaultDragHandles: false,
proxyDecorator: (child, index, animation) => Material(
color: Colors.transparent,
child: Stack(
children: [
child,
const MouseRegion(cursor: SystemMouseCursors.grabbing),
],
),
),
footer: Padding(
padding: const EdgeInsets.only(left: 20),
child: CreateRowFieldButton(viewId: viewId),
),
children: children,
); );
}, },
); );
} }
void _reorderField(
BuildContext context,
List<DatabaseCellContext> cells,
String reorderedFieldId,
int oldIndex,
int newIndex,
) {
// when reorderiing downwards, need to update index
if (oldIndex < newIndex) {
newIndex--;
}
// also update index when the index is after the index of the primary field
// in the original list of DatabaseCellContext's
final primaryFieldIndex =
cells.indexWhere((element) => element.fieldInfo.isPrimary);
if (oldIndex >= primaryFieldIndex) {
oldIndex++;
}
if (newIndex >= primaryFieldIndex) {
newIndex++;
}
context.read<RowDetailBloc>().add(
RowDetailEvent.reorderField(reorderedFieldId, oldIndex, newIndex),
);
}
} }
class _PropertyCell extends StatefulWidget { class _PropertyCell extends StatefulWidget {
final DatabaseCellContext cellContext; final DatabaseCellContext cellContext;
final GridCellBuilder cellBuilder; final GridCellBuilder cellBuilder;
final int index;
const _PropertyCell({ const _PropertyCell({
required this.cellContext, required this.cellContext,
required this.cellBuilder, required this.cellBuilder,
Key? key, Key? key,
required this.index,
}) : super(key: key); }) : super(key: key);
@override @override
@ -78,45 +132,65 @@ class _PropertyCell extends StatefulWidget {
} }
class _PropertyCellState extends State<_PropertyCell> { class _PropertyCellState extends State<_PropertyCell> {
final PopoverController popover = PopoverController(); final PopoverController _popoverController = PopoverController();
bool _isFieldHover = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final style = _customCellStyle(widget.cellContext.fieldType); final style = _customCellStyle(widget.cellContext.fieldType);
final cell = widget.cellBuilder.build(widget.cellContext, style: style); final cell = widget.cellBuilder.build(widget.cellContext, style: style);
final gesture = GestureDetector( final dragThumb = MouseRegion(
behavior: HitTestBehavior.opaque, cursor: SystemMouseCursors.grab,
onTap: () => cell.requestFocus.notify(), child: SizedBox(
child: AccessoryHover( width: 16,
contentPadding: const EdgeInsets.symmetric(horizontal: 3, vertical: 3), height: 30,
child: cell, child: _isFieldHover ? const FlowySvg(FlowySvgs.drag_element_s) : null,
), ),
); );
return IntrinsicHeight( final gesture = GestureDetector(
child: ConstrainedBox( behavior: HitTestBehavior.opaque,
constraints: const BoxConstraints(minHeight: 30), onTap: () => cell.requestFocus.notify(),
child: AccessoryHover(child: cell),
);
return Container(
margin: const EdgeInsets.only(bottom: 8),
constraints: const BoxConstraints(minHeight: 30),
child: MouseRegion(
onEnter: (event) => setState(() => _isFieldHover = true),
onExit: (event) => setState(() => _isFieldHover = false),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
ReorderableDragStartListener(
index: widget.index,
enabled: _isFieldHover,
child: dragThumb,
),
const HSpace(4),
AppFlowyPopover( AppFlowyPopover(
controller: popover, controller: _popoverController,
constraints: BoxConstraints.loose(const Size(240, 600)), constraints: BoxConstraints.loose(const Size(240, 600)),
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
triggerActions: PopoverTriggerFlags.none, triggerActions: PopoverTriggerFlags.none,
direction: PopoverDirection.bottomWithLeftAligned,
popupBuilder: (popoverContext) => buildFieldEditor(), popupBuilder: (popoverContext) => buildFieldEditor(),
child: SizedBox( child: SizedBox(
width: 150, width: 160,
height: 40, height: 30,
child: FieldCellButton( child: FieldCellButton(
field: widget.cellContext.fieldInfo.field, field: widget.cellContext.fieldInfo.field,
onTap: () => popover.show(), onTap: () => _popoverController.show(),
radius: BorderRadius.circular(6), radius: BorderRadius.circular(6),
margin:
const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
), ),
), ),
), ),
const HSpace(8),
Expanded(child: gesture), Expanded(child: gesture),
], ],
), ),
@ -133,11 +207,11 @@ class _PropertyCellState extends State<_PropertyCell> {
field: widget.cellContext.fieldInfo.field, field: widget.cellContext.fieldInfo.field,
), ),
onHidden: (fieldId) { onHidden: (fieldId) {
popover.close(); _popoverController.close();
context.read<RowDetailBloc>().add(RowDetailEvent.hideField(fieldId)); context.read<RowDetailBloc>().add(RowDetailEvent.hideField(fieldId));
}, },
onDeleted: (fieldId) { onDeleted: (fieldId) {
popover.close(); _popoverController.close();
NavigatorAlertDialog( NavigatorAlertDialog(
title: LocaleKeys.grid_field_deleteFieldPromptMessage.tr(), title: LocaleKeys.grid_field_deleteFieldPromptMessage.tr(),
@ -155,26 +229,35 @@ class _PropertyCellState extends State<_PropertyCell> {
GridCellStyle? _customCellStyle(FieldType fieldType) { GridCellStyle? _customCellStyle(FieldType fieldType) {
switch (fieldType) { switch (fieldType) {
case FieldType.Checkbox: case FieldType.Checkbox:
return null; return GridCheckboxCellStyle(
cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
);
case FieldType.DateTime: case FieldType.DateTime:
return DateCellStyle( return DateCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
); );
case FieldType.LastEditedTime: case FieldType.LastEditedTime:
case FieldType.CreatedTime: case FieldType.CreatedTime:
return TimestampCellStyle( return TimestampCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
); );
case FieldType.MultiSelect: case FieldType.MultiSelect:
return SelectOptionCellStyle( return SelectOptionCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(), placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
); );
case FieldType.Checklist: case FieldType.Checklist:
return SelectOptionCellStyle( return SelectOptionCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(), placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
); );
case FieldType.Number: case FieldType.Number:
return null; return GridNumberCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
);
case FieldType.RichText: case FieldType.RichText:
return GridTextCellStyle( return GridTextCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(), placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
@ -182,6 +265,7 @@ GridCellStyle? _customCellStyle(FieldType fieldType) {
case FieldType.SingleSelect: case FieldType.SingleSelect:
return SelectOptionCellStyle( return SelectOptionCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(), placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
); );
case FieldType.URL: case FieldType.URL:
@ -195,3 +279,81 @@ GridCellStyle? _customCellStyle(FieldType fieldType) {
} }
throw UnimplementedError; throw UnimplementedError;
} }
class CreateRowFieldButton extends StatefulWidget {
final String viewId;
const CreateRowFieldButton({required this.viewId, super.key});
@override
State<CreateRowFieldButton> createState() => _CreateRowFieldButtonState();
}
class _CreateRowFieldButtonState extends State<CreateRowFieldButton> {
late PopoverController popoverController;
late TypeOptionPB typeOption;
@override
void initState() {
popoverController = PopoverController();
super.initState();
}
@override
Widget build(BuildContext context) {
return AppFlowyPopover(
constraints: BoxConstraints.loose(const Size(240, 200)),
controller: popoverController,
direction: PopoverDirection.topWithLeftAligned,
triggerActions: PopoverTriggerFlags.none,
margin: EdgeInsets.zero,
child: SizedBox(
height: 30,
child: FlowyButton(
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
text: FlowyText.medium(
LocaleKeys.grid_field_newProperty.tr(),
color: Theme.of(context).hintColor,
),
hoverColor: AFThemeExtension.of(context).lightGreyHover,
onTap: () async {
final result = await TypeOptionBackendService.createFieldTypeOption(
viewId: widget.viewId,
);
result.fold(
(l) {
typeOption = l;
popoverController.show();
},
(r) => Log.error("Failed to create field type option: $r"),
);
},
leftIcon: FlowySvg(
FlowySvgs.add_m,
color: Theme.of(context).hintColor,
),
),
),
popupBuilder: (BuildContext popOverContext) {
return FieldEditor(
viewId: widget.viewId,
typeOptionLoader: FieldTypeOptionLoader(
viewId: widget.viewId,
field: typeOption.field_2,
),
onDeleted: (fieldId) {
popoverController.close();
NavigatorAlertDialog(
title: LocaleKeys.grid_field_deleteFieldPromptMessage.tr(),
confirm: () {
context
.read<RowDetailBloc>()
.add(RowDetailEvent.deleteField(fieldId));
},
).show(context);
},
);
},
);
}
}

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="4" cy="8" r="1" fill="#333333"/>
<circle cx="8" cy="8" r="1" fill="#333333"/>
<circle cx="12" cy="8" r="1" fill="#333333"/>
</svg>

After

Width:  |  Height:  |  Size: 239 B

View File

@ -0,0 +1,13 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_160_12007)">
<path d="M8 12.998C10.7614 12.998 13 10.7595 13 7.99805C13 5.23662 10.7614 2.99805 8 2.99805C5.23857 2.99805 3 5.23662 3 7.99805C3 10.7595 5.23857 12.998 8 12.998Z" stroke="#333333" stroke-linejoin="round"/>
<path d="M9.75 9.74805C9.75 9.74805 9.25 10.748 8 10.748C6.75 10.748 6.25 9.74805 6.25 9.74805" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.25 6.99805H9.25" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.25 6.49805V7.49805" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_160_12007">
<rect width="12" height="12" fill="white" transform="translate(2 1.99805)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 846 B