refactor: rename database code path from database_view to database (#4310)

This commit is contained in:
Richard Shiue
2024-01-05 17:30:54 +08:00
committed by GitHub
parent 6dba118413
commit 1eeb812a1c
305 changed files with 687 additions and 687 deletions

View File

@ -0,0 +1,344 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/mobile/presentation/database/card/card.dart';
import 'package:appflowy/plugins/database/application/cell/cell_service.dart';
import 'package:appflowy/plugins/database/application/row/row_cache.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/row/action.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'card_bloc.dart';
import 'card_cell_builder.dart';
import 'cells/card_cell.dart';
import 'container/accessory.dart';
import 'container/card_container.dart';
/// Edit a database row with card style widget
class RowCard<CustomCardData> extends StatefulWidget {
final RowMetaPB rowMeta;
final String viewId;
final String? groupingFieldId;
final String? groupId;
/// Allows passing a custom card data object to the card. The card will be
/// returned in the [CardCellBuilder] and can be used to build the card.
final CustomCardData? cardData;
final bool isEditing;
final RowCache rowCache;
/// The [CardCellBuilder] is used to build the card cells.
final CardCellBuilder<CustomCardData> cellBuilder;
/// Called when the user taps on the card.
final void Function(BuildContext) openCard;
/// Called when the user starts editing the card.
final VoidCallback onStartEditing;
/// Called when the user ends editing the card.
final VoidCallback onEndEditing;
/// The [RowCardRenderHook] is used to render the card's cell. Other than
/// using the default cell builder. For example the [SelectOptionCardCell]
final RowCardRenderHook<CustomCardData>? renderHook;
final RowCardStyleConfiguration styleConfiguration;
const RowCard({
super.key,
required this.rowMeta,
required this.viewId,
required this.isEditing,
required this.rowCache,
required this.cellBuilder,
required this.openCard,
required this.onStartEditing,
required this.onEndEditing,
this.groupingFieldId,
this.groupId,
this.cardData,
this.styleConfiguration = const RowCardStyleConfiguration(
showAccessory: true,
),
this.renderHook,
});
@override
State<RowCard<CustomCardData>> createState() =>
_RowCardState<CustomCardData>();
}
class _RowCardState<T> extends State<RowCard<T>> {
final popoverController = PopoverController();
late final CardBloc _cardBloc;
late final EditableRowNotifier rowNotifier;
AccessoryType? accessoryType;
@override
void initState() {
super.initState();
rowNotifier = EditableRowNotifier(isEditing: widget.isEditing);
_cardBloc = CardBloc(
viewId: widget.viewId,
groupFieldId: widget.groupingFieldId,
isEditing: widget.isEditing,
rowMeta: widget.rowMeta,
rowCache: widget.rowCache,
)..add(const RowCardEvent.initial());
rowNotifier.isEditing.addListener(() {
if (!mounted) return;
_cardBloc.add(RowCardEvent.setIsEditing(rowNotifier.isEditing.value));
if (rowNotifier.isEditing.value) {
widget.onStartEditing();
} else {
widget.onEndEditing();
}
});
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cardBloc,
child: BlocBuilder<CardBloc, RowCardState>(
buildWhen: (previous, current) {
// Rebuild when:
// 1. If the length of the cells is not the same or isEditing changed
if (previous.cells.length != current.cells.length ||
previous.isEditing != current.isEditing) {
return true;
}
// 2. the content of the cells changed
return !listEquals(previous.cells, current.cells);
},
builder: (context, state) {
if (PlatformExtension.isMobile) {
return GestureDetector(
child: MobileCardContent<T>(
cellBuilder: widget.cellBuilder,
styleConfiguration: widget.styleConfiguration,
cells: state.cells,
renderHook: widget.renderHook,
cardData: widget.cardData,
),
onTap: () => widget.openCard(context),
);
}
return AppFlowyPopover(
controller: popoverController,
triggerActions: PopoverTriggerFlags.none,
constraints: BoxConstraints.loose(const Size(140, 200)),
direction: PopoverDirection.rightWithCenterAligned,
popupBuilder: (_) {
return RowActionMenu.board(
viewId: _cardBloc.viewId,
rowId: _cardBloc.rowMeta.id,
groupId: widget.groupId,
);
},
child: RowCardContainer(
buildAccessoryWhen: () => state.isEditing == false,
accessories: [
if (widget.styleConfiguration.showAccessory) ...[
_CardEditOption(rowNotifier: rowNotifier),
const CardMoreOption(),
],
],
openAccessory: _handleOpenAccessory,
openCard: (context) => widget.openCard(context),
child: _CardContent<T>(
rowNotifier: rowNotifier,
cellBuilder: widget.cellBuilder,
styleConfiguration: widget.styleConfiguration,
cells: state.cells,
renderHook: widget.renderHook,
cardData: widget.cardData,
),
),
);
},
),
);
}
void _handleOpenAccessory(AccessoryType newAccessoryType) {
accessoryType = newAccessoryType;
switch (newAccessoryType) {
case AccessoryType.edit:
break;
case AccessoryType.more:
popoverController.show();
break;
}
}
@override
Future<void> dispose() async {
rowNotifier.dispose();
_cardBloc.close();
super.dispose();
}
}
class _CardContent<CustomCardData> extends StatefulWidget {
const _CardContent({
super.key,
required this.rowNotifier,
required this.cellBuilder,
required this.cells,
required this.cardData,
required this.styleConfiguration,
this.renderHook,
});
final EditableRowNotifier rowNotifier;
final CardCellBuilder<CustomCardData> cellBuilder;
final List<DatabaseCellContext> cells;
final CustomCardData? cardData;
final RowCardStyleConfiguration styleConfiguration;
final RowCardRenderHook<CustomCardData>? renderHook;
@override
State<_CardContent<CustomCardData>> createState() =>
_CardContentState<CustomCardData>();
}
class _CardContentState<CustomCardData>
extends State<_CardContent<CustomCardData>> {
final List<EditableCardNotifier> _notifiers = [];
@override
void dispose() {
for (final element in _notifiers) {
element.dispose();
}
_notifiers.clear();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.styleConfiguration.hoverStyle != null) {
return FlowyHover(
style: widget.styleConfiguration.hoverStyle,
buildWhenOnHover: () => !widget.rowNotifier.isEditing.value,
child: Padding(
padding: widget.styleConfiguration.cardPadding,
child: Column(
mainAxisSize: MainAxisSize.min,
children: _makeCells(context, widget.cells),
),
),
);
}
return Padding(
padding: widget.styleConfiguration.cardPadding,
child: Column(
mainAxisSize: MainAxisSize.min,
children: _makeCells(context, widget.cells),
),
);
}
List<Widget> _makeCells(
BuildContext context,
List<DatabaseCellContext> cells,
) {
final List<Widget> children = [];
// Remove all the cell listeners.
widget.rowNotifier.unbind();
cells.asMap().forEach((int index, DatabaseCellContext cellContext) {
final isEditing = index == 0 ? widget.rowNotifier.isEditing.value : false;
final cellNotifier = EditableCardNotifier(isEditing: isEditing);
if (index == 0) {
// Only use the first cell to receive user's input when click the edit
// button
widget.rowNotifier.bindCell(cellContext, cellNotifier);
} else {
_notifiers.add(cellNotifier);
}
final child = Padding(
key: cellContext.key(),
padding: widget.styleConfiguration.cellPadding,
child: widget.cellBuilder.buildCell(
cellContext: cellContext,
cellNotifier: cellNotifier,
renderHook: widget.renderHook,
cardData: widget.cardData,
hasNotes: !cellContext.rowMeta.isDocumentEmpty,
),
);
children.add(child);
});
return children;
}
}
class CardMoreOption extends StatelessWidget with CardAccessory {
const CardMoreOption({super.key});
@override
AccessoryType get type => AccessoryType.more;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(3.0),
child: FlowySvg(
FlowySvgs.three_dots_s,
color: Theme.of(context).hintColor,
),
);
}
}
class _CardEditOption extends StatelessWidget with CardAccessory {
final EditableRowNotifier rowNotifier;
const _CardEditOption({
required this.rowNotifier,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(3.0),
child: FlowySvg(
FlowySvgs.edit_s,
color: Theme.of(context).hintColor,
),
);
}
@override
void onTap(BuildContext context) => rowNotifier.becomeFirstResponder();
@override
AccessoryType get type => AccessoryType.edit;
}
class RowCardStyleConfiguration {
final bool showAccessory;
final EdgeInsets cellPadding;
final EdgeInsets cardPadding;
final HoverStyle? hoverStyle;
const RowCardStyleConfiguration({
this.showAccessory = true,
this.cellPadding = EdgeInsets.zero,
this.cardPadding = const EdgeInsets.all(8),
this.hoverStyle,
});
}

View File

@ -0,0 +1,162 @@
import 'dart:collection';
import 'package:appflowy/plugins/database/application/row/row_listener.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'dart:async';
import '../../application/cell/cell_service.dart';
import '../../application/row/row_cache.dart';
import '../../application/row/row_service.dart';
part 'card_bloc.freezed.dart';
class CardBloc extends Bloc<RowCardEvent, RowCardState> {
final RowMetaPB rowMeta;
final String? groupFieldId;
final RowBackendService _rowBackendSvc;
final RowCache _rowCache;
final String viewId;
final RowListener _rowListener;
VoidCallback? _rowCallback;
CardBloc({
required this.rowMeta,
required this.groupFieldId,
required this.viewId,
required RowCache rowCache,
required bool isEditing,
}) : _rowBackendSvc = RowBackendService(viewId: viewId),
_rowListener = RowListener(rowMeta.id),
_rowCache = rowCache,
super(
RowCardState.initial(
_makeCells(groupFieldId, rowCache.loadCells(rowMeta)),
isEditing,
),
) {
on<RowCardEvent>(
(event, emit) async {
await event.when(
initial: () async {
await _startListening();
},
didReceiveCells: (cells, reason) async {
emit(
state.copyWith(
cells: cells,
changeReason: reason,
),
);
},
setIsEditing: (bool isEditing) {
emit(state.copyWith(isEditing: isEditing));
},
didReceiveRowMeta: (rowMeta) {
final cells = state.cells
.map(
(cell) => cell.rowMeta.id == rowMeta.id
? cell.copyWith(rowMeta: rowMeta)
: cell,
)
.toList();
emit(state.copyWith(cells: cells));
},
);
},
);
}
@override
Future<void> close() async {
if (_rowCallback != null) {
_rowCache.removeRowListener(_rowCallback!);
_rowCallback = null;
}
return super.close();
}
RowInfo rowInfo() {
return RowInfo(
viewId: _rowBackendSvc.viewId,
fields: UnmodifiableListView(
state.cells.map((cell) => cell.fieldInfo).toList(),
),
rowId: rowMeta.id,
rowMeta: rowMeta,
);
}
Future<void> _startListening() async {
_rowCallback = _rowCache.addListener(
rowId: rowMeta.id,
onRowChanged: (cellMap, reason) {
if (!isClosed) {
final cells = _makeCells(groupFieldId, cellMap);
add(RowCardEvent.didReceiveCells(cells, reason));
}
},
);
_rowListener.start(
onMetaChanged: (meta) {
if (!isClosed) {
add(RowCardEvent.didReceiveRowMeta(meta));
}
},
);
}
}
List<DatabaseCellContext> _makeCells(
String? groupFieldId,
CellContextByFieldId originalCellMap,
) {
final List<DatabaseCellContext> cells = [];
originalCellMap
.removeWhere((fieldId, cellContext) => !cellContext.isVisible());
for (final entry in originalCellMap.entries) {
// Filter out the cell if it's fieldId equal to the groupFieldId
if (groupFieldId != null) {
if (entry.value.fieldId == groupFieldId) {
continue;
}
}
cells.add(entry.value);
}
return cells;
}
@freezed
class RowCardEvent with _$RowCardEvent {
const factory RowCardEvent.initial() = _InitialRow;
const factory RowCardEvent.setIsEditing(bool isEditing) = _IsEditing;
const factory RowCardEvent.didReceiveCells(
List<DatabaseCellContext> cells,
ChangedReason reason,
) = _DidReceiveCells;
const factory RowCardEvent.didReceiveRowMeta(
RowMetaPB meta,
) = _DidReceiveRowMeta;
}
@freezed
class RowCardState with _$RowCardState {
const factory RowCardState({
required List<DatabaseCellContext> cells,
required bool isEditing,
ChangedReason? changeReason,
}) = _RowCardState;
factory RowCardState.initial(
List<DatabaseCellContext> cells,
bool isEditing,
) =>
RowCardState(
cells: cells,
isEditing: isEditing,
);
}

View File

@ -0,0 +1,218 @@
import 'package:appflowy/mobile/presentation/database/card/card_content/card_cells/card_cells.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/widgets/card/cells/timestamp_card_cell.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import '../../application/cell/cell_service.dart';
import 'cells/card_cell.dart';
import 'cells/checkbox_card_cell.dart';
import 'cells/checklist_card_cell.dart';
import 'cells/date_card_cell.dart';
import 'cells/number_card_cell.dart';
import 'cells/select_option_card_cell.dart';
import 'cells/text_card_cell.dart';
import 'cells/url_card_cell.dart';
// T represents as the Generic card data
class CardCellBuilder<CustomCardData> {
final CellMemCache cellCache;
final Map<FieldType, CardCellStyle>? styles;
CardCellBuilder(this.cellCache, {this.styles});
Widget buildCell({
CustomCardData? cardData,
required DatabaseCellContext cellContext,
EditableCardNotifier? cellNotifier,
RowCardRenderHook<CustomCardData>? renderHook,
required bool hasNotes,
}) {
final cellControllerBuilder = CellControllerBuilder(
cellContext: cellContext,
cellCache: cellCache,
);
final key = cellContext.key();
final style = styles?[cellContext.fieldType];
return PlatformExtension.isMobile
? _getMobileCardCellWidget(
key: key,
cellContext: cellContext,
cellControllerBuilder: cellControllerBuilder,
style: style,
cardData: cardData,
cellNotifier: cellNotifier,
renderHook: renderHook,
hasNotes: hasNotes,
)
: _getDesktopCardCellWidget(
key: key,
cellContext: cellContext,
cellControllerBuilder: cellControllerBuilder,
style: style,
cardData: cardData,
cellNotifier: cellNotifier,
renderHook: renderHook,
hasNotes: hasNotes,
);
}
Widget _getDesktopCardCellWidget({
required Key key,
required DatabaseCellContext cellContext,
required CellControllerBuilder cellControllerBuilder,
CardCellStyle? style,
CustomCardData? cardData,
EditableCardNotifier? cellNotifier,
RowCardRenderHook<CustomCardData>? renderHook,
required bool hasNotes,
}) {
switch (cellContext.fieldType) {
case FieldType.Checkbox:
return CheckboxCardCell(
cellControllerBuilder: cellControllerBuilder,
key: key,
);
case FieldType.DateTime:
return DateCardCell<CustomCardData>(
renderHook: renderHook?.renderHook[FieldType.DateTime],
cellControllerBuilder: cellControllerBuilder,
key: key,
);
case FieldType.LastEditedTime:
return TimestampCardCell<CustomCardData>(
renderHook: renderHook?.renderHook[FieldType.LastEditedTime],
cellControllerBuilder: cellControllerBuilder,
key: key,
);
case FieldType.CreatedTime:
return TimestampCardCell<CustomCardData>(
renderHook: renderHook?.renderHook[FieldType.CreatedTime],
cellControllerBuilder: cellControllerBuilder,
key: key,
);
case FieldType.SingleSelect:
return SelectOptionCardCell<CustomCardData>(
renderHook: renderHook?.renderHook[FieldType.SingleSelect],
cellControllerBuilder: cellControllerBuilder,
cardData: cardData,
key: key,
);
case FieldType.MultiSelect:
return SelectOptionCardCell<CustomCardData>(
renderHook: renderHook?.renderHook[FieldType.MultiSelect],
cellControllerBuilder: cellControllerBuilder,
cardData: cardData,
editableNotifier: cellNotifier,
key: key,
);
case FieldType.Checklist:
return ChecklistCardCell(
cellControllerBuilder: cellControllerBuilder,
key: key,
);
case FieldType.Number:
return NumberCardCell<CustomCardData>(
renderHook: renderHook?.renderHook[FieldType.Number],
style: isStyleOrNull<NumberCardCellStyle>(style),
cellControllerBuilder: cellControllerBuilder,
key: key,
);
case FieldType.RichText:
return TextCardCell<CustomCardData>(
key: key,
style: isStyleOrNull<TextCardCellStyle>(style),
cardData: cardData,
renderHook: renderHook?.renderHook[FieldType.RichText],
cellControllerBuilder: cellControllerBuilder,
editableNotifier: cellNotifier,
showNotes: cellContext.fieldInfo.isPrimary && hasNotes,
);
case FieldType.URL:
return URLCardCell<CustomCardData>(
style: isStyleOrNull<URLCardCellStyle>(style),
cellControllerBuilder: cellControllerBuilder,
key: key,
);
}
throw UnimplementedError;
}
Widget _getMobileCardCellWidget({
required Key key,
required DatabaseCellContext cellContext,
required CellControllerBuilder cellControllerBuilder,
CardCellStyle? style,
CustomCardData? cardData,
EditableCardNotifier? cellNotifier,
RowCardRenderHook<CustomCardData>? renderHook,
required bool hasNotes,
}) {
switch (cellContext.fieldType) {
case FieldType.Checkbox:
return MobileCheckboxCardCell(
cellControllerBuilder: cellControllerBuilder,
key: key,
);
case FieldType.DateTime:
return MobileDateCardCell<CustomCardData>(
renderHook: renderHook?.renderHook[FieldType.DateTime],
cellControllerBuilder: cellControllerBuilder,
key: key,
);
case FieldType.LastEditedTime:
return MobileTimestampCardCell<CustomCardData>(
renderHook: renderHook?.renderHook[FieldType.LastEditedTime],
cellControllerBuilder: cellControllerBuilder,
key: key,
);
case FieldType.CreatedTime:
return MobileTimestampCardCell<CustomCardData>(
renderHook: renderHook?.renderHook[FieldType.CreatedTime],
cellControllerBuilder: cellControllerBuilder,
key: key,
);
case FieldType.SingleSelect:
return MobileSelectOptionCardCell<CustomCardData>(
renderHook: renderHook?.renderHook[FieldType.SingleSelect],
cellControllerBuilder: cellControllerBuilder,
cardData: cardData,
key: key,
);
case FieldType.MultiSelect:
return MobileSelectOptionCardCell<CustomCardData>(
renderHook: renderHook?.renderHook[FieldType.MultiSelect],
cellControllerBuilder: cellControllerBuilder,
cardData: cardData,
key: key,
);
case FieldType.Checklist:
return MobileChecklistCardCell(
cellControllerBuilder: cellControllerBuilder,
key: key,
);
case FieldType.Number:
return MobileNumberCardCell<CustomCardData>(
renderHook: renderHook?.renderHook[FieldType.Number],
cellControllerBuilder: cellControllerBuilder,
key: key,
);
case FieldType.RichText:
return MobileTextCardCell<CustomCardData>(
key: key,
cardData: cardData,
renderHook: renderHook?.renderHook[FieldType.RichText],
cellControllerBuilder: cellControllerBuilder,
);
case FieldType.URL:
return MobileURLCardCell<CustomCardData>(
cellControllerBuilder: cellControllerBuilder,
key: key,
);
}
throw UnimplementedError;
}
}

View File

@ -0,0 +1,187 @@
import 'package:appflowy/plugins/database/application/cell/cell_service.dart';
import 'package:appflowy/plugins/database/application/row/row_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/timestamp_entities.pb.dart';
import 'package:flutter/material.dart';
typedef CellRenderHook<C, CustomCardData> = Widget? Function(
C cellData,
CustomCardData cardData,
BuildContext buildContext,
);
typedef RenderHookByFieldType<C> = Map<FieldType, CellRenderHook<dynamic, C>>;
/// The [RowCardRenderHook] is used to customize the rendering of the
/// card cell. Each cell has its own field type. So the [renderHook]
/// is a map of [FieldType] to [CellRenderHook].
class RowCardRenderHook<CustomCardData> {
final RenderHookByFieldType<CustomCardData> renderHook = {};
RowCardRenderHook();
/// Add render hook for the FieldType.SingleSelect and FieldType.MultiSelect
void addSelectOptionHook(
CellRenderHook<List<SelectOptionPB>, CustomCardData?> hook,
) {
final hookFn = _typeSafeHook<List<SelectOptionPB>>(hook);
renderHook[FieldType.SingleSelect] = hookFn;
renderHook[FieldType.MultiSelect] = hookFn;
}
/// Add a render hook for the [FieldType.RichText]
void addTextCellHook(
CellRenderHook<String, CustomCardData?> hook,
) {
renderHook[FieldType.RichText] = _typeSafeHook<String>(hook);
}
/// Add a render hook for the [FieldType.Number]
void addNumberCellHook(
CellRenderHook<String, CustomCardData?> hook,
) {
renderHook[FieldType.Number] = _typeSafeHook<String>(hook);
}
/// Add a render hook for the [FieldType.Date]
void addDateCellHook(
CellRenderHook<DateCellDataPB, CustomCardData?> hook,
) {
renderHook[FieldType.DateTime] = _typeSafeHook<DateCellDataPB>(hook);
}
/// Add a render hook for [FieldType.LastEditedTime] and [FieldType.CreatedTime]
void addTimestampCellHook(
CellRenderHook<TimestampCellDataPB, CustomCardData?> hook,
) {
renderHook[FieldType.LastEditedTime] =
_typeSafeHook<TimestampCellDataPB>(hook);
renderHook[FieldType.CreatedTime] =
_typeSafeHook<TimestampCellDataPB>(hook);
}
CellRenderHook<dynamic, CustomCardData> _typeSafeHook<C>(
CellRenderHook<C, CustomCardData?> hook,
) {
Widget? hookFn(cellData, cardData, buildContext) {
if (cellData == null) {
return null;
}
if (cellData is C) {
return hook(cellData, cardData, buildContext);
} else {
Log.debug("Unexpected cellData type: ${cellData.runtimeType}");
return null;
}
}
return hookFn;
}
}
abstract class CardCellStyle {}
S? isStyleOrNull<S>(CardCellStyle? style) {
if (style is S) {
return style as S;
} else {
return null;
}
}
abstract class CardCell<T, S extends CardCellStyle> extends StatefulWidget {
final T? cardData;
final S? style;
const CardCell({super.key, this.cardData, this.style});
}
class EditableCardNotifier {
final ValueNotifier<bool> isCellEditing;
EditableCardNotifier({bool isEditing = false})
: isCellEditing = ValueNotifier(isEditing);
void dispose() {
isCellEditing.dispose();
}
}
class EditableRowNotifier {
final Map<EditableCellId, EditableCardNotifier> _cells = {};
final ValueNotifier<bool> isEditing;
EditableRowNotifier({required bool isEditing})
: isEditing = ValueNotifier(isEditing);
void bindCell(
DatabaseCellContext cellIdentifier,
EditableCardNotifier notifier,
) {
assert(
_cells.values.isEmpty,
'Only one cell can receive the notification',
);
final id = EditableCellId.from(cellIdentifier);
_cells[id]?.dispose();
notifier.isCellEditing.addListener(() {
isEditing.value = notifier.isCellEditing.value;
});
_cells[EditableCellId.from(cellIdentifier)] = notifier;
}
void becomeFirstResponder() {
if (_cells.values.isEmpty) return;
assert(
_cells.values.length == 1,
'Only one cell can receive the notification',
);
_cells.values.first.isCellEditing.value = true;
}
void resignFirstResponder() {
if (_cells.values.isEmpty) return;
assert(
_cells.values.length == 1,
'Only one cell can receive the notification',
);
_cells.values.first.isCellEditing.value = false;
}
void unbind() {
for (final notifier in _cells.values) {
notifier.dispose();
}
_cells.clear();
}
void dispose() {
unbind();
isEditing.dispose();
}
}
abstract mixin class EditableCell {
// Each cell notifier will be bind to the [EditableRowNotifier], which enable
// the row notifier receive its cells event. For example: begin editing the
// cell or end editing the cell.
//
EditableCardNotifier? get editableNotifier;
}
class EditableCellId {
String fieldId;
RowId rowId;
EditableCellId(this.rowId, this.fieldId);
factory EditableCellId.from(DatabaseCellContext cellIdentifier) =>
EditableCellId(
cellIdentifier.rowId,
cellIdentifier.fieldId,
);
}

View File

@ -0,0 +1,72 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/checkbox_cell/checkbox_cell_bloc.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../define.dart';
import 'card_cell.dart';
class CheckboxCardCell extends CardCell {
final CellControllerBuilder cellControllerBuilder;
const CheckboxCardCell({
required this.cellControllerBuilder,
super.key,
});
@override
State<CheckboxCardCell> createState() => _CheckboxCellState();
}
class _CheckboxCellState extends State<CheckboxCardCell> {
late CheckboxCellBloc _cellBloc;
@override
void initState() {
super.initState();
final cellController =
widget.cellControllerBuilder.build() as CheckboxCellController;
_cellBloc = CheckboxCellBloc(cellController: cellController)
..add(const CheckboxCellEvent.initial());
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cellBloc,
child: BlocBuilder<CheckboxCellBloc, CheckboxCellState>(
buildWhen: (previous, current) =>
previous.isSelected != current.isSelected,
builder: (context, state) {
final icon = FlowySvg(
state.isSelected ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s,
blendMode: BlendMode.dst,
);
return Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: CardSizes.cardCellPadding,
child: FlowyIconButton(
iconPadding: EdgeInsets.zero,
icon: icon,
width: 20,
onPressed: () => context
.read<CheckboxCellBloc>()
.add(const CheckboxCellEvent.select()),
),
),
);
},
),
);
}
@override
Future<void> dispose() async {
_cellBloc.close();
super.dispose();
}
}

View File

@ -0,0 +1,50 @@
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/checklist_cell/checklist_progress_bar.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../row/cells/checklist_cell/checklist_cell_bloc.dart';
import '../define.dart';
import 'card_cell.dart';
class ChecklistCardCell extends CardCell {
final CellControllerBuilder cellControllerBuilder;
const ChecklistCardCell({required this.cellControllerBuilder, super.key});
@override
State<ChecklistCardCell> createState() => _ChecklistCellState();
}
class _ChecklistCellState extends State<ChecklistCardCell> {
late ChecklistCellBloc _cellBloc;
@override
void initState() {
final cellController =
widget.cellControllerBuilder.build() as ChecklistCellController;
_cellBloc = ChecklistCellBloc(cellController: cellController);
_cellBloc.add(const ChecklistCellEvent.initial());
super.initState();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cellBloc,
child: BlocBuilder<ChecklistCellBloc, ChecklistCellState>(
builder: (context, state) {
if (state.tasks.isEmpty) {
return const SizedBox.shrink();
}
return Padding(
padding: CardSizes.cardCellPadding,
child: ChecklistProgressBar(
tasks: state.tasks,
percent: state.percent,
),
);
},
),
);
}
}

View File

@ -0,0 +1,78 @@
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/date_cell/date_cell_bloc.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../define.dart';
import 'card_cell.dart';
class DateCardCell<CustomCardData> extends CardCell {
final CellControllerBuilder cellControllerBuilder;
final CellRenderHook<dynamic, CustomCardData>? renderHook;
const DateCardCell({
required this.cellControllerBuilder,
this.renderHook,
super.key,
});
@override
State<DateCardCell> createState() => _DateCellState();
}
class _DateCellState extends State<DateCardCell> {
late DateCellBloc _cellBloc;
@override
void initState() {
final cellController =
widget.cellControllerBuilder.build() as DateCellController;
_cellBloc = DateCellBloc(cellController: cellController)
..add(const DateCellEvent.initial());
super.initState();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cellBloc,
child: BlocBuilder<DateCellBloc, DateCellState>(
buildWhen: (previous, current) => previous.dateStr != current.dateStr,
builder: (context, state) {
if (state.dateStr.isEmpty) {
return const SizedBox.shrink();
}
final Widget? custom = widget.renderHook?.call(
state.data,
widget.cardData,
context,
);
if (custom != null) {
return custom;
}
return Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: CardSizes.cardCellPadding,
child: FlowyText.regular(
state.dateStr,
fontSize: 11,
color: Theme.of(context).hintColor,
overflow: TextOverflow.ellipsis,
),
),
);
},
),
);
}
@override
Future<void> dispose() async {
_cellBloc.close();
super.dispose();
}
}

View File

@ -0,0 +1,87 @@
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/number_cell/number_cell_bloc.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../define.dart';
import 'card_cell.dart';
class NumberCardCellStyle extends CardCellStyle {
final double fontSize;
NumberCardCellStyle(this.fontSize);
}
class NumberCardCell<CustomCardData>
extends CardCell<CustomCardData, NumberCardCellStyle> {
final CellRenderHook<String, CustomCardData>? renderHook;
final CellControllerBuilder cellControllerBuilder;
const NumberCardCell({
required this.cellControllerBuilder,
super.cardData,
super.style,
this.renderHook,
super.key,
});
@override
State<NumberCardCell> createState() => _NumberCellState();
}
class _NumberCellState extends State<NumberCardCell> {
late NumberCellBloc _cellBloc;
@override
void initState() {
final cellController =
widget.cellControllerBuilder.build() as NumberCellController;
_cellBloc = NumberCellBloc(cellController: cellController)
..add(const NumberCellEvent.initial());
super.initState();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cellBloc,
child: BlocBuilder<NumberCellBloc, NumberCellState>(
buildWhen: (previous, current) =>
previous.cellContent != current.cellContent,
builder: (context, state) {
if (state.cellContent.isEmpty) {
return const SizedBox.shrink();
}
final Widget? custom = widget.renderHook?.call(
state.cellContent,
widget.cardData,
context,
);
if (custom != null) {
return custom;
}
return Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: CardSizes.cardCellPadding,
child: FlowyText.regular(
state.cellContent,
fontSize: widget.style?.fontSize ?? 11,
color: Theme.of(context).hintColor,
),
),
);
},
),
);
}
@override
Future<void> dispose() async {
_cellBloc.close();
super.dispose();
}
}

View File

@ -0,0 +1,92 @@
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/select_option_cell/extension.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/select_option_cell/select_option_cell_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option.pb.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../define.dart';
import 'card_cell.dart';
class SelectOptionCardCellStyle extends CardCellStyle {}
class SelectOptionCardCell<CustomCardData>
extends CardCell<CustomCardData, SelectOptionCardCellStyle>
with EditableCell {
final CellControllerBuilder cellControllerBuilder;
final CellRenderHook<List<SelectOptionPB>, CustomCardData>? renderHook;
@override
final EditableCardNotifier? editableNotifier;
SelectOptionCardCell({
required this.cellControllerBuilder,
required super.cardData,
this.renderHook,
this.editableNotifier,
super.key,
});
@override
State<SelectOptionCardCell> createState() => _SelectOptionCellState();
}
class _SelectOptionCellState extends State<SelectOptionCardCell> {
late SelectOptionCellBloc _cellBloc;
@override
void initState() {
final cellController =
widget.cellControllerBuilder.build() as SelectOptionCellController;
_cellBloc = SelectOptionCellBloc(cellController: cellController)
..add(const SelectOptionCellEvent.initial());
super.initState();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cellBloc,
child: BlocBuilder<SelectOptionCellBloc, SelectOptionCellState>(
buildWhen: (previous, current) {
return previous.selectedOptions != current.selectedOptions;
},
builder: (context, state) {
final Widget? custom = widget.renderHook?.call(
state.selectedOptions,
widget.cardData,
context,
);
if (custom != null) {
return custom;
}
final children = state.selectedOptions
.map(
(option) => SelectOptionTag(
option: option,
fontSize: 11,
padding:
const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
),
)
.toList();
return Align(
alignment: AlignmentDirectional.topStart,
child: Padding(
padding: CardSizes.cardCellPadding,
child: Wrap(spacing: 4, runSpacing: 2, children: children),
),
);
},
),
);
}
@override
Future<void> dispose() async {
_cellBloc.close();
super.dispose();
}
}

View File

@ -0,0 +1,221 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/text_cell/text_cell_bloc.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../row/cell_builder.dart';
import '../define.dart';
import 'card_cell.dart';
class TextCardCellStyle extends CardCellStyle {
final double fontSize;
TextCardCellStyle(this.fontSize);
}
class TextCardCell<CustomCardData>
extends CardCell<CustomCardData, TextCardCellStyle> with EditableCell {
const TextCardCell({
super.key,
super.cardData,
super.style,
required this.cellControllerBuilder,
this.editableNotifier,
this.renderHook,
this.showNotes = false,
});
@override
final EditableCardNotifier? editableNotifier;
final CellControllerBuilder cellControllerBuilder;
final CellRenderHook<String, CustomCardData>? renderHook;
final bool showNotes;
@override
State<TextCardCell> createState() => _TextCellState();
}
class _TextCellState extends State<TextCardCell> {
late TextCellBloc _cellBloc;
late TextEditingController _controller;
bool focusWhenInit = false;
final focusNode = SingleListenerFocusNode();
@override
void initState() {
final cellController =
widget.cellControllerBuilder.build() as TextCellController;
_cellBloc = TextCellBloc(cellController: cellController)
..add(const TextCellEvent.initial());
_controller = TextEditingController(text: _cellBloc.state.content);
focusWhenInit = widget.editableNotifier?.isCellEditing.value ?? false;
if (focusWhenInit) {
focusNode.requestFocus();
}
// If the focusNode lost its focus, the widget's editableNotifier will
// set to false, which will cause the [EditableRowNotifier] to receive
// end edit event.
focusNode.addListener(() {
if (!focusNode.hasFocus) {
focusWhenInit = false;
widget.editableNotifier?.isCellEditing.value = false;
_cellBloc.add(const TextCellEvent.enableEdit(false));
}
});
_bindEditableNotifier();
super.initState();
}
void _bindEditableNotifier() {
widget.editableNotifier?.isCellEditing.addListener(() {
if (!mounted) return;
final isEditing = widget.editableNotifier?.isCellEditing.value ?? false;
if (isEditing) {
WidgetsBinding.instance.addPostFrameCallback((_) {
focusNode.requestFocus();
});
}
_cellBloc.add(TextCellEvent.enableEdit(isEditing));
});
}
@override
void didUpdateWidget(covariant oldWidget) {
_bindEditableNotifier();
super.didUpdateWidget(oldWidget);
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cellBloc,
child: BlocListener<TextCellBloc, TextCellState>(
listener: (context, state) {
if (_controller.text != state.content) {
_controller.text = state.content;
}
},
child: BlocBuilder<TextCellBloc, TextCellState>(
buildWhen: (previous, current) {
if (previous.content != current.content &&
_controller.text == current.content &&
current.enableEdit) {
return false;
}
return previous != current;
},
builder: (context, state) {
// Returns a custom render widget
final Widget? custom = widget.renderHook?.call(
state.content,
widget.cardData,
context,
);
if (custom != null) {
return custom;
}
final isTitle =
context.read<TextCellBloc>().cellController.fieldInfo.isPrimary;
if (state.content.isEmpty &&
state.enableEdit == false &&
focusWhenInit == false &&
!isTitle) {
return const SizedBox.shrink();
}
final child = state.enableEdit || focusWhenInit
? _buildTextField()
: _buildText(state, isTitle);
return Padding(
padding: CardSizes.cardCellPadding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.showNotes) ...[
FlowyTooltip(
message: LocaleKeys.board_notesTooltip.tr(),
child: FlowySvg(
FlowySvgs.notes_s,
color: Theme.of(context).hintColor,
),
),
const HSpace(4),
],
Expanded(child: child),
],
),
);
},
),
),
);
}
Future<void> focusChanged() async {
_cellBloc.add(TextCellEvent.updateText(_controller.text));
}
@override
Future<void> dispose() async {
_cellBloc.close();
_controller.dispose();
focusNode.dispose();
super.dispose();
}
Widget _buildText(TextCellState state, bool isTitle) {
final text = state.content.isEmpty
? LocaleKeys.grid_row_titlePlaceholder.tr()
: state.content;
final color = state.content.isEmpty ? Theme.of(context).hintColor : null;
return FlowyText(
text,
fontSize: _fontSize(isTitle),
fontWeight: _fontWeight(isTitle),
color: color,
maxLines: null, // Enable multiple lines
);
}
double _fontSize(bool isTitle) {
return widget.style?.fontSize ?? (isTitle ? 12 : 11);
}
FontWeight _fontWeight(bool isTitle) {
return isTitle ? FontWeight.w500 : FontWeight.w400;
}
Widget _buildTextField() {
return TextField(
controller: _controller,
focusNode: focusNode,
onChanged: (value) => focusChanged(),
onEditingComplete: () => focusNode.unfocus(),
maxLines: null,
style: Theme.of(context)
.textTheme
.bodyMedium!
.copyWith(fontSize: _fontSize(true)),
decoration: InputDecoration(
contentPadding:
EdgeInsets.symmetric(vertical: CardSizes.cardCellPadding.top),
border: InputBorder.none,
isDense: true,
isCollapsed: true,
hintText: LocaleKeys.grid_row_titlePlaceholder.tr(),
),
);
}
}

View File

@ -0,0 +1,77 @@
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/timestamp_cell/timestamp_cell_bloc.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../define.dart';
import 'card_cell.dart';
class TimestampCardCell<CustomCardData> extends CardCell {
final CellControllerBuilder cellControllerBuilder;
final CellRenderHook<dynamic, CustomCardData>? renderHook;
const TimestampCardCell({
required this.cellControllerBuilder,
this.renderHook,
super.key,
});
@override
State<TimestampCardCell> createState() => _TimestampCellState();
}
class _TimestampCellState extends State<TimestampCardCell> {
late TimestampCellBloc _cellBloc;
@override
void initState() {
final cellController =
widget.cellControllerBuilder.build() as TimestampCellController;
_cellBloc = TimestampCellBloc(cellController: cellController)
..add(const TimestampCellEvent.initial());
super.initState();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cellBloc,
child: BlocBuilder<TimestampCellBloc, TimestampCellState>(
buildWhen: (previous, current) => previous.dateStr != current.dateStr,
builder: (context, state) {
if (state.dateStr.isEmpty) {
return const SizedBox.shrink();
}
final Widget? custom = widget.renderHook?.call(
state.data,
widget.cardData,
context,
);
if (custom != null) {
return custom;
}
return Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: CardSizes.cardCellPadding,
child: FlowyText.regular(
state.dateStr,
fontSize: 11,
color: Theme.of(context).hintColor,
),
),
);
},
),
);
}
@override
Future<void> dispose() async {
_cellBloc.close();
super.dispose();
}
}

View File

@ -0,0 +1,78 @@
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/url_cell/url_cell_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../define.dart';
import 'card_cell.dart';
class URLCardCellStyle extends CardCellStyle {
final double fontSize;
URLCardCellStyle(this.fontSize);
}
class URLCardCell<CustomCardData>
extends CardCell<CustomCardData, URLCardCellStyle> {
final CellControllerBuilder cellControllerBuilder;
const URLCardCell({
required this.cellControllerBuilder,
super.style,
super.key,
});
@override
State<URLCardCell> createState() => _URLCellState();
}
class _URLCellState extends State<URLCardCell> {
late URLCellBloc _cellBloc;
@override
void initState() {
final cellController =
widget.cellControllerBuilder.build() as URLCellController;
_cellBloc = URLCellBloc(cellController: cellController);
_cellBloc.add(const URLCellEvent.initial());
super.initState();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cellBloc,
child: BlocBuilder<URLCellBloc, URLCellState>(
buildWhen: (previous, current) => previous.content != current.content,
builder: (context, state) {
if (state.content.isEmpty) {
return const SizedBox();
}
return Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: CardSizes.cardCellPadding,
child: RichText(
textAlign: TextAlign.left,
text: TextSpan(
text: state.content,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontSize: widget.style?.fontSize ?? 11,
color: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
),
),
),
),
);
},
),
);
}
@override
Future<void> dispose() async {
_cellBloc.close();
super.dispose();
}
}

View File

@ -0,0 +1,105 @@
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
enum AccessoryType {
edit,
more,
}
abstract mixin class CardAccessory implements Widget {
AccessoryType get type;
void onTap(BuildContext context) {}
}
typedef CardAccessoryBuilder = List<CardAccessory> Function(
BuildContext buildContext,
);
class CardAccessoryContainer extends StatelessWidget {
final void Function(AccessoryType) onTapAccessory;
final List<CardAccessory> accessories;
const CardAccessoryContainer({
required this.accessories,
required this.onTapAccessory,
super.key,
});
@override
Widget build(BuildContext context) {
final children = accessories.map<Widget>((accessory) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
accessory.onTap(context);
onTapAccessory(accessory.type);
},
child: _wrapHover(context, accessory),
);
}).toList();
children.insert(
1,
VerticalDivider(
width: 1,
thickness: 1,
color: Theme.of(context).brightness == Brightness.light
? const Color(0xFF1F2329).withOpacity(0.12)
: const Color(0xff59647a),
),
);
return _wrapDecoration(
context,
IntrinsicHeight(child: Row(children: children)),
);
}
Widget _wrapHover(BuildContext context, CardAccessory accessory) {
return SizedBox(
width: 24,
height: 22,
child: FlowyHover(
style: HoverStyle(
backgroundColor: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.zero,
),
child: accessory,
),
);
}
Widget _wrapDecoration(BuildContext context, Widget child) {
final decoration = BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.all(Radius.circular(4)),
border: Border.fromBorderSide(
BorderSide(
color: Theme.of(context).brightness == Brightness.light
? const Color(0xFF1F2329).withOpacity(0.12)
: const Color(0xff59647a),
width: 1.0,
),
),
boxShadow: [
BoxShadow(
blurRadius: 4,
spreadRadius: 0,
color: const Color(0xFF1F2329).withOpacity(0.02),
),
BoxShadow(
blurRadius: 4,
spreadRadius: -2,
color: const Color(0xFF1F2329).withOpacity(0.02),
),
],
);
return Container(
clipBehavior: Clip.hardEdge,
decoration: decoration,
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(4)),
child: child,
),
);
}
}

View File

@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'accessory.dart';
class RowCardContainer extends StatelessWidget {
const RowCardContainer({
super.key,
required this.child,
required this.openCard,
required this.openAccessory,
required this.accessories,
this.buildAccessoryWhen,
});
final Widget child;
final void Function(BuildContext) openCard;
final void Function(AccessoryType) openAccessory;
final List<CardAccessory> accessories;
final bool Function()? buildAccessoryWhen;
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => _CardContainerNotifier(),
child: Consumer<_CardContainerNotifier>(
builder: (context, notifier, _) {
Widget container = Center(child: child);
bool shouldBuildAccessory = true;
if (buildAccessoryWhen != null) {
shouldBuildAccessory = buildAccessoryWhen!.call();
}
if (shouldBuildAccessory && accessories.isNotEmpty) {
container = _CardEnterRegion(
accessories: accessories,
onTapAccessory: openAccessory,
child: container,
);
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => openCard(context),
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 30),
child: container,
),
);
},
),
);
}
}
class _CardEnterRegion extends StatelessWidget {
const _CardEnterRegion({
required this.child,
required this.accessories,
required this.onTapAccessory,
});
final Widget child;
final List<CardAccessory> accessories;
final void Function(AccessoryType) onTapAccessory;
@override
Widget build(BuildContext context) {
return Selector<_CardContainerNotifier, bool>(
selector: (context, notifier) => notifier.onEnter,
builder: (context, onEnter, _) {
final List<Widget> children = [child];
if (onEnter) {
children.add(
Positioned(
top: 10.0,
right: 10.0,
child: CardAccessoryContainer(
accessories: accessories,
onTapAccessory: onTapAccessory,
),
),
);
}
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (p) =>
Provider.of<_CardContainerNotifier>(context, listen: false)
.onEnter = true,
onExit: (p) =>
Provider.of<_CardContainerNotifier>(context, listen: false)
.onEnter = false,
child: IntrinsicHeight(
child: Stack(
alignment: AlignmentDirectional.topEnd,
fit: StackFit.expand,
children: children,
),
),
);
},
);
}
}
class _CardContainerNotifier extends ChangeNotifier {
bool _onEnter = false;
_CardContainerNotifier();
set onEnter(bool value) {
if (_onEnter != value) {
_onEnter = value;
notifyListeners();
}
}
bool get onEnter => _onEnter;
}

View File

@ -0,0 +1,5 @@
import 'package:flutter/widgets.dart';
class CardSizes {
static EdgeInsets get cardCellPadding => const EdgeInsets.all(4);
}

View File

@ -0,0 +1,34 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
import 'package:easy_localization/easy_localization.dart';
extension DatabaseLayoutExtension on DatabaseLayoutPB {
String get layoutName {
return switch (this) {
DatabaseLayoutPB.Board => LocaleKeys.board_menuName.tr(),
DatabaseLayoutPB.Calendar => LocaleKeys.calendar_menuName.tr(),
DatabaseLayoutPB.Grid => LocaleKeys.grid_menuName.tr(),
_ => "",
};
}
ViewLayoutPB get layoutType {
return switch (this) {
DatabaseLayoutPB.Board => ViewLayoutPB.Board,
DatabaseLayoutPB.Calendar => ViewLayoutPB.Calendar,
DatabaseLayoutPB.Grid => ViewLayoutPB.Grid,
_ => throw UnimplementedError(),
};
}
FlowySvgData get icon {
return switch (this) {
DatabaseLayoutPB.Board => FlowySvgs.board_s,
DatabaseLayoutPB.Calendar => FlowySvgs.date_s,
DatabaseLayoutPB.Grid => FlowySvgs.grid_s,
_ => throw UnimplementedError(),
};
}
}

View File

@ -0,0 +1,72 @@
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_listener.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flutter/material.dart';
class DatabaseViewWidget extends StatefulWidget {
final ViewPB view;
final bool shrinkWrap;
const DatabaseViewWidget({
super.key,
required this.view,
this.shrinkWrap = true,
});
@override
State<DatabaseViewWidget> createState() => _DatabaseViewWidgetState();
}
class _DatabaseViewWidgetState extends State<DatabaseViewWidget> {
/// Listens to the view updates.
late final ViewListener _listener;
/// Notifies the view layout type changes. When the layout type changes,
/// the widget of the view will be updated.
late final ValueNotifier<ViewLayoutPB> _layoutTypeChangeNotifier;
/// The view will be updated by the [ViewListener].
late ViewPB view;
@override
void initState() {
super.initState();
view = widget.view;
_listenOnViewUpdated();
}
@override
void dispose() {
_layoutTypeChangeNotifier.dispose();
_listener.stop();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<ViewLayoutPB>(
valueListenable: _layoutTypeChangeNotifier,
builder: (_, __, ___) {
return view
.plugin()
.widgetBuilder
.buildWidget(shrinkWrap: widget.shrinkWrap);
},
);
}
void _listenOnViewUpdated() {
_listener = ViewListener(viewId: widget.view.id)
..start(
onViewUpdated: (updatedView) {
if (mounted) {
view = updatedView;
_layoutTypeChangeNotifier.value = view.layout;
}
},
);
_layoutTypeChangeNotifier = ValueNotifier(widget.view.layout);
}
}

View File

@ -0,0 +1,173 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/application/field/field_info.dart';
import 'package:appflowy/plugins/database/application/setting/group_bloc.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/header/field_type_extension.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle_style.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/board_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:protobuf/protobuf.dart' hide FieldInfo;
class DatabaseGroupList extends StatelessWidget {
const DatabaseGroupList({
super.key,
required this.viewId,
required this.databaseController,
required this.onDismissed,
});
final String viewId;
final DatabaseController databaseController;
final VoidCallback onDismissed;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => DatabaseGroupBloc(
viewId: viewId,
databaseController: databaseController,
)..add(const DatabaseGroupEvent.initial()),
child: BlocBuilder<DatabaseGroupBloc, DatabaseGroupState>(
builder: (context, state) {
final showHideUngroupedToggle = state.fieldInfos.any(
(field) =>
field.canBeGroup &&
field.isGroupField &&
field.fieldType != FieldType.Checkbox,
);
final children = [
if (showHideUngroupedToggle) ...[
SizedBox(
height: GridSize.popoverItemHeight,
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Row(
children: [
Expanded(
child: FlowyText.medium(
LocaleKeys.board_showUngrouped.tr(),
),
),
Toggle(
value: !state.layoutSettings.hideUngroupedColumn,
onChanged: (value) =>
_updateLayoutSettings(state.layoutSettings, value),
style: ToggleStyle.big,
padding: EdgeInsets.zero,
),
],
),
),
),
const TypeOptionSeparator(spacing: 0),
],
SizedBox(
height: GridSize.popoverItemHeight,
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: FlowyText.medium(
LocaleKeys.board_groupBy.tr(),
textAlign: TextAlign.left,
color: Theme.of(context).hintColor,
),
),
),
...state.fieldInfos.where((fieldInfo) => fieldInfo.canBeGroup).map(
(fieldInfo) => _GridGroupCell(
fieldInfo: fieldInfo,
onSelected: onDismissed,
key: ValueKey(fieldInfo.id),
),
),
];
return ListView.separated(
shrinkWrap: true,
itemCount: children.length,
itemBuilder: (BuildContext context, int index) => children[index],
separatorBuilder: (BuildContext context, int index) =>
VSpace(GridSize.typeOptionSeparatorHeight),
padding: const EdgeInsets.symmetric(vertical: 6.0),
);
},
),
);
}
Future<void> _updateLayoutSettings(
BoardLayoutSettingPB layoutSettings,
bool hideUngrouped,
) {
layoutSettings.freeze();
final newLayoutSetting = layoutSettings.rebuild((message) {
message.hideUngroupedColumn = hideUngrouped;
});
return databaseController.updateLayoutSetting(
boardLayoutSetting: newLayoutSetting,
);
}
}
class _GridGroupCell extends StatelessWidget {
final VoidCallback onSelected;
final FieldInfo fieldInfo;
const _GridGroupCell({
required this.fieldInfo,
required this.onSelected,
super.key,
});
@override
Widget build(BuildContext context) {
Widget? rightIcon;
if (fieldInfo.isGroupField) {
rightIcon = const Padding(
padding: EdgeInsets.all(2.0),
child: FlowySvg(FlowySvgs.check_s),
);
}
return SizedBox(
height: GridSize.popoverItemHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6.0),
child: FlowyButton(
hoverColor: AFThemeExtension.of(context).lightGreyHover,
text: FlowyText.medium(
fieldInfo.name,
color: AFThemeExtension.of(context).textColor,
),
leftIcon: FlowySvg(
fieldInfo.fieldType.icon(),
color: Theme.of(context).iconTheme.color,
),
rightIcon: rightIcon,
onTap: () {
context.read<DatabaseGroupBloc>().add(
DatabaseGroupEvent.setGroupByField(
fieldInfo.id,
fieldInfo.fieldType,
),
);
onSelected();
},
),
),
);
}
}

View File

@ -0,0 +1,176 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/material.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
import '../cell_builder.dart';
class GridCellAccessoryBuildContext {
final BuildContext anchorContext;
final bool isCellEditing;
GridCellAccessoryBuildContext({
required this.anchorContext,
required this.isCellEditing,
});
}
class GridCellAccessoryBuilder<T extends State<StatefulWidget>> {
final GlobalKey<T> _key = GlobalKey();
final Widget Function(Key key) _builder;
GridCellAccessoryBuilder({required Widget Function(Key key) builder})
: _builder = builder;
Widget build() => _builder(_key);
void onTap() {
(_key.currentState as GridCellAccessoryState).onTap();
}
bool enable() {
if (_key.currentState == null) {
return true;
}
return (_key.currentState as GridCellAccessoryState).enable();
}
}
abstract mixin class GridCellAccessoryState {
void onTap();
// The accessory will be hidden if enable() return false;
bool enable() => true;
}
class PrimaryCellAccessory extends StatefulWidget {
final VoidCallback onTapCallback;
final bool isCellEditing;
const PrimaryCellAccessory({
required this.onTapCallback,
required this.isCellEditing,
super.key,
});
@override
State<StatefulWidget> createState() => _PrimaryCellAccessoryState();
}
class _PrimaryCellAccessoryState extends State<PrimaryCellAccessory>
with GridCellAccessoryState {
@override
Widget build(BuildContext context) {
return FlowyTooltip(
message: LocaleKeys.tooltip_openAsPage.tr(),
child: SizedBox(
width: 26,
height: 26,
child: Padding(
padding: const EdgeInsets.all(3.0),
child: FlowySvg(
FlowySvgs.full_view_s,
color: Theme.of(context).colorScheme.primary,
),
),
),
);
}
@override
void onTap() => widget.onTapCallback();
@override
bool enable() => !widget.isCellEditing;
}
class AccessoryHover extends StatefulWidget {
final CellAccessory child;
final FieldType fieldType;
const AccessoryHover({
super.key,
required this.child,
required this.fieldType,
});
@override
State<AccessoryHover> createState() => _AccessoryHoverState();
}
class _AccessoryHoverState extends State<AccessoryHover> {
bool _isHover = false;
@override
Widget build(BuildContext context) {
final List<Widget> children = [
DecoratedBox(
decoration: BoxDecoration(
color: _isHover && widget.fieldType != FieldType.Checklist
? AFThemeExtension.of(context).lightGreyHover
: Colors.transparent,
borderRadius: Corners.s6Border,
),
child: widget.child,
),
];
final accessoryBuilder = widget.child.accessoryBuilder;
if (accessoryBuilder != null && _isHover) {
final accessories = accessoryBuilder(
(GridCellAccessoryBuildContext(
anchorContext: context,
isCellEditing: false,
)),
);
children.add(
Padding(
padding: const EdgeInsets.only(right: 6),
child: CellAccessoryContainer(accessories: accessories),
).positioned(right: 0),
);
}
return MouseRegion(
cursor: SystemMouseCursors.click,
opaque: false,
onEnter: (p) => setState(() => _isHover = true),
onExit: (p) => setState(() => _isHover = false),
child: Stack(
fit: StackFit.loose,
alignment: AlignmentDirectional.center,
children: children,
),
);
}
}
class CellAccessoryContainer extends StatelessWidget {
final List<GridCellAccessoryBuilder> accessories;
const CellAccessoryContainer({required this.accessories, super.key});
@override
Widget build(BuildContext context) {
final children =
accessories.where((accessory) => accessory.enable()).map((accessory) {
final hover = FlowyHover(
style:
HoverStyle(hoverColor: AFThemeExtension.of(context).lightGreyHover),
builder: (_, onHover) => accessory.build(),
);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => accessory.onTap(),
child: hover,
);
}).toList();
return Wrap(spacing: 6, children: children);
}
}

View File

@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
class CellDecoration {
static BoxDecoration box({required Color color}) {
return BoxDecoration(
border: Border.all(color: Colors.black26, width: 0.2),
color: color,
);
}
}

View File

@ -0,0 +1,112 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
typedef CellKeyboardAction = dynamic Function();
enum CellKeyboardKey {
onEnter,
onCopy,
onInsert,
}
abstract class CellShortcuts extends Widget {
const CellShortcuts({super.key});
Map<CellKeyboardKey, CellKeyboardAction> get shortcutHandlers;
}
class GridCellShortcuts extends StatelessWidget {
final CellShortcuts child;
const GridCellShortcuts({required this.child, super.key});
@override
Widget build(BuildContext context) {
return Shortcuts(
shortcuts: shortcuts,
child: Actions(
actions: actions,
child: child,
),
);
}
Map<ShortcutActivator, Intent> get shortcuts => {
if (shouldAddKeyboardKey(CellKeyboardKey.onEnter))
LogicalKeySet(LogicalKeyboardKey.enter): const GridCellEnterIdent(),
if (shouldAddKeyboardKey(CellKeyboardKey.onCopy))
LogicalKeySet(
Platform.isMacOS
? LogicalKeyboardKey.meta
: LogicalKeyboardKey.control,
LogicalKeyboardKey.keyC,
): const GridCellCopyIntent(),
};
Map<Type, Action<Intent>> get actions => {
if (shouldAddKeyboardKey(CellKeyboardKey.onEnter))
GridCellEnterIdent: GridCellEnterAction(child: child),
if (shouldAddKeyboardKey(CellKeyboardKey.onCopy))
GridCellCopyIntent: GridCellCopyAction(child: child),
};
bool shouldAddKeyboardKey(CellKeyboardKey key) =>
child.shortcutHandlers.containsKey(key);
}
class GridCellEnterIdent extends Intent {
const GridCellEnterIdent();
}
class GridCellEnterAction extends Action<GridCellEnterIdent> {
final CellShortcuts child;
GridCellEnterAction({required this.child});
@override
void invoke(covariant GridCellEnterIdent intent) {
final callback = child.shortcutHandlers[CellKeyboardKey.onEnter];
if (callback != null) {
callback();
}
}
}
class GridCellCopyIntent extends Intent {
const GridCellCopyIntent();
}
class GridCellCopyAction extends Action<GridCellCopyIntent> {
final CellShortcuts child;
GridCellCopyAction({required this.child});
@override
void invoke(covariant GridCellCopyIntent intent) {
final callback = child.shortcutHandlers[CellKeyboardKey.onCopy];
if (callback == null) {
return;
}
final s = callback();
if (s is String) {
Clipboard.setData(ClipboardData(text: s));
}
}
}
class GridCellPasteIntent extends Intent {
const GridCellPasteIntent();
}
class GridCellPasteAction extends Action<GridCellPasteIntent> {
final CellShortcuts child;
GridCellPasteAction({required this.child});
@override
void invoke(covariant GridCellPasteIntent intent) {
final callback = child.shortcutHandlers[CellKeyboardKey.onInsert];
if (callback != null) {
callback();
}
}
}

View File

@ -0,0 +1,444 @@
import 'package:appflowy/mobile/presentation/database/card/card_detail/cells/checkbox_cell.dart';
import 'package:appflowy/mobile/presentation/database/card/card_detail/cells/number_cell.dart';
import 'package:appflowy/mobile/presentation/database/card/card_detail/cells/text_cell.dart';
import 'package:appflowy/mobile/presentation/database/card/card_detail/cells/url_cell.dart';
import 'package:appflowy/mobile/presentation/database/card/row/cells/cells.dart';
import 'package:appflowy/mobile/presentation/database/card/row/cells/mobile_checklist_cell.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import '../../application/cell/cell_service.dart';
import 'accessory/cell_accessory.dart';
import 'accessory/cell_shortcuts.dart';
import 'cells/cell_container.dart';
import 'cells/checkbox_cell/checkbox_cell.dart';
import 'cells/checklist_cell/checklist_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/text_cell/text_cell.dart';
import 'cells/timestamp_cell/timestamp_cell.dart';
import 'cells/url_cell/url_cell.dart';
/// Build the cell widget in Grid style.
class GridCellBuilder {
final CellMemCache cellCache;
GridCellBuilder({
required this.cellCache,
});
GridCellWidget build(
DatabaseCellContext cellContext, {
GridCellStyle? style,
}) {
final cellControllerBuilder = CellControllerBuilder(
cellContext: cellContext,
cellCache: cellCache,
);
final key = cellContext.key();
if (PlatformExtension.isMobile) {
return _getMobileCardCellWidget(
key,
cellContext,
cellControllerBuilder,
style,
);
}
return _getDesktopGridCellWidget(
key,
cellContext,
cellControllerBuilder,
style,
);
}
GridCellWidget _getDesktopGridCellWidget(
ValueKey key,
DatabaseCellContext cellContext,
CellControllerBuilder cellControllerBuilder,
GridCellStyle? style,
) {
switch (cellContext.fieldType) {
case FieldType.Checkbox:
return GridCheckboxCell(
cellControllerBuilder: cellControllerBuilder,
style: style,
key: key,
);
case FieldType.DateTime:
return GridDateCell(
cellControllerBuilder: cellControllerBuilder,
key: key,
style: style,
);
case FieldType.LastEditedTime:
case FieldType.CreatedTime:
return GridTimestampCell(
cellControllerBuilder: cellControllerBuilder,
key: key,
style: style,
fieldType: cellContext.fieldType,
);
case FieldType.SingleSelect:
return GridSingleSelectCell(
cellControllerBuilder: cellControllerBuilder,
style: style,
key: key,
);
case FieldType.MultiSelect:
return GridMultiSelectCell(
cellControllerBuilder: cellControllerBuilder,
style: style,
key: key,
);
case FieldType.Checklist:
return GridChecklistCell(
cellControllerBuilder: cellControllerBuilder,
style: style,
key: key,
);
case FieldType.Number:
return GridNumberCell(
cellControllerBuilder: cellControllerBuilder,
style: style,
key: key,
);
case FieldType.RichText:
return GridTextCell(
cellControllerBuilder: cellControllerBuilder,
style: style,
key: key,
);
case FieldType.URL:
return GridURLCell(
cellControllerBuilder: cellControllerBuilder,
style: style,
key: key,
);
}
throw UnimplementedError;
}
// editable cell/(card's propery value) widget
GridCellWidget _getMobileCardCellWidget(
ValueKey key,
DatabaseCellContext cellContext,
CellControllerBuilder cellControllerBuilder,
GridCellStyle? style,
) {
switch (cellContext.fieldType) {
case FieldType.RichText:
style as GridTextCellStyle?;
return MobileTextCell(
cellControllerBuilder: cellControllerBuilder,
style: style,
);
case FieldType.Number:
style as GridNumberCellStyle?;
return MobileNumberCell(
cellControllerBuilder: cellControllerBuilder,
hintText: style?.placeholder,
);
case FieldType.LastEditedTime:
case FieldType.CreatedTime:
return MobileTimestampCell(
cellControllerBuilder: cellControllerBuilder,
key: key,
);
case FieldType.Checkbox:
return MobileCheckboxCell(
cellControllerBuilder: cellControllerBuilder,
key: key,
);
case FieldType.DateTime:
style as DateCellStyle?;
return GridDateCell(
cellControllerBuilder: cellControllerBuilder,
style: style,
);
case FieldType.URL:
style as GridURLCellStyle?;
return MobileURLCell(
cellControllerBuilder: cellControllerBuilder,
hintText: style?.placeholder,
key: key,
);
case FieldType.SingleSelect:
return GridSingleSelectCell(
cellControllerBuilder: cellControllerBuilder,
style: style,
key: key,
);
case FieldType.MultiSelect:
return GridMultiSelectCell(
cellControllerBuilder: cellControllerBuilder,
style: style,
key: key,
);
case FieldType.Checklist:
return MobileChecklistCell(
cellControllerBuilder: cellControllerBuilder,
style: style,
key: key,
);
}
throw UnimplementedError;
}
}
class MobileRowDetailPageCellBuilder {
final CellMemCache cellCache;
MobileRowDetailPageCellBuilder({
required this.cellCache,
});
GridCellWidget build(
DatabaseCellContext cellContext, {
GridCellStyle? style,
}) {
final cellControllerBuilder = CellControllerBuilder(
cellContext: cellContext,
cellCache: cellCache,
);
final key = cellContext.key();
switch (cellContext.fieldType) {
case FieldType.RichText:
style as GridTextCellStyle?;
return RowDetailTextCell(
cellControllerBuilder: cellControllerBuilder,
style: style,
);
case FieldType.Number:
style as GridNumberCellStyle?;
return RowDetailNumberCell(
cellControllerBuilder: cellControllerBuilder,
hintText: style?.placeholder,
);
case FieldType.LastEditedTime:
case FieldType.CreatedTime:
return GridTimestampCell(
cellControllerBuilder: cellControllerBuilder,
fieldType: cellContext.fieldType,
style: style,
key: key,
);
case FieldType.Checkbox:
return RowDetailCheckboxCell(
cellControllerBuilder: cellControllerBuilder,
key: key,
);
case FieldType.DateTime:
style as DateCellStyle?;
return GridDateCell(
cellControllerBuilder: cellControllerBuilder,
style: style,
);
case FieldType.URL:
style as GridURLCellStyle?;
return RowDetailURLCell(
cellControllerBuilder: cellControllerBuilder,
hintText: style?.placeholder,
key: key,
);
case FieldType.SingleSelect:
return GridSingleSelectCell(
cellControllerBuilder: cellControllerBuilder,
style: style,
key: key,
);
case FieldType.MultiSelect:
return GridMultiSelectCell(
cellControllerBuilder: cellControllerBuilder,
style: style,
key: key,
);
case FieldType.Checklist:
return MobileChecklistCell(
cellControllerBuilder: cellControllerBuilder,
style: style,
key: key,
);
}
throw UnimplementedError;
}
}
class BlankCell extends StatelessWidget {
const BlankCell({super.key});
@override
Widget build(BuildContext context) {
return const SizedBox.shrink();
}
}
abstract class CellEditable {
RequestFocusListener get requestFocus;
CellContainerNotifier get cellContainerNotifier;
// ValueNotifier<bool> get onCellEditing;
}
typedef AccessoryBuilder = List<GridCellAccessoryBuilder> Function(
GridCellAccessoryBuildContext buildContext,
);
abstract class CellAccessory extends Widget {
const CellAccessory({super.key});
// The hover will show if the isHover's value is true
ValueNotifier<bool>? get onAccessoryHover;
AccessoryBuilder? get accessoryBuilder;
}
abstract class GridCellWidget extends StatefulWidget
implements CellAccessory, CellEditable, CellShortcuts {
GridCellWidget({super.key});
@override
final CellContainerNotifier cellContainerNotifier = CellContainerNotifier();
// When the cell is focused, we assume that the accessory also be hovered.
@override
ValueNotifier<bool> get onAccessoryHover => ValueNotifier(false);
// @override
// final ValueNotifier<bool> onCellEditing = ValueNotifier<bool>(false);
@override
List<GridCellAccessoryBuilder> Function(
GridCellAccessoryBuildContext buildContext,
)? get accessoryBuilder => null;
@override
final RequestFocusListener requestFocus = RequestFocusListener();
@override
final Map<CellKeyboardKey, CellKeyboardAction> shortcutHandlers = {};
}
abstract class GridCellState<T extends GridCellWidget> extends State<T> {
@override
void initState() {
super.initState();
widget.requestFocus.setListener(requestBeginFocus);
}
@override
void didUpdateWidget(covariant T oldWidget) {
if (oldWidget != this) {
widget.requestFocus.setListener(requestBeginFocus);
}
super.didUpdateWidget(oldWidget);
}
@override
void dispose() {
widget.onAccessoryHover.dispose();
widget.requestFocus.removeAllListener();
widget.requestFocus.dispose();
super.dispose();
}
/// Subclass can override this method to request focus.
void requestBeginFocus();
String? onCopy() => null;
}
abstract class GridEditableTextCell<T extends GridCellWidget>
extends GridCellState<T> {
SingleListenerFocusNode get focusNode;
@override
void initState() {
super.initState();
widget.shortcutHandlers[CellKeyboardKey.onEnter] =
() => focusNode.unfocus();
_listenOnFocusNodeChanged();
}
@override
void dispose() {
widget.shortcutHandlers.clear();
focusNode.removeAllListener();
focusNode.dispose();
super.dispose();
}
@override
void requestBeginFocus() {
if (!focusNode.hasFocus && focusNode.canRequestFocus) {
FocusScope.of(context).requestFocus(focusNode);
}
}
void _listenOnFocusNodeChanged() {
widget.cellContainerNotifier.isFocus = focusNode.hasFocus;
focusNode.setListener(() {
widget.cellContainerNotifier.isFocus = focusNode.hasFocus;
focusChanged();
});
}
Future<void> focusChanged() async {}
}
class RequestFocusListener extends ChangeNotifier {
VoidCallback? _listener;
void setListener(VoidCallback listener) {
if (_listener != null) {
removeListener(_listener!);
}
_listener = listener;
addListener(listener);
}
void removeAllListener() {
if (_listener != null) {
removeListener(_listener!);
_listener = null;
}
}
void notify() {
notifyListeners();
}
}
abstract class GridCellStyle {
const GridCellStyle();
}
class SingleListenerFocusNode extends FocusNode {
VoidCallback? _listener;
void setListener(VoidCallback listener) {
if (_listener != null) {
removeListener(_listener!);
}
_listener = listener;
super.addListener(listener);
}
void removeAllListener() {
if (_listener != null) {
removeListener(_listener!);
}
}
}

View File

@ -0,0 +1,155 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import '../../../grid/presentation/layout/sizes.dart';
import '../../../grid/presentation/widgets/row/row.dart';
import '../accessory/cell_accessory.dart';
import '../accessory/cell_shortcuts.dart';
import '../cell_builder.dart';
class CellContainer extends StatelessWidget {
final GridCellWidget child;
final AccessoryBuilder? accessoryBuilder;
final double width;
final bool isPrimary;
const CellContainer({
super.key,
required this.child,
required this.width,
required this.isPrimary,
this.accessoryBuilder,
});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider.value(
value: child.cellContainerNotifier,
child: Selector<CellContainerNotifier, bool>(
selector: (context, notifier) => notifier.isFocus,
builder: (providerContext, isFocus, _) {
Widget container = Center(child: GridCellShortcuts(child: child));
if (accessoryBuilder != null) {
final accessories = accessoryBuilder!.call(
GridCellAccessoryBuildContext(
anchorContext: context,
isCellEditing: isFocus,
),
);
if (accessories.isNotEmpty) {
container = _GridCellEnterRegion(
accessories: accessories,
isPrimary: isPrimary,
child: container,
);
}
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (!isFocus) {
child.requestFocus.notify();
}
},
child: Container(
constraints: BoxConstraints(maxWidth: width, minHeight: 46),
decoration: _makeBoxDecoration(context, isFocus),
child: container,
),
);
},
),
);
}
BoxDecoration _makeBoxDecoration(BuildContext context, bool isFocus) {
if (isFocus) {
final borderSide = BorderSide(
color: Theme.of(context).colorScheme.primary,
);
return BoxDecoration(border: Border.fromBorderSide(borderSide));
}
final borderSide = BorderSide(color: Theme.of(context).dividerColor);
return BoxDecoration(
border: Border(right: borderSide, bottom: borderSide),
);
}
}
class _GridCellEnterRegion extends StatelessWidget {
const _GridCellEnterRegion({
required this.child,
required this.accessories,
required this.isPrimary,
});
final Widget child;
final List<GridCellAccessoryBuilder> accessories;
final bool isPrimary;
@override
Widget build(BuildContext context) {
return Selector2<RegionStateNotifier, CellContainerNotifier, bool>(
selector: (context, regionNotifier, cellNotifier) =>
!cellNotifier.isFocus &&
(cellNotifier.onEnter || regionNotifier.onEnter && isPrimary),
builder: (context, showAccessory, _) {
final List<Widget> children = [child];
if (showAccessory) {
children.add(
CellAccessoryContainer(accessories: accessories).positioned(
right: GridSize.cellContentInsets.right,
),
);
}
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (p) =>
CellContainerNotifier.of(context, listen: false).onEnter = true,
onExit: (p) =>
CellContainerNotifier.of(context, listen: false).onEnter = false,
child: Stack(
alignment: AlignmentDirectional.center,
fit: StackFit.expand,
children: children,
),
);
},
);
}
}
class CellContainerNotifier extends ChangeNotifier {
bool _isFocus = false;
bool _onEnter = false;
set isFocus(bool value) {
if (_isFocus != value) {
_isFocus = value;
notifyListeners();
}
}
set onEnter(bool value) {
if (_onEnter != value) {
_onEnter = value;
notifyListeners();
}
}
bool get isFocus => _isFocus;
bool get onEnter => _onEnter;
static CellContainerNotifier of(BuildContext context, {bool listen = true}) {
return Provider.of<CellContainerNotifier>(context, listen: listen);
}
}

View File

@ -0,0 +1,8 @@
export 'checkbox_cell/checkbox_cell.dart';
export 'checklist_cell/checklist_cell.dart';
export 'date_cell/date_cell.dart';
export 'number_cell/number_cell.dart';
export 'select_option_cell/select_option_cell.dart';
export 'text_cell/text_cell.dart';
export 'timestamp_cell/timestamp_cell.dart';
export 'url_cell/url_cell.dart';

View File

@ -0,0 +1,124 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/widgets/row/cell_builder.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'checkbox_cell_bloc.dart';
class GridCheckboxCellStyle extends GridCellStyle {
EdgeInsets? cellPadding;
GridCheckboxCellStyle({
this.cellPadding,
});
}
class GridCheckboxCell extends GridCellWidget {
final CellControllerBuilder cellControllerBuilder;
late final GridCheckboxCellStyle cellStyle;
GridCheckboxCell({
required this.cellControllerBuilder,
GridCellStyle? style,
super.key,
}) {
if (style != null) {
cellStyle = (style as GridCheckboxCellStyle);
} else {
cellStyle = GridCheckboxCellStyle();
}
}
@override
GridCellState<GridCheckboxCell> createState() => _CheckboxCellState();
}
class _CheckboxCellState extends GridCellState<GridCheckboxCell> {
late CheckboxCellBloc _cellBloc;
@override
void initState() {
final cellController =
widget.cellControllerBuilder.build() as CheckboxCellController;
_cellBloc = CheckboxCellBloc(cellController: cellController)
..add(const CheckboxCellEvent.initial());
super.initState();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cellBloc,
child: BlocBuilder<CheckboxCellBloc, CheckboxCellState>(
builder: (context, state) {
final icon = state.isSelected
? const CheckboxCellCheck()
: const CheckboxCellUncheck();
return Align(
alignment: Alignment.centerLeft,
child: Padding(
padding:
widget.cellStyle.cellPadding ?? GridSize.cellContentInsets,
child: FlowyIconButton(
hoverColor: Colors.transparent,
onPressed: () => context
.read<CheckboxCellBloc>()
.add(const CheckboxCellEvent.select()),
iconPadding: EdgeInsets.zero,
icon: icon,
width: 20,
),
),
);
},
),
);
}
@override
Future<void> dispose() async {
_cellBloc.close();
super.dispose();
}
@override
void requestBeginFocus() {
_cellBloc.add(const CheckboxCellEvent.select());
}
@override
String? onCopy() {
if (_cellBloc.state.isSelected) {
return "Yes";
} else {
return "No";
}
}
}
class CheckboxCellCheck extends StatelessWidget {
const CheckboxCellCheck({super.key});
@override
Widget build(BuildContext context) {
return const FlowySvg(
FlowySvgs.check_filled_s,
blendMode: BlendMode.dst,
);
}
}
class CheckboxCellUncheck extends StatelessWidget {
const CheckboxCellUncheck({super.key});
@override
Widget build(BuildContext context) {
return const FlowySvg(
FlowySvgs.uncheck_s,
blendMode: BlendMode.dst,
);
}
}

View File

@ -0,0 +1,76 @@
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'dart:async';
part 'checkbox_cell_bloc.freezed.dart';
class CheckboxCellBloc extends Bloc<CheckboxCellEvent, CheckboxCellState> {
final CheckboxCellController cellController;
void Function()? _onCellChangedFn;
CheckboxCellBloc({
required this.cellController,
}) : super(CheckboxCellState.initial(cellController)) {
on<CheckboxCellEvent>(
(event, emit) async {
await event.when(
initial: () {
_startListening();
},
didReceiveCellUpdate: (cellData) {
emit(state.copyWith(isSelected: _isSelected(cellData)));
},
select: () async {
cellController.saveCellData(!state.isSelected ? "Yes" : "No");
},
);
},
);
}
@override
Future<void> close() async {
if (_onCellChangedFn != null) {
cellController.removeListener(_onCellChangedFn!);
_onCellChangedFn = null;
}
await cellController.dispose();
return super.close();
}
void _startListening() {
_onCellChangedFn = cellController.startListening(
onCellChanged: ((cellData) {
if (!isClosed) {
add(CheckboxCellEvent.didReceiveCellUpdate(cellData));
}
}),
);
}
}
@freezed
class CheckboxCellEvent with _$CheckboxCellEvent {
const factory CheckboxCellEvent.initial() = _Initial;
const factory CheckboxCellEvent.select() = _Selected;
const factory CheckboxCellEvent.didReceiveCellUpdate(String? cellData) =
_DidReceiveCellUpdate;
}
@freezed
class CheckboxCellState with _$CheckboxCellState {
const factory CheckboxCellState({
required bool isSelected,
}) = _CheckboxCellState;
factory CheckboxCellState.initial(TextCellController context) {
return CheckboxCellState(isSelected: _isSelected(context.getCellData()));
}
}
bool _isSelected(String? cellData) {
// The backend use "Yes" and "No" to represent the checkbox cell data.
return cellData == "Yes";
}

View File

@ -0,0 +1,250 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy_popover/appflowy_popover.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/widget/flowy_tooltip.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../cell_builder.dart';
import 'checklist_cell_bloc.dart';
import 'checklist_cell_editor.dart';
import 'checklist_progress_bar.dart';
class ChecklistCellStyle extends GridCellStyle {
final String placeholder;
final EdgeInsets? cellPadding;
final bool showTasksInline;
final bool useRoundedBorders;
const ChecklistCellStyle({
this.placeholder = "",
this.cellPadding,
this.showTasksInline = false,
this.useRoundedBorders = false,
});
}
class GridChecklistCell extends GridCellWidget {
final CellControllerBuilder cellControllerBuilder;
late final ChecklistCellStyle cellStyle;
GridChecklistCell({
required this.cellControllerBuilder,
GridCellStyle? style,
super.key,
}) {
if (style != null) {
cellStyle = (style as ChecklistCellStyle);
} else {
cellStyle = const ChecklistCellStyle();
}
}
@override
GridCellState<GridChecklistCell> createState() => GridChecklistCellState();
}
class GridChecklistCellState extends GridCellState<GridChecklistCell> {
late ChecklistCellBloc _cellBloc;
late final PopoverController _popover;
bool showIncompleteOnly = false;
@override
void initState() {
_popover = PopoverController();
final cellController =
widget.cellControllerBuilder.build() as ChecklistCellController;
_cellBloc = ChecklistCellBloc(cellController: cellController)
..add(const ChecklistCellEvent.initial());
super.initState();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cellBloc,
child: BlocBuilder<ChecklistCellBloc, ChecklistCellState>(
builder: (context, state) {
if (widget.cellStyle.showTasksInline) {
final tasks = List.from(state.tasks);
if (showIncompleteOnly) {
tasks.removeWhere((task) => task.isSelected);
}
final children = tasks
.mapIndexed(
(index, task) => ChecklistItem(
task: task,
autofocus: state.newTask && index == tasks.length - 1,
onSubmitted: () {
if (index == tasks.length - 1) {
context
.read<ChecklistCellBloc>()
.add(const ChecklistCellEvent.createNewTask(""));
}
},
),
)
.toList();
return Align(
alignment: Alignment.centerLeft,
child: Padding(
padding:
widget.cellStyle.cellPadding ?? GridSize.cellContentInsets,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: ChecklistProgressBar(
tasks: state.tasks,
percent: state.percent,
),
),
const HSpace(6.0),
FlowyIconButton(
tooltipText: showIncompleteOnly
? LocaleKeys.grid_checklist_showComplete.tr()
: LocaleKeys.grid_checklist_hideComplete.tr(),
width: 32,
iconColorOnHover:
Theme.of(context).colorScheme.onSurface,
icon: FlowySvg(
showIncompleteOnly
? FlowySvgs.show_m
: FlowySvgs.hide_m,
size: const Size.square(16),
),
onPressed: () {
setState(
() => showIncompleteOnly = !showIncompleteOnly,
);
},
),
],
),
),
const VSpace(4),
...children,
const ChecklistItemControl(),
],
),
),
);
}
return AppFlowyPopover(
margin: EdgeInsets.zero,
controller: _popover,
constraints: BoxConstraints.loose(const Size(360, 400)),
direction: PopoverDirection.bottomWithLeftAligned,
triggerActions: PopoverTriggerFlags.none,
popupBuilder: (BuildContext popoverContext) {
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.cellContainerNotifier.isFocus = true;
});
return GridChecklistCellEditor(
cellController: widget.cellControllerBuilder.build()
as ChecklistCellController,
);
},
onClose: () => widget.cellContainerNotifier.isFocus = false,
child: Align(
alignment: Alignment.centerLeft,
child: Padding(
padding:
widget.cellStyle.cellPadding ?? GridSize.cellContentInsets,
child: state.tasks.isEmpty
? FlowyText.medium(
widget.cellStyle.placeholder,
color: Theme.of(context).hintColor,
)
: ChecklistProgressBar(
tasks: state.tasks,
percent: state.percent,
),
),
),
);
},
),
);
}
@override
void requestBeginFocus() {
if (!widget.cellStyle.showTasksInline) {
_popover.show();
}
}
}
class ChecklistItemControl extends StatefulWidget {
const ChecklistItemControl({super.key});
@override
State<ChecklistItemControl> createState() => _ChecklistItemControlState();
}
class _ChecklistItemControlState extends State<ChecklistItemControl> {
bool _isHover = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onHover: (_) => setState(() => _isHover = true),
onExit: (_) => setState(() => _isHover = false),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => context
.read<ChecklistCellBloc>()
.add(const ChecklistCellEvent.createNewTask("")),
child: Padding(
padding: const EdgeInsets.fromLTRB(8.0, 4.0, 8.0, 0),
child: SizedBox(
height: 12,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
child: _isHover
? FlowyTooltip(
message: LocaleKeys.grid_checklist_addNew.tr(),
child: Row(
children: [
const Flexible(child: Center(child: Divider())),
const HSpace(12.0),
FilledButton(
style: FilledButton.styleFrom(
minimumSize: const Size.square(12),
maximumSize: const Size.square(12),
padding: EdgeInsets.zero,
),
onPressed: () => context
.read<ChecklistCellBloc>()
.add(
const ChecklistCellEvent.createNewTask(""),
),
child: FlowySvg(
FlowySvgs.add_s,
color: Theme.of(context).colorScheme.onPrimary,
),
),
const HSpace(12.0),
const Flexible(child: Center(child: Divider())),
],
),
)
: const SizedBox.expand(),
),
),
),
),
);
}
}

View File

@ -0,0 +1,166 @@
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/application/cell/checklist_cell_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/checklist_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option.pb.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'dart:async';
part 'checklist_cell_bloc.freezed.dart';
class ChecklistSelectOption {
final bool isSelected;
final SelectOptionPB data;
ChecklistSelectOption(this.isSelected, this.data);
}
class ChecklistCellBloc extends Bloc<ChecklistCellEvent, ChecklistCellState> {
final ChecklistCellController cellController;
final ChecklistCellBackendService _checklistCellService;
void Function()? _onCellChangedFn;
ChecklistCellBloc({
required this.cellController,
}) : _checklistCellService = ChecklistCellBackendService(
viewId: cellController.viewId,
fieldId: cellController.fieldId,
rowId: cellController.rowId,
),
super(ChecklistCellState.initial(cellController)) {
on<ChecklistCellEvent>(
(event, emit) async {
await event.when(
initial: () {
_startListening();
},
didReceiveOptions: (data) {
if (data == null) {
emit(
const ChecklistCellState(
tasks: [],
percent: 0,
newTask: false,
),
);
return;
}
emit(
state.copyWith(
tasks: _makeChecklistSelectOptions(data),
percent: data.percentage,
),
);
},
updateTaskName: (option, name) {
_updateOption(option, name);
},
selectTask: (option) async {
await _checklistCellService.select(optionId: option.id);
},
createNewTask: (name) async {
final result = await _checklistCellService.create(name: name);
result.fold(
(l) => emit(state.copyWith(newTask: true)),
(err) => Log.error(err),
);
},
deleteTask: (option) async {
await _deleteOption([option]);
},
);
},
);
}
@override
Future<void> close() async {
if (_onCellChangedFn != null) {
cellController.removeListener(_onCellChangedFn!);
_onCellChangedFn = null;
}
await cellController.dispose();
return super.close();
}
void _startListening() {
_onCellChangedFn = cellController.startListening(
onCellChanged: (data) {
if (!isClosed) {
add(ChecklistCellEvent.didReceiveOptions(data));
}
},
);
}
void _updateOption(SelectOptionPB option, String name) async {
final result =
await _checklistCellService.updateName(option: option, name: name);
result.fold((l) => null, (err) => Log.error(err));
}
Future<void> _deleteOption(List<SelectOptionPB> options) async {
final result = await _checklistCellService.delete(
optionIds: options.map((e) => e.id).toList(),
);
result.fold((l) => null, (err) => Log.error(err));
}
}
@freezed
class ChecklistCellEvent with _$ChecklistCellEvent {
const factory ChecklistCellEvent.initial() = _InitialCell;
const factory ChecklistCellEvent.didReceiveOptions(
ChecklistCellDataPB? data,
) = _DidReceiveCellUpdate;
const factory ChecklistCellEvent.updateTaskName(
SelectOptionPB option,
String name,
) = _UpdateTaskName;
const factory ChecklistCellEvent.selectTask(SelectOptionPB task) =
_SelectTask;
const factory ChecklistCellEvent.createNewTask(String description) =
_CreateNewTask;
const factory ChecklistCellEvent.deleteTask(SelectOptionPB option) =
_DeleteTask;
}
@freezed
class ChecklistCellState with _$ChecklistCellState {
const factory ChecklistCellState({
required List<ChecklistSelectOption> tasks,
required double percent,
required bool newTask,
}) = _ChecklistCellState;
factory ChecklistCellState.initial(ChecklistCellController cellController) {
final cellData = cellController.getCellData(loadIfNotExist: true);
return ChecklistCellState(
tasks: _makeChecklistSelectOptions(cellData),
percent: cellData?.percentage ?? 0,
newTask: false,
);
}
}
List<ChecklistSelectOption> _makeChecklistSelectOptions(
ChecklistCellDataPB? data,
) {
if (data == null) {
return [];
}
final List<ChecklistSelectOption> options = [];
final List<SelectOptionPB> allOptions = List.from(data.options);
final selectedOptionIds = data.selectedOptions.map((e) => e.id).toList();
for (final option in allOptions) {
options.add(
ChecklistSelectOption(selectedOptionIds.contains(option.id), option),
);
}
return options;
}

View File

@ -0,0 +1,374 @@
import 'dart:async';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.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:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'checklist_cell_bloc.dart';
import 'checklist_progress_bar.dart';
class GridChecklistCellEditor extends StatefulWidget {
final ChecklistCellController cellController;
const GridChecklistCellEditor({required this.cellController, super.key});
@override
State<GridChecklistCellEditor> createState() => _GridChecklistCellState();
}
class _GridChecklistCellState extends State<GridChecklistCellEditor> {
late ChecklistCellBloc _bloc;
/// Focus node for the new task text field
late final FocusNode newTaskFocusNode;
@override
void initState() {
super.initState();
newTaskFocusNode = FocusNode(
onKey: (node, event) {
if (event is RawKeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.escape) {
node.unfocus();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
);
_bloc = ChecklistCellBloc(cellController: widget.cellController)
..add(const ChecklistCellEvent.initial());
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _bloc,
child: BlocConsumer<ChecklistCellBloc, ChecklistCellState>(
listener: (context, state) {
if (state.tasks.isEmpty) {
newTaskFocusNode.requestFocus();
}
},
builder: (context, state) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: state.tasks.isEmpty
? const SizedBox.shrink()
: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: ChecklistProgressBar(
tasks: state.tasks,
percent: state.percent,
),
),
),
ChecklistItemList(
options: state.tasks,
onUpdateTask: () => newTaskFocusNode.requestFocus(),
),
if (state.tasks.isNotEmpty)
const TypeOptionSeparator(spacing: 0.0),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: NewTaskItem(focusNode: newTaskFocusNode),
),
],
);
},
),
);
}
@override
void dispose() {
_bloc.close();
super.dispose();
}
}
/// Displays the a list of all the exisiting tasks and an input field to create
/// a new task if `isAddingNewTask` is true
class ChecklistItemList extends StatefulWidget {
final List<ChecklistSelectOption> options;
final VoidCallback onUpdateTask;
const ChecklistItemList({
super.key,
required this.options,
required this.onUpdateTask,
});
@override
State<ChecklistItemList> createState() => _ChecklistItemListState();
}
class _ChecklistItemListState extends State<ChecklistItemList> {
@override
Widget build(BuildContext context) {
if (widget.options.isEmpty) {
return const SizedBox.shrink();
}
final itemList = widget.options
.mapIndexed(
(index, option) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: ChecklistItem(
task: option,
onSubmitted: index == widget.options.length - 1
? widget.onUpdateTask
: null,
key: ValueKey(option.data.id),
),
),
)
.toList();
return Flexible(
child: ListView.separated(
itemBuilder: (context, index) => itemList[index],
separatorBuilder: (context, index) => const VSpace(4),
itemCount: itemList.length,
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 8.0),
),
);
}
}
/// Represents an existing task
@visibleForTesting
class ChecklistItem extends StatefulWidget {
final ChecklistSelectOption task;
final VoidCallback? onSubmitted;
final bool autofocus;
const ChecklistItem({
super.key,
required this.task,
this.onSubmitted,
this.autofocus = false,
});
@override
State<ChecklistItem> createState() => _ChecklistItemState();
}
class _ChecklistItemState extends State<ChecklistItem> {
late final TextEditingController _textController;
late final FocusNode _focusNode;
bool _isHovered = false;
Timer? _debounceOnChanged;
@override
void initState() {
super.initState();
_textController = TextEditingController(text: widget.task.data.name);
_focusNode = FocusNode(
onKey: (node, event) {
if (event is RawKeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.escape) {
node.unfocus();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
);
if (widget.autofocus) {
_focusNode.requestFocus();
}
}
@override
void didUpdateWidget(ChecklistItem oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.task.data.name != oldWidget.task.data.name &&
!_focusNode.hasFocus) {
_textController.text = widget.task.data.name;
}
}
@override
Widget build(BuildContext context) {
final icon = FlowySvg(
widget.task.isSelected ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s,
blendMode: BlendMode.dst,
);
return MouseRegion(
onEnter: (event) => setState(() => _isHovered = true),
onExit: (event) => setState(() => _isHovered = false),
child: Container(
constraints: BoxConstraints(minHeight: GridSize.popoverItemHeight),
decoration: BoxDecoration(
color: _isHovered
? AFThemeExtension.of(context).lightGreyHover
: Colors.transparent,
borderRadius: Corners.s6Border,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
FlowyIconButton(
width: 32,
icon: icon,
hoverColor: Colors.transparent,
onPressed: () => context.read<ChecklistCellBloc>().add(
ChecklistCellEvent.selectTask(widget.task.data),
),
),
Expanded(
child: TextField(
controller: _textController,
focusNode: _focusNode,
style: Theme.of(context).textTheme.bodyMedium,
maxLines: 1,
decoration: InputDecoration(
border: InputBorder.none,
isCollapsed: true,
contentPadding: EdgeInsets.only(
top: 8.0,
bottom: 8.0,
left: 2.0,
right: _isHovered ? 2.0 : 8.0,
),
hintText: LocaleKeys.grid_checklist_taskHint.tr(),
),
onChanged: _debounceOnChangedText,
onSubmitted: (description) {
_submitUpdateTaskDescription(description);
widget.onSubmitted?.call();
},
),
),
if (_isHovered)
FlowyIconButton(
width: 32,
icon: const FlowySvg(FlowySvgs.delete_s),
hoverColor: Colors.transparent,
iconColorOnHover: Theme.of(context).colorScheme.error,
onPressed: () => context.read<ChecklistCellBloc>().add(
ChecklistCellEvent.deleteTask(widget.task.data),
),
),
],
),
),
);
}
void _debounceOnChangedText(String text) {
_debounceOnChanged?.cancel();
_debounceOnChanged = Timer(const Duration(milliseconds: 300), () {
_submitUpdateTaskDescription(text);
});
}
void _submitUpdateTaskDescription(String description) {
context.read<ChecklistCellBloc>().add(
ChecklistCellEvent.updateTaskName(
widget.task.data,
description.trim(),
),
);
}
}
/// Creates a new task after entering the description and pressing enter.
/// This can be cancelled by pressing escape
@visibleForTesting
class NewTaskItem extends StatefulWidget {
final FocusNode focusNode;
const NewTaskItem({super.key, required this.focusNode});
@override
State<NewTaskItem> createState() => _NewTaskItemState();
}
class _NewTaskItemState extends State<NewTaskItem> {
late final TextEditingController _textEditingController;
@override
void initState() {
super.initState();
_textEditingController = TextEditingController();
if (widget.focusNode.canRequestFocus) {
widget.focusNode.requestFocus();
}
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8),
constraints: BoxConstraints(minHeight: GridSize.popoverItemHeight),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const HSpace(8),
Expanded(
child: TextField(
focusNode: widget.focusNode,
controller: _textEditingController,
style: Theme.of(context).textTheme.bodyMedium,
maxLines: 1,
decoration: InputDecoration(
border: InputBorder.none,
isCollapsed: true,
contentPadding: const EdgeInsets.symmetric(
vertical: 6.0,
horizontal: 2.0,
),
hintText: LocaleKeys.grid_checklist_addNew.tr(),
),
onSubmitted: (taskDescription) {
if (taskDescription.trim().isNotEmpty) {
context.read<ChecklistCellBloc>().add(
ChecklistCellEvent.createNewTask(
taskDescription.trim(),
),
);
}
widget.focusNode.requestFocus();
_textEditingController.clear();
},
onChanged: (value) => setState(() {}),
),
),
FlowyTextButton(
LocaleKeys.grid_checklist_submitNewTask.tr(),
fontSize: 11,
fillColor: _textEditingController.text.isEmpty
? Theme.of(context).disabledColor
: Theme.of(context).colorScheme.primary,
hoverColor: _textEditingController.text.isEmpty
? Theme.of(context).disabledColor
: Theme.of(context).colorScheme.primaryContainer,
fontColor: Theme.of(context).colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
onPressed: () {
final text = _textEditingController.text.trim();
if (text.isNotEmpty) {
context.read<ChecklistCellBloc>().add(
ChecklistCellEvent.createNewTask(text),
);
}
widget.focusNode.requestFocus();
_textEditingController.clear();
},
),
],
),
);
}
}

View File

@ -0,0 +1,87 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:percent_indicator/percent_indicator.dart';
import 'checklist_cell_bloc.dart';
class ChecklistProgressBar extends StatefulWidget {
final List<ChecklistSelectOption> tasks;
final double percent;
final int segmentLimit = 5;
final double fontSize;
const ChecklistProgressBar({
super.key,
required this.tasks,
required this.percent,
this.fontSize = 11,
});
@override
State<ChecklistProgressBar> createState() => _ChecklistProgressBarState();
}
class _ChecklistProgressBarState extends State<ChecklistProgressBar> {
@override
Widget build(BuildContext context) {
final numFinishedTasks = widget.tasks.where((e) => e.isSelected).length;
final completedTaskColor = numFinishedTasks == widget.tasks.length
? AFThemeExtension.of(context).success
: Theme.of(context).colorScheme.primary;
return Row(
children: [
Expanded(
child: Row(
children: [
if (widget.tasks.isNotEmpty &&
widget.tasks.length <= widget.segmentLimit)
...List<Widget>.generate(
widget.tasks.length,
(index) => Flexible(
child: Container(
decoration: BoxDecoration(
borderRadius:
const BorderRadius.all(Radius.circular(2)),
color: index < numFinishedTasks
? completedTaskColor
: AFThemeExtension.of(context).progressBarBGColor,
),
margin: const EdgeInsets.symmetric(horizontal: 1),
height: 4.0,
),
),
)
else
Expanded(
child: LinearPercentIndicator(
lineHeight: 4.0,
percent: widget.percent,
padding: EdgeInsets.zero,
progressColor: completedTaskColor,
backgroundColor:
AFThemeExtension.of(context).progressBarBGColor,
barRadius: const Radius.circular(2),
),
),
],
),
),
SizedBox(
width: PlatformExtension.isDesktop ? 36 : 45,
child: Align(
alignment: AlignmentDirectional.centerEnd,
child: FlowyText.regular(
"${(widget.percent * 100).round()}%",
fontSize: widget.fontSize,
color: PlatformExtension.isDesktop
? Theme.of(context).hintColor
: null,
),
),
),
],
);
}
}

View File

@ -0,0 +1,317 @@
import 'dart:async';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart';
import 'package:appflowy/plugins/base/drag_handler.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/checklist_cell/checklist_cell_bloc.dart';
import 'package:collection/collection.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 'package:go_router/go_router.dart';
class MobileChecklistCellEditScreen extends StatefulWidget {
const MobileChecklistCellEditScreen({
super.key,
required this.cellController,
});
final ChecklistCellController cellController;
@override
State<MobileChecklistCellEditScreen> createState() =>
_MobileChecklistCellEditScreenState();
}
class _MobileChecklistCellEditScreenState
extends State<MobileChecklistCellEditScreen> {
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: const BoxConstraints.tightFor(height: 420),
child: BlocProvider(
create: (context) => ChecklistCellBloc(
cellController: widget.cellController,
)..add(const ChecklistCellEvent.initial()),
child: BlocBuilder<ChecklistCellBloc, ChecklistCellState>(
builder: (context, state) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const DragHandler(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: _buildHeader(context),
),
const Divider(),
const Expanded(child: _TaskList()),
],
);
},
),
),
);
}
Widget _buildHeader(BuildContext context) {
const iconWidth = 36.0;
const height = 44.0;
return Stack(
children: [
Align(
alignment: Alignment.centerLeft,
child: FlowyIconButton(
icon: const FlowySvg(
FlowySvgs.close_s,
size: Size.square(iconWidth),
),
width: iconWidth,
iconPadding: EdgeInsets.zero,
onPressed: () => context.pop(),
),
),
SizedBox(
height: 44.0,
child: Align(
alignment: Alignment.center,
child: FlowyText.medium(
LocaleKeys.grid_field_checklistFieldName.tr(),
fontSize: 18,
),
),
),
].map((e) => SizedBox(height: height, child: e)).toList(),
);
}
}
class _TaskList extends StatelessWidget {
const _TaskList();
@override
Widget build(BuildContext context) {
return BlocBuilder<ChecklistCellBloc, ChecklistCellState>(
builder: (context, state) {
final cells = <Widget>[];
cells.addAll(
state.tasks
.mapIndexed(
(index, task) => _ChecklistItem(
task: task,
autofocus: state.newTask && index == state.tasks.length - 1,
),
)
.toList(),
);
cells.add(const _NewTaskButton());
return ListView.separated(
shrinkWrap: true,
itemCount: cells.length,
separatorBuilder: (_, __) => const VSpace(8),
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (_, int index) => cells[index],
padding: const EdgeInsets.only(bottom: 12.0),
);
},
);
}
}
class _ChecklistItem extends StatefulWidget {
const _ChecklistItem({required this.task, required this.autofocus});
final ChecklistSelectOption task;
final bool autofocus;
@override
State<_ChecklistItem> createState() => _ChecklistItemState();
}
class _ChecklistItemState extends State<_ChecklistItem> {
late final TextEditingController _textController;
final FocusNode _focusNode = FocusNode();
Timer? _debounceOnChanged;
@override
void initState() {
super.initState();
_textController = TextEditingController(text: widget.task.data.name);
if (widget.autofocus) {
_focusNode.requestFocus();
}
}
@override
void didUpdateWidget(covariant oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.task.data.name != oldWidget.task.data.name &&
!_focusNode.hasFocus) {
_textController.text = widget.task.data.name;
}
}
@override
void dispose() {
_textController.dispose();
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5),
height: 44,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
InkWell(
borderRadius: BorderRadius.circular(22),
onTap: () => context
.read<ChecklistCellBloc>()
.add(ChecklistCellEvent.selectTask(widget.task.data)),
child: SizedBox.square(
dimension: 44,
child: Center(
child: FlowySvg(
widget.task.isSelected
? FlowySvgs.check_filled_s
: FlowySvgs.uncheck_s,
size: const Size.square(20.0),
blendMode: BlendMode.dst,
),
),
),
),
Expanded(
child: TextField(
controller: _textController,
focusNode: _focusNode,
style: Theme.of(context).textTheme.bodyMedium,
maxLines: 1,
decoration: InputDecoration(
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
isCollapsed: true,
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
hintText: LocaleKeys.grid_checklist_taskHint.tr(),
),
onChanged: _debounceOnChangedText,
onSubmitted: (description) {
_submitUpdateTaskDescription(description);
},
),
),
InkWell(
borderRadius: BorderRadius.circular(22),
onTap: _showDeleteTaskBottomSheet,
child: SizedBox.square(
dimension: 44,
child: Center(
child: FlowySvg(
FlowySvgs.three_dots_s,
color: Theme.of(context).hintColor,
),
),
),
),
],
),
);
}
void _debounceOnChangedText(String text) {
_debounceOnChanged?.cancel();
_debounceOnChanged = Timer(const Duration(milliseconds: 300), () {
_submitUpdateTaskDescription(text);
});
}
void _submitUpdateTaskDescription(String description) {
context.read<ChecklistCellBloc>().add(
ChecklistCellEvent.updateTaskName(
widget.task.data,
description.trim(),
),
);
}
void _showDeleteTaskBottomSheet() {
showMobileBottomSheet(
context,
padding: const EdgeInsets.only(top: 8, bottom: 32),
builder: (_) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: InkWell(
onTap: () {
context.read<ChecklistCellBloc>().add(
ChecklistCellEvent.deleteTask(widget.task.data),
);
context.pop();
},
borderRadius: BorderRadius.circular(12),
child: Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
FlowySvg(
FlowySvgs.m_delete_m,
size: const Size.square(20),
color: Theme.of(context).colorScheme.error,
),
const HSpace(8),
FlowyText(
LocaleKeys.button_delete.tr(),
fontSize: 15,
color: Theme.of(context).colorScheme.error,
),
],
),
),
),
),
const Divider(height: 9),
],
),
);
}
}
class _NewTaskButton extends StatelessWidget {
const _NewTaskButton();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () {
context
.read<ChecklistCellBloc>()
.add(const ChecklistCellEvent.createNewTask(""));
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 13),
child: Row(
children: [
const FlowySvg(FlowySvgs.add_s, size: Size.square(20)),
const HSpace(11),
FlowyText(LocaleKeys.grid_checklist_addNew.tr(), fontSize: 15),
],
),
),
),
);
}
}

View File

@ -0,0 +1,193 @@
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../grid/presentation/layout/sizes.dart';
import '../../cell_builder.dart';
import 'date_cell_bloc.dart';
import 'date_editor.dart';
class DateCellStyle extends GridCellStyle {
String placeholder;
Alignment alignment;
EdgeInsets? cellPadding;
final bool useRoundedBorder;
DateCellStyle({
this.placeholder = "",
this.alignment = Alignment.centerLeft,
this.cellPadding,
this.useRoundedBorder = false,
});
}
class GridDateCell extends GridCellWidget {
final CellControllerBuilder cellControllerBuilder;
late final DateCellStyle cellStyle;
GridDateCell({
super.key,
GridCellStyle? style,
required this.cellControllerBuilder,
}) {
if (style != null) {
cellStyle = (style as DateCellStyle);
} else {
cellStyle = DateCellStyle();
}
}
@override
GridCellState<GridDateCell> createState() => _DateCellState();
}
class _DateCellState extends GridCellState<GridDateCell> {
final PopoverController _popover = PopoverController();
late final DateCellController _cellController;
late DateCellBloc _cellBloc;
@override
void initState() {
super.initState();
_cellController =
widget.cellControllerBuilder.build() as DateCellController;
_cellBloc = DateCellBloc(cellController: _cellController)
..add(const DateCellEvent.initial());
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cellBloc,
child: BlocBuilder<DateCellBloc, DateCellState>(
builder: (context, state) {
final text = state.dateStr.isEmpty
? widget.cellStyle.placeholder
: state.dateStr;
final color =
state.dateStr.isEmpty ? Theme.of(context).hintColor : null;
final padding =
widget.cellStyle.cellPadding ?? GridSize.cellContentInsets;
final alignment = widget.cellStyle.alignment;
if (PlatformExtension.isDesktopOrWeb) {
return AppFlowyPopover(
controller: _popover,
triggerActions: PopoverTriggerFlags.none,
direction: PopoverDirection.bottomWithLeftAligned,
constraints: BoxConstraints.loose(const Size(260, 620)),
margin: EdgeInsets.zero,
child: Container(
alignment: alignment,
padding: padding,
child: FlowyText.medium(
text,
color: color,
overflow: TextOverflow.ellipsis,
),
),
popupBuilder: (BuildContext popoverContent) {
return DateCellEditor(
cellController: _cellController,
onDismissed: () =>
widget.cellContainerNotifier.isFocus = false,
);
},
onClose: () {
widget.cellContainerNotifier.isFocus = false;
},
);
} else if (widget.cellStyle.useRoundedBorder) {
return InkWell(
borderRadius: const BorderRadius.all(Radius.circular(14)),
onTap: () => showMobileBottomSheet(
context,
padding: EdgeInsets.zero,
builder: (context) {
return MobileDateCellEditScreen(
controller: _cellController,
showAsFullScreen: false,
);
},
),
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: padding,
child: FlowyText.regular(
text,
fontSize: 16,
color: color,
maxLines: null,
),
),
);
} else {
return FlowyButton(
radius: BorderRadius.zero,
hoverColor: Colors.transparent,
margin: EdgeInsets.zero,
text: Align(
alignment: alignment,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: FlowyText(
text,
color: color,
fontSize: 15,
maxLines: 1,
),
),
),
onTap: () {
showMobileBottomSheet(
context,
padding: EdgeInsets.zero,
backgroundColor:
Theme.of(context).colorScheme.secondaryContainer,
builder: (context) {
return MobileDateCellEditScreen(
controller: _cellController,
showAsFullScreen: false,
);
},
);
},
);
}
},
),
);
}
@override
Future<void> dispose() async {
_cellBloc.close();
_cellController.dispose();
super.dispose();
}
@override
void requestBeginFocus() {
_popover.show();
widget.cellContainerNotifier.isFocus = true;
}
@override
String? onCopy() => _cellBloc.state.dateStr;
}

View File

@ -0,0 +1,101 @@
import 'dart:async';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/application/field/field_info.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'date_cell_bloc.freezed.dart';
class DateCellBloc extends Bloc<DateCellEvent, DateCellState> {
final DateCellController cellController;
void Function()? _onCellChangedFn;
DateCellBloc({required this.cellController})
: super(DateCellState.initial(cellController)) {
on<DateCellEvent>(
(event, emit) async {
event.when(
initial: () => _startListening(),
didReceiveCellUpdate: (DateCellDataPB? cellData) {
emit(
state.copyWith(
data: cellData,
dateStr: _dateStrFromCellData(cellData),
),
);
},
);
},
);
}
@override
Future<void> close() async {
if (_onCellChangedFn != null) {
cellController.removeListener(_onCellChangedFn!);
_onCellChangedFn = null;
}
return super.close();
}
void _startListening() {
_onCellChangedFn = cellController.startListening(
onCellChanged: ((data) {
if (!isClosed) {
add(DateCellEvent.didReceiveCellUpdate(data));
}
}),
);
}
}
@freezed
class DateCellEvent with _$DateCellEvent {
const factory DateCellEvent.initial() = _InitialCell;
const factory DateCellEvent.didReceiveCellUpdate(DateCellDataPB? data) =
_DidReceiveCellUpdate;
}
@freezed
class DateCellState with _$DateCellState {
const factory DateCellState({
required DateCellDataPB? data,
required String dateStr,
required FieldInfo fieldInfo,
}) = _DateCellState;
factory DateCellState.initial(DateCellController context) {
final cellData = context.getCellData();
return DateCellState(
fieldInfo: context.fieldInfo,
data: cellData,
dateStr: _dateStrFromCellData(cellData),
);
}
}
String _dateStrFromCellData(DateCellDataPB? cellData) {
if (cellData == null || !cellData.hasTimestamp()) {
return "";
}
String dateStr = "";
if (cellData.isRange) {
if (cellData.includeTime) {
dateStr =
"${cellData.date} ${cellData.time}${cellData.endDate} ${cellData.endTime}";
} else {
dateStr = "${cellData.date}${cellData.endDate}";
}
} else {
if (cellData.includeTime) {
dateStr = "${cellData.date} ${cellData.time}";
} else {
dateStr = cellData.date;
}
}
return dateStr.trim();
}

View File

@ -0,0 +1,524 @@
import 'dart:async';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/application/cell/date_cell_service.dart';
import 'package:appflowy/plugins/database/application/field/field_service.dart';
import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:easy_localization/easy_localization.dart'
show StringTranslateExtension;
import 'package:flowy_infra/time/duration.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:protobuf/protobuf.dart';
part 'date_cell_editor_bloc.freezed.dart';
class DateCellEditorBloc
extends Bloc<DateCellEditorEvent, DateCellEditorState> {
final DateCellBackendService _dateCellBackendService;
final DateCellController cellController;
void Function()? _onCellChangedFn;
DateCellEditorBloc({
required this.cellController,
}) : _dateCellBackendService = DateCellBackendService(
viewId: cellController.viewId,
fieldId: cellController.fieldId,
rowId: cellController.rowId,
),
super(DateCellEditorState.initial(cellController)) {
on<DateCellEditorEvent>(
(event, emit) async {
await event.when(
initial: () async => _startListening(),
didReceiveCellUpdate: (DateCellDataPB? cellData) {
final dateCellData = _dateDataFromCellData(cellData);
final endDay =
dateCellData.isRange == state.isRange && dateCellData.isRange
? dateCellData.endDateTime
: null;
emit(
state.copyWith(
dateTime: dateCellData.dateTime,
timeStr: dateCellData.timeStr,
endDateTime: dateCellData.endDateTime,
endTimeStr: dateCellData.endTimeStr,
includeTime: dateCellData.includeTime,
isRange: dateCellData.isRange,
startDay: dateCellData.isRange ? dateCellData.dateTime : null,
endDay: endDay,
dateStr: dateCellData.dateStr,
endDateStr: dateCellData.endDateStr,
),
);
},
didReceiveTimeFormatError:
(String? parseTimeError, String? parseEndTimeError) {
emit(
state.copyWith(
parseTimeError: parseTimeError,
parseEndTimeError: parseEndTimeError,
),
);
},
selectDay: (date) async {
if (state.isRange) {
return;
}
await _updateDateData(date: date);
},
setIncludeTime: (includeTime) async {
await _updateDateData(includeTime: includeTime);
},
setIsRange: (isRange) async {
await _updateDateData(isRange: isRange);
},
setTime: (timeStr) async {
emit(state.copyWith(timeStr: timeStr));
await _updateDateData(timeStr: timeStr);
},
selectDateRange: (DateTime? start, DateTime? end) async {
if (end == null && state.startDay != null && state.endDay == null) {
final (newStart, newEnd) = state.startDay!.isBefore(start!)
? (state.startDay!, start)
: (start, state.startDay!);
emit(
state.copyWith(
startDay: null,
endDay: null,
),
);
await _updateDateData(
date: newStart.date,
endDate: newEnd.date,
);
} else if (end == null) {
emit(
state.copyWith(
startDay: start,
endDay: null,
),
);
} else {
await _updateDateData(
date: start!.date,
endDate: end.date,
);
}
},
setStartDay: (DateTime startDay) async {
if (state.endDay == null) {
emit(
state.copyWith(
startDay: startDay,
),
);
} else if (startDay.isAfter(state.endDay!)) {
emit(
state.copyWith(
startDay: startDay,
endDay: null,
),
);
} else {
emit(
state.copyWith(
startDay: startDay,
),
);
_updateDateData(date: startDay.date, endDate: state.endDay!.date);
}
},
setEndDay: (DateTime endDay) async {
if (state.startDay == null) {
emit(
state.copyWith(
endDay: endDay,
),
);
} else if (endDay.isBefore(state.startDay!)) {
emit(
state.copyWith(
startDay: null,
endDay: endDay,
),
);
} else {
emit(
state.copyWith(
endDay: endDay,
),
);
_updateDateData(date: state.startDay!.date, endDate: endDay.date);
}
},
setEndTime: (String endTime) async {
emit(state.copyWith(endTimeStr: endTime));
await _updateDateData(endTimeStr: endTime);
},
setDateFormat: (dateFormat) async {
await _updateTypeOption(emit, dateFormat: dateFormat);
},
setTimeFormat: (timeFormat) async {
await _updateTypeOption(emit, timeFormat: timeFormat);
},
clearDate: () async {
await _clearDate();
},
);
},
);
}
Future<void> _updateDateData({
DateTime? date,
String? timeStr,
DateTime? endDate,
String? endTimeStr,
bool? includeTime,
bool? isRange,
}) async {
// make sure that not both date and time are updated at the same time
assert(
!(date != null && timeStr != null) ||
!(endDate != null && endTimeStr != null),
);
// if not updating the time, use the old time in the state
final String? newTime = timeStr ?? state.timeStr;
DateTime? newDate;
if (timeStr != null && timeStr.isNotEmpty) {
newDate = state.dateTime ?? DateTime.now();
} else {
newDate = _utcToLocalAndAddCurrentTime(date);
}
// if not updating the time, use the old time in the state
final String? newEndTime = endTimeStr ?? state.endTimeStr;
DateTime? newEndDate;
if (endTimeStr != null && endTimeStr.isNotEmpty) {
newEndDate = state.endDateTime ?? DateTime.now();
} else {
newEndDate = _utcToLocalAndAddCurrentTime(endDate);
}
final result = await _dateCellBackendService.update(
date: newDate,
time: newTime,
endDate: newEndDate,
endTime: newEndTime,
includeTime: includeTime ?? state.includeTime,
isRange: isRange ?? state.isRange,
);
result.fold(
(_) {
if (!isClosed &&
(state.parseEndTimeError != null || state.parseTimeError != null)) {
add(
const DateCellEditorEvent.didReceiveTimeFormatError(null, null),
);
}
},
(err) {
switch (err.code) {
case ErrorCode.InvalidDateTimeFormat:
if (isClosed) {
return;
}
// to determine which textfield should show error
final (startError, endError) = newDate != null
? (timeFormatPrompt(err), null)
: (null, timeFormatPrompt(err));
add(
DateCellEditorEvent.didReceiveTimeFormatError(
startError,
endError,
),
);
break;
default:
Log.error(err);
}
},
);
}
Future<void> _clearDate() async {
final result = await _dateCellBackendService.clear();
result.fold(
(_) {
if (isClosed) {
return;
}
add(
const DateCellEditorEvent.didReceiveTimeFormatError(null, null),
);
},
(err) => Log.error(err),
);
}
DateTime? _utcToLocalAndAddCurrentTime(DateTime? date) {
if (date == null) {
return null;
}
final now = DateTime.now();
// the incoming date is Utc. This trick converts it into Local
// and add the current time. The time may be overwritten by
// explicitly provided time string in the backend though
return DateTime(
date.year,
date.month,
date.day,
now.hour,
now.minute,
now.second,
);
}
String timeFormatPrompt(FlowyError error) {
return switch (state.dateTypeOptionPB.timeFormat) {
TimeFormatPB.TwelveHour =>
"${LocaleKeys.grid_field_invalidTimeFormat.tr()}. e.g. 01:00 PM",
TimeFormatPB.TwentyFourHour =>
"${LocaleKeys.grid_field_invalidTimeFormat.tr()}. e.g. 13:00",
_ => "",
};
}
@override
Future<void> close() async {
if (_onCellChangedFn != null) {
cellController.removeListener(_onCellChangedFn!);
_onCellChangedFn = null;
}
return super.close();
}
void _startListening() {
_onCellChangedFn = cellController.startListening(
onCellChanged: ((cell) {
if (!isClosed) {
add(DateCellEditorEvent.didReceiveCellUpdate(cell));
}
}),
);
}
Future<void>? _updateTypeOption(
Emitter<DateCellEditorState> emit, {
DateFormatPB? dateFormat,
TimeFormatPB? timeFormat,
}) async {
state.dateTypeOptionPB.freeze();
final newDateTypeOption = state.dateTypeOptionPB.rebuild((typeOption) {
if (dateFormat != null) {
typeOption.dateFormat = dateFormat;
}
if (timeFormat != null) {
typeOption.timeFormat = timeFormat;
}
});
final result = await FieldBackendService.updateFieldTypeOption(
viewId: cellController.viewId,
fieldId: cellController.fieldInfo.id,
typeOptionData: newDateTypeOption.writeToBuffer(),
);
result.fold(
(l) => emit(
state.copyWith(
dateTypeOptionPB: newDateTypeOption,
timeHintText: _timeHintText(newDateTypeOption),
),
),
(err) => Log.error(err),
);
}
}
@freezed
class DateCellEditorEvent with _$DateCellEditorEvent {
// initial event
const factory DateCellEditorEvent.initial() = _Initial;
// notification that cell is updated in the backend
const factory DateCellEditorEvent.didReceiveCellUpdate(
DateCellDataPB? data,
) = _DidReceiveCellUpdate;
const factory DateCellEditorEvent.didReceiveTimeFormatError(
String? parseTimeError,
String? parseEndTimeError,
) = _DidReceiveTimeFormatError;
// date cell data is modified
const factory DateCellEditorEvent.selectDay(DateTime day) = _SelectDay;
const factory DateCellEditorEvent.selectDateRange(
DateTime? start,
DateTime? end,
) = _SelectDateRange;
const factory DateCellEditorEvent.setStartDay(
DateTime startDay,
) = _SetStartDay;
const factory DateCellEditorEvent.setEndDay(
DateTime endDay,
) = _SetEndDay;
const factory DateCellEditorEvent.setTime(String time) = _Time;
const factory DateCellEditorEvent.setEndTime(String endTime) = _EndTime;
const factory DateCellEditorEvent.setIncludeTime(bool includeTime) =
_IncludeTime;
const factory DateCellEditorEvent.setIsRange(bool isRange) = _IsRange;
// date field type options are modified
const factory DateCellEditorEvent.setTimeFormat(TimeFormatPB timeFormat) =
_TimeFormat;
const factory DateCellEditorEvent.setDateFormat(DateFormatPB dateFormat) =
_DateFormat;
const factory DateCellEditorEvent.clearDate() = _ClearDate;
}
@freezed
class DateCellEditorState with _$DateCellEditorState {
const factory DateCellEditorState({
// the date field's type option
required DateTypeOptionPB dateTypeOptionPB,
// used when selecting a date range
required DateTime? startDay,
required DateTime? endDay,
// cell data from the backend
required DateTime? dateTime,
required DateTime? endDateTime,
required String? timeStr,
required String? endTimeStr,
required bool includeTime,
required bool isRange,
required String? dateStr,
required String? endDateStr,
// error and hint text
required String? parseTimeError,
required String? parseEndTimeError,
required String timeHintText,
}) = _DateCellEditorState;
factory DateCellEditorState.initial(DateCellController controller) {
final typeOption = controller.getTypeOption(DateTypeOptionDataParser());
final cellData = controller.getCellData();
final dateCellData = _dateDataFromCellData(cellData);
return DateCellEditorState(
dateTypeOptionPB: typeOption,
startDay: dateCellData.isRange ? dateCellData.dateTime : null,
endDay: dateCellData.isRange ? dateCellData.endDateTime : null,
dateTime: dateCellData.dateTime,
endDateTime: dateCellData.endDateTime,
timeStr: dateCellData.timeStr,
endTimeStr: dateCellData.endTimeStr,
dateStr: dateCellData.dateStr,
endDateStr: dateCellData.endDateStr,
includeTime: dateCellData.includeTime,
isRange: dateCellData.isRange,
parseTimeError: null,
parseEndTimeError: null,
timeHintText: _timeHintText(typeOption),
);
}
}
String _timeHintText(DateTypeOptionPB typeOption) {
switch (typeOption.timeFormat) {
case TimeFormatPB.TwelveHour:
return LocaleKeys.document_date_timeHintTextInTwelveHour.tr();
case TimeFormatPB.TwentyFourHour:
return LocaleKeys.document_date_timeHintTextInTwentyFourHour.tr();
default:
return "";
}
}
_DateCellData _dateDataFromCellData(
DateCellDataPB? cellData,
) {
// a null DateCellDataPB may be returned, indicating that all the fields are
// their default values: empty strings and false booleans
if (cellData == null) {
return _DateCellData(
dateTime: null,
endDateTime: null,
timeStr: null,
endTimeStr: null,
includeTime: false,
isRange: false,
dateStr: null,
endDateStr: null,
);
}
DateTime? dateTime;
String? timeStr;
DateTime? endDateTime;
String? endTimeStr;
String? endDateStr;
if (cellData.hasTimestamp()) {
final timestamp = cellData.timestamp * 1000;
dateTime = DateTime.fromMillisecondsSinceEpoch(timestamp.toInt());
timeStr = cellData.time;
if (cellData.hasEndTimestamp()) {
final endTimestamp = cellData.endTimestamp * 1000;
endDateTime = DateTime.fromMillisecondsSinceEpoch(endTimestamp.toInt());
endTimeStr = cellData.endTime;
}
}
final bool includeTime = cellData.includeTime;
final bool isRange = cellData.isRange;
if (cellData.isRange) {
endDateStr = cellData.endDate;
}
final String dateStr = cellData.date;
return _DateCellData(
dateTime: dateTime,
endDateTime: endDateTime,
timeStr: timeStr,
endTimeStr: endTimeStr,
includeTime: includeTime,
isRange: isRange,
dateStr: dateStr,
endDateStr: endDateStr,
);
}
class _DateCellData {
final DateTime? dateTime;
final DateTime? endDateTime;
final String? timeStr;
final String? endTimeStr;
final bool includeTime;
final bool isRange;
final String? dateStr;
final String? endDateStr;
_DateCellData({
required this.dateTime,
required this.endDateTime,
required this.timeStr,
required this.endTimeStr,
required this.includeTime,
required this.isRange,
required this.dateStr,
required this.endDateStr,
});
}

View File

@ -0,0 +1,84 @@
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/appflowy_date_picker.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'date_cell_editor_bloc.dart';
class DateCellEditor extends StatefulWidget {
const DateCellEditor({
super.key,
required this.onDismissed,
required this.cellController,
});
final VoidCallback onDismissed;
final DateCellController cellController;
@override
State<StatefulWidget> createState() => _DateCellEditor();
}
class _DateCellEditor extends State<DateCellEditor> {
final PopoverMutex popoverMutex = PopoverMutex();
@override
void dispose() {
popoverMutex.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => DateCellEditorBloc(
cellController: widget.cellController,
)..add(const DateCellEditorEvent.initial()),
child: BlocBuilder<DateCellEditorBloc, DateCellEditorState>(
builder: (context, state) {
final bloc = context.read<DateCellEditorBloc>();
return AppFlowyDatePicker(
includeTime: state.includeTime,
onIncludeTimeChanged: (value) =>
bloc.add(DateCellEditorEvent.setIncludeTime(!value)),
isRange: state.isRange,
onIsRangeChanged: (value) =>
bloc.add(DateCellEditorEvent.setIsRange(!value)),
dateFormat: state.dateTypeOptionPB.dateFormat,
timeFormat: state.dateTypeOptionPB.timeFormat,
selectedDay: state.dateTime,
timeStr: state.timeStr,
endTimeStr: state.endTimeStr,
timeHintText: state.timeHintText,
parseEndTimeError: state.parseEndTimeError,
parseTimeError: state.parseTimeError,
popoverMutex: popoverMutex,
onStartTimeSubmitted: (timeStr) {
bloc.add(DateCellEditorEvent.setTime(timeStr));
},
onEndTimeSubmitted: (timeStr) {
bloc.add(DateCellEditorEvent.setEndTime(timeStr));
},
onDaySelected: (selectedDay, _) {
bloc.add(DateCellEditorEvent.selectDay(selectedDay));
},
onRangeSelected: (start, end, _) {
bloc.add(DateCellEditorEvent.selectDateRange(start, end));
},
allowFormatChanges: true,
onDateFormatChanged: (format) {
bloc.add(DateCellEditorEvent.setDateFormat(format));
},
onTimeFormatChanged: (format) {
bloc.add(DateCellEditorEvent.setTimeFormat(format));
},
onClearDate: () {
bloc.add(const DateCellEditorEvent.clearDate());
},
);
},
),
);
}
}

View File

@ -0,0 +1,191 @@
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_picker.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:table_calendar/table_calendar.dart';
import 'date_cell_editor_bloc.dart';
class MobileDatePicker extends StatefulWidget {
const MobileDatePicker({
super.key,
});
@override
State<MobileDatePicker> createState() => _MobileDatePickerState();
}
class _MobileDatePickerState extends State<MobileDatePicker> {
DateTime _focusedDay = DateTime.now();
CalendarFormat _calendarFormat = CalendarFormat.month;
final ValueNotifier<(DateTime, dynamic)> _currentDateNotifier = ValueNotifier(
(DateTime.now(), null),
);
PageController? _pageController;
@override
Widget build(BuildContext context) {
return Column(
children: [
const VSpace(8.0),
_buildHeader(context),
const VSpace(8.0),
_buildCalendar(context),
const VSpace(16.0),
],
);
}
Widget _buildCalendar(BuildContext context) {
const selectedColor = Color(0xFF00BCF0);
final textStyle = Theme.of(context).textTheme.bodyMedium!.copyWith();
const boxDecoration = BoxDecoration(
shape: BoxShape.circle,
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: BlocBuilder<DateCellEditorBloc, DateCellEditorState>(
builder: (context, state) {
return TableCalendar(
firstDay: kFirstDay,
lastDay: kLastDay,
focusedDay: _focusedDay,
rowHeight: 48.0,
calendarFormat: _calendarFormat,
daysOfWeekHeight: 48.0,
rangeSelectionMode: state.isRange
? RangeSelectionMode.enforced
: RangeSelectionMode.disabled,
rangeStartDay: state.isRange ? state.startDay : null,
rangeEndDay: state.isRange ? state.endDay : null,
onCalendarCreated: (pageController) =>
_pageController = pageController,
headerVisible: false,
availableGestures: AvailableGestures.horizontalSwipe,
calendarStyle: CalendarStyle(
cellMargin: const EdgeInsets.all(3.5),
defaultDecoration: boxDecoration,
selectedDecoration: boxDecoration.copyWith(
color: selectedColor,
),
todayDecoration: boxDecoration.copyWith(
color: Colors.transparent,
border: Border.all(color: selectedColor),
),
weekendDecoration: boxDecoration,
outsideDecoration: boxDecoration,
rangeStartDecoration: boxDecoration.copyWith(
color: selectedColor,
),
rangeEndDecoration: boxDecoration.copyWith(
color: selectedColor,
),
defaultTextStyle: textStyle,
weekendTextStyle: textStyle,
selectedTextStyle: textStyle.copyWith(
color: Theme.of(context).colorScheme.surface,
),
rangeStartTextStyle: textStyle.copyWith(
color: Theme.of(context).colorScheme.surface,
),
rangeEndTextStyle: textStyle.copyWith(
color: Theme.of(context).colorScheme.surface,
),
todayTextStyle: textStyle,
outsideTextStyle: textStyle.copyWith(
color: Theme.of(context).disabledColor,
),
rangeHighlightColor:
Theme.of(context).colorScheme.secondaryContainer,
),
calendarBuilders: CalendarBuilders(
dowBuilder: (context, day) {
final locale = context.locale.toLanguageTag();
final label = DateFormat.E(locale).format(day).substring(0, 2);
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Center(
child: Text(
label,
style: textStyle.copyWith(
color: Theme.of(context).hintColor,
fontSize: 14.0,
),
),
),
);
},
),
selectedDayPredicate: (day) =>
state.isRange ? false : isSameDay(state.dateTime, day),
onDaySelected: (selectedDay, focusedDay) {
context.read<DateCellEditorBloc>().add(
DateCellEditorEvent.selectDay(selectedDay),
);
},
onRangeSelected: (start, end, focusedDay) {
context.read<DateCellEditorBloc>().add(
DateCellEditorEvent.selectDateRange(start, end),
);
},
onFormatChanged: (calendarFormat) => setState(() {
_calendarFormat = calendarFormat;
}),
onPageChanged: (focusedDay) => setState(() {
_focusedDay = focusedDay;
_currentDateNotifier.value = (focusedDay, null);
}),
);
},
),
);
}
Widget _buildHeader(BuildContext context) {
return Row(
children: [
const HSpace(16.0),
ValueListenableBuilder(
valueListenable: _currentDateNotifier,
builder: (_, value, ___) {
return FlowyText(
DateFormat.yMMMM(value.$2).format(value.$1),
);
},
),
const Spacer(),
FlowyButton(
useIntrinsicWidth: true,
text: FlowySvg(
FlowySvgs.arrow_left_s,
color: Theme.of(context).iconTheme.color,
size: const Size.square(24.0),
),
onTap: () => _pageController?.previousPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
),
),
const HSpace(24.0),
FlowyButton(
useIntrinsicWidth: true,
text: FlowySvg(
FlowySvgs.arrow_right_s,
color: Theme.of(context).iconTheme.color,
size: const Size.square(24.0),
),
onTap: () => _pageController?.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
),
),
const HSpace(8.0),
],
);
}
}

View File

@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../accessory/cell_shortcuts.dart';
import '../cell_builder.dart';
import 'cell_container.dart';
class MobileCellContainer extends StatelessWidget {
final GridCellWidget child;
final bool isPrimary;
final VoidCallback? onPrimaryFieldCellTap;
const MobileCellContainer({
super.key,
required this.child,
required this.isPrimary,
this.onPrimaryFieldCellTap,
});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider.value(
value: child.cellContainerNotifier,
child: Selector<CellContainerNotifier, bool>(
selector: (context, notifier) => notifier.isFocus,
builder: (providerContext, isFocus, _) {
Widget container = Center(child: GridCellShortcuts(child: child));
if (isPrimary) {
container = IgnorePointer(child: container);
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (isPrimary) {
onPrimaryFieldCellTap?.call();
return;
}
if (!isFocus) {
child.requestFocus.notify();
}
},
child: Container(
constraints: const BoxConstraints(maxWidth: 200, minHeight: 46),
decoration: _makeBoxDecoration(context, isPrimary, isFocus),
child: container,
),
);
},
),
);
}
BoxDecoration _makeBoxDecoration(
BuildContext context,
bool isPrimary,
bool isFocus,
) {
if (isFocus) {
return BoxDecoration(
border: Border.fromBorderSide(
BorderSide(
color: Theme.of(context).colorScheme.primary,
),
),
);
}
final borderSide = BorderSide(color: Theme.of(context).dividerColor);
return BoxDecoration(
border: Border(
left: isPrimary ? borderSide : BorderSide.none,
right: borderSide,
bottom: borderSide,
),
);
}
}

View File

@ -0,0 +1,115 @@
import 'dart:async';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'number_cell_bloc.dart';
import '../../../../grid/presentation/layout/sizes.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 {
final CellControllerBuilder cellControllerBuilder;
late final GridNumberCellStyle cellStyle;
GridNumberCell({
required this.cellControllerBuilder,
required GridCellStyle? style,
super.key,
}) {
if (style != null) {
cellStyle = (style as GridNumberCellStyle);
} else {
cellStyle = GridNumberCellStyle();
}
}
@override
GridEditableTextCell<GridNumberCell> createState() => _NumberCellState();
}
class _NumberCellState extends GridEditableTextCell<GridNumberCell> {
late NumberCellBloc _cellBloc;
late TextEditingController _controller;
@override
SingleListenerFocusNode focusNode = SingleListenerFocusNode();
@override
void initState() {
final cellController =
widget.cellControllerBuilder.build() as NumberCellController;
_cellBloc = NumberCellBloc(cellController: cellController)
..add(const NumberCellEvent.initial());
_controller = TextEditingController(text: _cellBloc.state.cellContent);
super.initState();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cellBloc,
child: MultiBlocListener(
listeners: [
BlocListener<NumberCellBloc, NumberCellState>(
listenWhen: (p, c) => p.cellContent != c.cellContent,
listener: (context, state) => _controller.text = state.cellContent,
),
],
child: TextField(
controller: _controller,
focusNode: focusNode,
onEditingComplete: () => focusNode.unfocus(),
onSubmitted: (_) => focusNode.unfocus(),
maxLines: null,
style: Theme.of(context).textTheme.bodyMedium,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
contentPadding:
widget.cellStyle.cellPadding ?? GridSize.cellContentInsets,
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
disabledBorder: InputBorder.none,
hintText: widget.cellStyle.placeholder,
isDense: true,
),
onTapOutside: (_) => focusNode.unfocus(),
),
),
);
}
@override
Future<void> dispose() async {
_cellBloc.close();
super.dispose();
}
@override
Future<void> focusChanged() async {
if (mounted) {
if (_cellBloc.isClosed == false &&
_controller.text != _cellBloc.state.cellContent) {
_cellBloc.add(NumberCellEvent.updateCell(_controller.text));
}
}
}
@override
String? onCopy() {
return _cellBloc.state.cellContent;
}
}

View File

@ -0,0 +1,85 @@
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'dart:async';
part 'number_cell_bloc.freezed.dart';
//
class NumberCellBloc extends Bloc<NumberCellEvent, NumberCellState> {
final NumberCellController cellController;
void Function()? _onCellChangedFn;
NumberCellBloc({
required this.cellController,
}) : super(NumberCellState.initial(cellController)) {
on<NumberCellEvent>(
(event, emit) async {
event.when(
initial: () {
_startListening();
},
didReceiveCellUpdate: (cellContent) {
emit(state.copyWith(cellContent: cellContent ?? ""));
},
updateCell: (text) async {
if (state.cellContent != text) {
emit(state.copyWith(cellContent: text));
await cellController.saveCellData(text);
// If the input content is "abc" that can't parsered as number then the data stored in the backend will be an empty string.
// So for every cell data that will be formatted in the backend.
// It needs to get the formatted data after saving.
add(
NumberCellEvent.didReceiveCellUpdate(
cellController.getCellData(),
),
);
}
},
);
},
);
}
@override
Future<void> close() async {
if (_onCellChangedFn != null) {
cellController.removeListener(_onCellChangedFn!);
_onCellChangedFn = null;
}
await cellController.dispose();
return super.close();
}
void _startListening() {
_onCellChangedFn = cellController.startListening(
onCellChanged: ((cellContent) {
if (!isClosed) {
add(NumberCellEvent.didReceiveCellUpdate(cellContent));
}
}),
);
}
}
@freezed
class NumberCellEvent with _$NumberCellEvent {
const factory NumberCellEvent.initial() = _Initial;
const factory NumberCellEvent.updateCell(String text) = _UpdateCell;
const factory NumberCellEvent.didReceiveCellUpdate(String? cellContent) =
_DidReceiveCellUpdate;
}
@freezed
class NumberCellState with _$NumberCellState {
const factory NumberCellState({
required String cellContent,
}) = _NumberCellState;
factory NumberCellState.initial(TextCellController context) {
return NumberCellState(
cellContent: context.getCellData() ?? "",
);
}
}

View File

@ -0,0 +1,156 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
extension SelectOptionColorExtension on SelectOptionColorPB {
Color toColor(BuildContext context) {
switch (this) {
case SelectOptionColorPB.Purple:
return AFThemeExtension.of(context).tint1;
case SelectOptionColorPB.Pink:
return AFThemeExtension.of(context).tint2;
case SelectOptionColorPB.LightPink:
return AFThemeExtension.of(context).tint3;
case SelectOptionColorPB.Orange:
return AFThemeExtension.of(context).tint4;
case SelectOptionColorPB.Yellow:
return AFThemeExtension.of(context).tint5;
case SelectOptionColorPB.Lime:
return AFThemeExtension.of(context).tint6;
case SelectOptionColorPB.Green:
return AFThemeExtension.of(context).tint7;
case SelectOptionColorPB.Aqua:
return AFThemeExtension.of(context).tint8;
case SelectOptionColorPB.Blue:
return AFThemeExtension.of(context).tint9;
default:
throw ArgumentError;
}
}
String optionName() {
switch (this) {
case SelectOptionColorPB.Purple:
return LocaleKeys.grid_selectOption_purpleColor.tr();
case SelectOptionColorPB.Pink:
return LocaleKeys.grid_selectOption_pinkColor.tr();
case SelectOptionColorPB.LightPink:
return LocaleKeys.grid_selectOption_lightPinkColor.tr();
case SelectOptionColorPB.Orange:
return LocaleKeys.grid_selectOption_orangeColor.tr();
case SelectOptionColorPB.Yellow:
return LocaleKeys.grid_selectOption_yellowColor.tr();
case SelectOptionColorPB.Lime:
return LocaleKeys.grid_selectOption_limeColor.tr();
case SelectOptionColorPB.Green:
return LocaleKeys.grid_selectOption_greenColor.tr();
case SelectOptionColorPB.Aqua:
return LocaleKeys.grid_selectOption_aquaColor.tr();
case SelectOptionColorPB.Blue:
return LocaleKeys.grid_selectOption_blueColor.tr();
default:
throw ArgumentError;
}
}
}
class SelectOptionTag extends StatelessWidget {
final SelectOptionPB? option;
final String? name;
final double? fontSize;
final Color? color;
final TextStyle? textStyle;
final EdgeInsets padding;
final void Function(String)? onRemove;
const SelectOptionTag({
super.key,
this.option,
this.name,
this.fontSize,
this.color,
this.textStyle,
this.onRemove,
required this.padding,
}) : assert(option != null || name != null && color != null);
@override
Widget build(BuildContext context) {
final optionName = option?.name ?? name!;
final optionColor = option?.color.toColor(context) ?? color!;
return Container(
padding: onRemove == null ? padding : padding.copyWith(right: 2.0),
decoration: BoxDecoration(
color: optionColor,
borderRadius: Corners.s6Border,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: FlowyText.medium(
optionName,
fontSize: fontSize,
overflow: TextOverflow.ellipsis,
color: AFThemeExtension.of(context).textColor,
),
),
if (onRemove != null) ...[
const HSpace(4),
FlowyIconButton(
width: 16.0,
onPressed: () => onRemove?.call(optionName),
hoverColor: Colors.transparent,
icon: const FlowySvg(FlowySvgs.close_s),
),
],
],
),
);
}
}
class SelectOptionTagCell extends StatelessWidget {
const SelectOptionTagCell({
super.key,
required this.option,
required this.onSelected,
this.children = const [],
});
final SelectOptionPB option;
final VoidCallback onSelected;
final List<Widget> children;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onSelected,
child: Align(
alignment: AlignmentDirectional.centerStart,
child: Padding(
padding: const EdgeInsets.all(5.0),
child: SelectOptionTag(
option: option,
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
),
),
),
),
),
...children,
],
);
}
}

View File

@ -0,0 +1,495 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/option_color_list.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/plugins/base/drag_handler.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/select_option_cell/extension.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/select_option_cell/select_option_editor_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option.pb.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 'package:go_router/go_router.dart';
import 'package:protobuf/protobuf.dart';
// include single select and multiple select
class MobileSelectOptionEditor extends StatefulWidget {
const MobileSelectOptionEditor({
super.key,
required this.cellController,
});
final SelectOptionCellController cellController;
@override
State<MobileSelectOptionEditor> createState() =>
_MobileSelectOptionEditorState();
}
class _MobileSelectOptionEditorState extends State<MobileSelectOptionEditor> {
final searchController = TextEditingController();
final renameController = TextEditingController();
String typingOption = '';
FieldType get fieldType => widget.cellController.fieldType;
bool showMoreOptions = false;
SelectOptionPB? option;
@override
void dispose() {
searchController.dispose();
renameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: const BoxConstraints.tightFor(height: 420),
child: BlocProvider(
create: (context) => SelectOptionCellEditorBloc(
cellController: widget.cellController,
)..add(const SelectOptionEditorEvent.initial()),
child: BlocBuilder<SelectOptionCellEditorBloc, SelectOptionEditorState>(
builder: (context, state) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const DragHandler(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: _buildHeader(context),
),
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: showMoreOptions ? 0.0 : 16.0,
),
child: _buildBody(context),
),
),
],
);
},
),
),
);
}
Widget _buildHeader(BuildContext context) {
const iconWidth = 36.0;
const height = 44.0;
return Stack(
children: [
Align(
alignment: Alignment.centerLeft,
child: FlowyIconButton(
icon: FlowySvg(
showMoreOptions ? FlowySvgs.arrow_left_s : FlowySvgs.close_s,
size: const Size.square(iconWidth),
),
width: iconWidth,
iconPadding: EdgeInsets.zero,
onPressed: () => _popOrBack(),
),
),
SizedBox(
height: 44.0,
child: Align(
alignment: Alignment.center,
child: FlowyText.medium(
_headerTitle(),
fontSize: 18,
),
),
),
].map((e) => SizedBox(height: height, child: e)).toList(),
);
}
Widget _buildBody(BuildContext context) {
if (showMoreOptions && option != null) {
return _MoreOptions(
initialOption: option!,
controller: renameController,
onDelete: () {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.deleteOption(option!));
_popOrBack();
},
onUpdate: (name, color) {
final option = this.option;
if (option == null) {
return;
}
option.freeze();
context.read<SelectOptionCellEditorBloc>().add(
SelectOptionEditorEvent.updateOption(
option.rebuild((p0) {
if (name != null) {
p0.name = name;
}
if (color != null) {
p0.color = color;
}
}),
),
);
},
);
}
return SingleChildScrollView(
child: Column(
children: [
_SearchField(
controller: searchController,
hintText: LocaleKeys.grid_selectOption_searchOrCreateOption.tr(),
onSubmitted: (option) {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.trySelectOption(option));
searchController.clear();
},
onChanged: (value) {
typingOption = value;
context.read<SelectOptionCellEditorBloc>().add(
SelectOptionEditorEvent.selectMultipleOptions(
[],
value,
),
);
},
),
_OptionList(
onCreateOption: (optionName) {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.newOption(optionName));
searchController.clear();
},
onCheck: (option, value) {
if (value) {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.selectOption(option.id));
} else {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.unSelectOption(option.id));
}
},
onMoreOptions: (option) {
setState(() {
this.option = option;
renameController.text = option.name;
showMoreOptions = true;
});
},
),
],
),
);
}
String _headerTitle() {
switch (fieldType) {
case FieldType.SingleSelect:
return LocaleKeys.grid_field_singleSelectFieldName.tr();
case FieldType.MultiSelect:
return LocaleKeys.grid_field_multiSelectFieldName.tr();
default:
throw UnimplementedError();
}
}
void _popOrBack() {
if (showMoreOptions) {
setState(() {
showMoreOptions = false;
option = null;
});
} else {
context.pop();
}
}
}
class _SearchField extends StatelessWidget {
const _SearchField({
this.hintText,
required this.onChanged,
required this.onSubmitted,
required this.controller,
});
final String? hintText;
final void Function(String value) onChanged;
final void Function(String value) onSubmitted;
final TextEditingController controller;
@override
Widget build(BuildContext context) {
final textStyle = Theme.of(context).textTheme.bodyMedium;
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 12,
),
child: SizedBox(
height: 44, // the height is fixed.
child: FlowyTextField(
autoFocus: false,
hintText: hintText,
textStyle: textStyle,
hintStyle: textStyle?.copyWith(color: Theme.of(context).hintColor),
onChanged: onChanged,
onSubmitted: onSubmitted,
controller: controller,
),
),
);
}
}
class _OptionList extends StatelessWidget {
const _OptionList({
required this.onCreateOption,
required this.onCheck,
required this.onMoreOptions,
});
final void Function(String optionName) onCreateOption;
final void Function(SelectOptionPB option, bool value) onCheck;
final void Function(SelectOptionPB option) onMoreOptions;
@override
Widget build(BuildContext context) {
return BlocBuilder<SelectOptionCellEditorBloc, SelectOptionEditorState>(
builder: (context, state) {
// existing options
final List<Widget> cells = [];
// create an option cell
state.createOption.fold(
() => null,
(createOption) {
cells.add(
_CreateOptionCell(
optionName: createOption,
onTap: () => onCreateOption(createOption),
),
);
},
);
cells.addAll(
state.options.map(
(option) => _SelectOption(
option: option,
checked: state.selectedOptions.contains(option),
onCheck: (value) => onCheck(option, value),
onMoreOptions: () => onMoreOptions(option),
),
),
);
return ListView.separated(
shrinkWrap: true,
itemCount: cells.length,
separatorBuilder: (_, __) =>
VSpace(GridSize.typeOptionSeparatorHeight),
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (_, int index) => cells[index],
padding: const EdgeInsets.only(bottom: 12.0),
);
},
);
}
}
class _SelectOption extends StatelessWidget {
const _SelectOption({
required this.option,
required this.checked,
required this.onCheck,
required this.onMoreOptions,
});
final SelectOptionPB option;
final bool checked;
final void Function(bool value) onCheck;
final VoidCallback onMoreOptions;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 44,
child: GestureDetector(
// no need to add click effect, so using gesture detector
behavior: HitTestBehavior.translucent,
onTap: () => onCheck(!checked),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// check icon
FlowySvg(
checked
? FlowySvgs.m_checkbox_checked_s
: FlowySvgs.m_checkbox_uncheck_s,
size: const Size.square(24.0),
blendMode: null,
),
// padding
const HSpace(12),
// option tag
SelectOptionTag(
option: option,
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 8),
),
const Spacer(),
// more options
FlowyIconButton(
icon: const FlowySvg(FlowySvgs.three_dots_s),
onPressed: onMoreOptions,
),
],
),
),
);
}
}
class _CreateOptionCell extends StatelessWidget {
const _CreateOptionCell({
required this.optionName,
required this.onTap,
});
final String optionName;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 44,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: onTap,
child: Row(
children: [
FlowyText.medium(
LocaleKeys.grid_selectOption_create.tr(),
color: Theme.of(context).hintColor,
),
const HSpace(8),
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: SelectOptionTag(
name: optionName,
color: Theme.of(context).colorScheme.surfaceVariant,
padding:
const EdgeInsets.symmetric(horizontal: 11, vertical: 8),
),
),
),
],
),
),
);
}
}
class _MoreOptions extends StatefulWidget {
const _MoreOptions({
required this.initialOption,
required this.onDelete,
required this.onUpdate,
required this.controller,
});
final SelectOptionPB initialOption;
final VoidCallback onDelete;
final void Function(String? name, SelectOptionColorPB? color) onUpdate;
final TextEditingController controller;
@override
State<_MoreOptions> createState() => _MoreOptionsState();
}
class _MoreOptionsState extends State<_MoreOptions> {
late SelectOptionPB option = widget.initialOption;
@override
Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.secondaryContainer;
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildRenameTextField(context),
const VSpace(16.0),
_buildDeleteButton(context),
const VSpace(16.0),
Padding(
padding: const EdgeInsets.only(left: 12.0),
child: ColoredBox(
color: color,
child: FlowyText(
LocaleKeys.grid_selectOption_colorPanelTitle.tr().toUpperCase(),
color: Theme.of(context).hintColor,
fontSize: 13,
),
),
),
const VSpace(4.0),
FlowyOptionDecorateBox(
showTopBorder: true,
showBottomBorder: true,
child: Padding(
padding: const EdgeInsets.only(
top: 12.0,
left: 6.0,
right: 6.0,
),
child: OptionColorList(
selectedColor: option.color,
onSelectedColor: (color) {
widget.onUpdate(null, color);
setState(() {
option.freeze();
option = option.rebuild((option) => option.color = color);
});
},
),
),
),
],
),
);
}
Widget _buildRenameTextField(BuildContext context) {
return ConstrainedBox(
constraints: const BoxConstraints.tightFor(height: 52.0),
child: FlowyOptionTile.textField(
onTextChanged: (name) => widget.onUpdate(name, null),
controller: widget.controller,
),
);
}
Widget _buildDeleteButton(BuildContext context) {
return FlowyOptionTile.text(
text: LocaleKeys.button_delete.tr(),
leftIcon: const FlowySvg(FlowySvgs.delete_s),
onTap: widget.onDelete,
);
}
}

View File

@ -0,0 +1,349 @@
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/select_option_cell/mobile_select_option_editor.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:collection/collection.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../grid/presentation/layout/sizes.dart';
import '../../cell_builder.dart';
import 'extension.dart';
import 'select_option_cell_bloc.dart';
import 'select_option_editor.dart';
class SelectOptionCellStyle extends GridCellStyle {
String placeholder;
EdgeInsets? cellPadding;
bool useRoundedBorder;
SelectOptionCellStyle({
this.placeholder = "",
this.cellPadding,
this.useRoundedBorder = false,
});
}
class GridSingleSelectCell extends GridCellWidget {
final CellControllerBuilder cellControllerBuilder;
late final SelectOptionCellStyle cellStyle;
GridSingleSelectCell({
super.key,
required this.cellControllerBuilder,
GridCellStyle? style,
}) {
if (style != null) {
cellStyle = (style as SelectOptionCellStyle);
} else {
cellStyle = SelectOptionCellStyle();
}
}
@override
GridCellState<GridSingleSelectCell> createState() => _SingleSelectCellState();
}
class _SingleSelectCellState extends GridCellState<GridSingleSelectCell> {
final PopoverController _popoverController = PopoverController();
late SelectOptionCellBloc _cellBloc;
@override
void initState() {
super.initState();
final cellController =
widget.cellControllerBuilder.build() as SelectOptionCellController;
_cellBloc = SelectOptionCellBloc(cellController: cellController)
..add(const SelectOptionCellEvent.initial());
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cellBloc,
child: BlocBuilder<SelectOptionCellBloc, SelectOptionCellState>(
builder: (context, state) {
return SelectOptionWrap(
selectOptions: state.selectedOptions,
cellStyle: widget.cellStyle,
onCellEditing: (isFocus) =>
widget.cellContainerNotifier.isFocus = isFocus,
popoverController: _popoverController,
cellControllerBuilder: widget.cellControllerBuilder,
);
},
),
);
}
@override
Future<void> dispose() async {
_cellBloc.close();
super.dispose();
}
@override
void requestBeginFocus() => _popoverController.show();
}
//----------------------------------------------------------------
class GridMultiSelectCell extends GridCellWidget {
final CellControllerBuilder cellControllerBuilder;
late final SelectOptionCellStyle cellStyle;
GridMultiSelectCell({
super.key,
required this.cellControllerBuilder,
GridCellStyle? style,
}) {
if (style != null) {
cellStyle = (style as SelectOptionCellStyle);
} else {
cellStyle = SelectOptionCellStyle();
}
}
@override
GridCellState<GridMultiSelectCell> createState() => _MultiSelectCellState();
}
class _MultiSelectCellState extends GridCellState<GridMultiSelectCell> {
final PopoverController _popoverController = PopoverController();
late SelectOptionCellBloc _cellBloc;
@override
void initState() {
super.initState();
final cellController =
widget.cellControllerBuilder.build() as SelectOptionCellController;
_cellBloc = SelectOptionCellBloc(cellController: cellController)
..add(const SelectOptionCellEvent.initial());
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cellBloc,
child: BlocBuilder<SelectOptionCellBloc, SelectOptionCellState>(
builder: (context, state) {
return SelectOptionWrap(
selectOptions: state.selectedOptions,
cellStyle: widget.cellStyle,
onCellEditing: (isFocus) =>
widget.cellContainerNotifier.isFocus = isFocus,
popoverController: _popoverController,
cellControllerBuilder: widget.cellControllerBuilder,
);
},
),
);
}
@override
Future<void> dispose() async {
_cellBloc.close();
super.dispose();
}
@override
void requestBeginFocus() => _popoverController.show();
}
class SelectOptionWrap extends StatefulWidget {
final List<SelectOptionPB> selectOptions;
final SelectOptionCellStyle cellStyle;
final CellControllerBuilder cellControllerBuilder;
final PopoverController popoverController;
final void Function(bool) onCellEditing;
const SelectOptionWrap({
super.key,
required this.selectOptions,
required this.cellControllerBuilder,
required this.onCellEditing,
required this.popoverController,
required this.cellStyle,
});
@override
State<StatefulWidget> createState() => _SelectOptionWrapState();
}
class _SelectOptionWrapState extends State<SelectOptionWrap> {
@override
Widget build(BuildContext context) {
final constraints = BoxConstraints.loose(
Size(SelectOptionCellEditor.editorPanelWidth, 300),
);
final cellController =
widget.cellControllerBuilder.build() as SelectOptionCellController;
if (PlatformExtension.isDesktopOrWeb) {
return AppFlowyPopover(
controller: widget.popoverController,
constraints: constraints,
margin: EdgeInsets.zero,
direction: PopoverDirection.bottomWithLeftAligned,
popupBuilder: (BuildContext popoverContext) {
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onCellEditing(true);
});
return SelectOptionCellEditor(
cellController: cellController,
);
},
onClose: () => widget.onCellEditing(false),
child: Padding(
padding: widget.cellStyle.cellPadding ?? GridSize.cellContentInsets,
child: _buildOptions(context),
),
);
} else if (widget.cellStyle.useRoundedBorder) {
return InkWell(
borderRadius: const BorderRadius.all(Radius.circular(14)),
onTap: () => showMobileBottomSheet(
context,
padding: EdgeInsets.zero,
builder: (context) {
return MobileSelectOptionEditor(
cellController: cellController,
);
},
),
child: Container(
constraints: const BoxConstraints(
minHeight: 48,
minWidth: double.infinity,
),
padding: EdgeInsets.symmetric(
horizontal: 12,
vertical: widget.selectOptions.isEmpty ? 13 : 10,
),
decoration: BoxDecoration(
border: Border.fromBorderSide(
BorderSide(color: Theme.of(context).colorScheme.outline),
),
borderRadius: const BorderRadius.all(Radius.circular(14)),
),
child: Row(
children: [
Expanded(child: _buildMobileOptions(isInRowDetail: true)),
const HSpace(6),
RotatedBox(
quarterTurns: 3,
child: Icon(
Icons.chevron_left,
color: Theme.of(context).hintColor,
),
),
const HSpace(2),
],
),
),
);
} else {
return FlowyButton(
hoverColor: Colors.transparent,
radius: BorderRadius.zero,
margin: EdgeInsets.zero,
text: Padding(
padding: widget.cellStyle.cellPadding ?? EdgeInsets.zero,
child: _buildMobileOptions(isInRowDetail: false),
),
onTap: () {
showMobileBottomSheet(
context,
padding: EdgeInsets.zero,
builder: (context) {
return MobileSelectOptionEditor(
cellController: cellController,
);
},
);
},
);
}
}
Widget _buildOptions(BuildContext context) {
final Widget child;
if (widget.selectOptions.isEmpty) {
child = Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: FlowyText.medium(
widget.cellStyle.placeholder,
color: Theme.of(context).hintColor,
),
);
} else {
final children = widget.selectOptions.map(
(option) {
return Padding(
padding: const EdgeInsets.only(right: 4),
child: SelectOptionTag(
option: option,
padding: const EdgeInsets.symmetric(vertical: 1, horizontal: 8),
),
);
},
).toList();
child = Wrap(
runSpacing: 4,
children: children,
);
}
return Align(alignment: Alignment.centerLeft, child: child);
}
Widget _buildMobileOptions({required bool isInRowDetail}) {
if (widget.selectOptions.isEmpty) {
return Container(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(vertical: 1),
child: FlowyText(
widget.cellStyle.placeholder,
color: Theme.of(context).hintColor,
),
);
} else {
final children = widget.selectOptions.mapIndexed(
(index, option) {
return Padding(
padding: EdgeInsets.only(left: index == 0 ? 0 : 4),
child: SelectOptionTag(
option: option,
fontSize: 14,
padding: isInRowDetail
? const EdgeInsets.symmetric(horizontal: 11, vertical: 5)
: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
),
);
},
).toList();
return isInRowDetail
? Align(
alignment: Alignment.centerLeft,
child: Wrap(
runSpacing: 4,
children: children,
),
)
: Align(
alignment: Alignment.centerLeft,
child: ListView.separated(
separatorBuilder: (context, index) => const HSpace(4),
itemCount: children.length,
itemBuilder: (context, index) => children[index],
scrollDirection: Axis.horizontal,
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
),
);
}
}
}

View File

@ -0,0 +1,81 @@
import 'dart:async';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option.pb.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'select_option_cell_bloc.freezed.dart';
class SelectOptionCellBloc
extends Bloc<SelectOptionCellEvent, SelectOptionCellState> {
final SelectOptionCellController cellController;
void Function()? _onCellChangedFn;
SelectOptionCellBloc({
required this.cellController,
}) : super(SelectOptionCellState.initial(cellController)) {
on<SelectOptionCellEvent>(
(event, emit) async {
await event.map(
initial: (_InitialCell value) async {
_startListening();
},
didReceiveOptions: (_DidReceiveOptions value) {
emit(
state.copyWith(
selectedOptions: value.selectedOptions,
),
);
},
);
},
);
}
@override
Future<void> close() async {
if (_onCellChangedFn != null) {
cellController.removeListener(_onCellChangedFn!);
_onCellChangedFn = null;
}
await cellController.dispose();
return super.close();
}
void _startListening() {
_onCellChangedFn = cellController.startListening(
onCellChanged: ((selectOptionContext) {
if (!isClosed) {
add(
SelectOptionCellEvent.didReceiveOptions(
selectOptionContext?.selectOptions ?? [],
),
);
}
}),
);
}
}
@freezed
class SelectOptionCellEvent with _$SelectOptionCellEvent {
const factory SelectOptionCellEvent.initial() = _InitialCell;
const factory SelectOptionCellEvent.didReceiveOptions(
List<SelectOptionPB> selectedOptions,
) = _DidReceiveOptions;
}
@freezed
class SelectOptionCellState with _$SelectOptionCellState {
const factory SelectOptionCellState({
required List<SelectOptionPB> selectedOptions,
}) = _SelectOptionCellState;
factory SelectOptionCellState.initial(SelectOptionCellController context) {
final data = context.getCellData();
return SelectOptionCellState(
selectedOptions: data?.selectOptions ?? [],
);
}
}

View File

@ -0,0 +1,388 @@
import 'dart:collection';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option.pb.dart';
import 'package:appflowy_popover/appflowy_popover.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/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:textfield_tags/textfield_tags.dart';
import '../../../../grid/presentation/layout/sizes.dart';
import '../../../../grid/presentation/widgets/common/type_option_separator.dart';
import '../../../../grid/presentation/widgets/header/type_option/select/select_option_editor.dart';
import 'extension.dart';
import 'select_option_editor_bloc.dart';
import 'text_field.dart';
const double _editorPanelWidth = 300;
const double _padding = 12.0;
class SelectOptionCellEditor extends StatefulWidget {
final SelectOptionCellController cellController;
static double editorPanelWidth = 300;
const SelectOptionCellEditor({super.key, required this.cellController});
@override
State<SelectOptionCellEditor> createState() => _SelectOptionCellEditorState();
}
class _SelectOptionCellEditorState extends State<SelectOptionCellEditor> {
final popoverMutex = PopoverMutex();
final tagController = TextfieldTagsController();
@override
void dispose() {
popoverMutex.dispose();
tagController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => SelectOptionCellEditorBloc(
cellController: widget.cellController,
)..add(const SelectOptionEditorEvent.initial()),
child: BlocBuilder<SelectOptionCellEditorBloc, SelectOptionEditorState>(
builder: (context, state) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_TextField(
popoverMutex: popoverMutex,
tagController: tagController,
),
const TypeOptionSeparator(spacing: 0.0),
Flexible(
child: _OptionList(
popoverMutex: popoverMutex,
tagController: tagController,
),
),
],
);
},
),
);
}
}
class _OptionList extends StatelessWidget {
final PopoverMutex popoverMutex;
final TextfieldTagsController tagController;
const _OptionList({
required this.popoverMutex,
required this.tagController,
});
@override
Widget build(BuildContext context) {
return BlocBuilder<SelectOptionCellEditorBloc, SelectOptionEditorState>(
builder: (context, state) {
final cells = [
_Title(onPressedAddButton: () => onPressedAddButton(context)),
...state.options.map(
(option) => _SelectOptionCell(
option: option,
isSelected: state.selectedOptions.contains(option),
popoverMutex: popoverMutex,
),
),
];
state.createOption.fold(
() => null,
(createOption) {
cells.add(_CreateOptionCell(name: createOption));
},
);
return ListView.separated(
shrinkWrap: true,
itemCount: cells.length,
separatorBuilder: (_, __) =>
VSpace(GridSize.typeOptionSeparatorHeight),
physics: StyledScrollPhysics(),
itemBuilder: (_, int index) => cells[index],
padding: const EdgeInsets.only(top: 6.0, bottom: 12.0),
);
},
);
}
void onPressedAddButton(BuildContext context) {
final text = tagController.textEditingController?.text;
if (text != null) {
context.read<SelectOptionCellEditorBloc>().add(
SelectOptionEditorEvent.trySelectOption(text),
);
}
tagController.textEditingController?.clear();
}
}
class _TextField extends StatelessWidget {
final PopoverMutex popoverMutex;
final TextfieldTagsController tagController;
const _TextField({
required this.popoverMutex,
required this.tagController,
});
@override
Widget build(BuildContext context) {
return BlocBuilder<SelectOptionCellEditorBloc, SelectOptionEditorState>(
builder: (context, state) {
final optionMap = LinkedHashMap<String, SelectOptionPB>.fromIterable(
state.selectedOptions,
key: (option) => option.name,
value: (option) => option,
);
return Padding(
padding: const EdgeInsets.all(_padding),
child: SelectOptionTextField(
options: state.options,
selectedOptionMap: optionMap,
distanceToText: _editorPanelWidth * 0.7,
tagController: tagController,
textSeparators: const [','],
onClick: () => popoverMutex.close(),
newText: (text) {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.filterOption(text));
},
onSubmitted: (tagName) {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.trySelectOption(tagName));
},
onPaste: (tagNames, remainder) {
context.read<SelectOptionCellEditorBloc>().add(
SelectOptionEditorEvent.selectMultipleOptions(
tagNames,
remainder,
),
);
},
onRemove: (optionName) {
context.read<SelectOptionCellEditorBloc>().add(
SelectOptionEditorEvent.unSelectOption(
optionMap[optionName]!.id,
),
);
},
),
);
},
);
}
}
class _Title extends StatelessWidget {
const _Title({
required this.onPressedAddButton,
});
final VoidCallback onPressedAddButton;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: SizedBox(
height: GridSize.popoverItemHeight,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: FlowyText.medium(
LocaleKeys.grid_selectOption_panelTitle.tr(),
color: Theme.of(context).hintColor,
),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4.0,
),
child: FlowyIconButton(
onPressed: onPressedAddButton,
width: 18,
icon: const FlowySvg(
FlowySvgs.add_s,
),
iconColorOnHover: Theme.of(context).colorScheme.onSecondary,
),
),
],
),
),
);
}
}
class _CreateOptionCell extends StatelessWidget {
const _CreateOptionCell({
required this.name,
});
final String name;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: SizedBox(
height: 28,
child: FlowyButton(
hoverColor: AFThemeExtension.of(context).lightGreyHover,
onTap: () => context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.newOption(name)),
text: Row(
children: [
FlowyText.medium(
LocaleKeys.grid_selectOption_create.tr(),
color: Theme.of(context).hintColor,
),
const HSpace(10),
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: SelectOptionTag(
name: name,
fontSize: 11,
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 1,
),
color: Theme.of(context).colorScheme.surfaceVariant,
),
),
),
],
),
),
),
);
}
}
class _SelectOptionCell extends StatefulWidget {
final SelectOptionPB option;
final PopoverMutex popoverMutex;
final bool isSelected;
const _SelectOptionCell({
required this.option,
required this.isSelected,
required this.popoverMutex,
});
@override
State<_SelectOptionCell> createState() => _SelectOptionCellState();
}
class _SelectOptionCellState extends State<_SelectOptionCell> {
late PopoverController _popoverController;
@override
void initState() {
_popoverController = PopoverController();
super.initState();
}
@override
Widget build(BuildContext context) {
final child = SizedBox(
height: 28,
child: SelectOptionTagCell(
option: widget.option,
onSelected: _onTap,
children: [
if (widget.isSelected)
FlowyIconButton(
width: 20,
hoverColor: Colors.transparent,
onPressed: _onTap,
icon: FlowySvg(
FlowySvgs.check_s,
color: Theme.of(context).iconTheme.color,
),
),
FlowyIconButton(
width: 30,
onPressed: () => _popoverController.show(),
iconPadding: const EdgeInsets.symmetric(horizontal: 6.0),
hoverColor: Colors.transparent,
icon: FlowySvg(
FlowySvgs.details_s,
color: Theme.of(context).iconTheme.color,
),
),
],
),
);
return AppFlowyPopover(
controller: _popoverController,
offset: const Offset(8, 0),
margin: EdgeInsets.zero,
asBarrier: true,
constraints: BoxConstraints.loose(const Size(200, 470)),
mutex: widget.popoverMutex,
clickHandler: PopoverClickHandler.gestureDetector,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: FlowyHover(
resetHoverOnRebuild: false,
style: HoverStyle(
hoverColor: AFThemeExtension.of(context).lightGreyHover,
),
child: child,
),
),
popupBuilder: (BuildContext popoverContext) {
return SelectOptionTypeOptionEditor(
option: widget.option,
onDeleted: () {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.deleteOption(widget.option));
PopoverContainer.of(popoverContext).close();
},
onUpdated: (updatedOption) {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.updateOption(updatedOption));
},
key: ValueKey(
widget.option.id,
), // Use ValueKey to refresh the UI, otherwise, it will remain the old value.
);
},
);
}
void _onTap() {
widget.popoverMutex.close();
if (widget.isSelected) {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.unSelectOption(widget.option.id));
} else {
context
.read<SelectOptionCellEditorBloc>()
.add(SelectOptionEditorEvent.selectOption(widget.option.id));
}
}
}

View File

@ -0,0 +1,313 @@
import 'dart:async';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option.pb.dart';
import 'package:dartz/dartz.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../../../application/cell/select_option_cell_service.dart';
part 'select_option_editor_bloc.freezed.dart';
class SelectOptionCellEditorBloc
extends Bloc<SelectOptionEditorEvent, SelectOptionEditorState> {
final SelectOptionCellBackendService _selectOptionService;
final SelectOptionCellController cellController;
SelectOptionCellEditorBloc({
required this.cellController,
}) : _selectOptionService = SelectOptionCellBackendService(
viewId: cellController.viewId,
fieldId: cellController.fieldId,
rowId: cellController.rowId,
),
super(SelectOptionEditorState.initial(cellController)) {
on<SelectOptionEditorEvent>(
(event, emit) async {
await event.map(
initial: (_Initial value) async {
_startListening();
await _loadOptions();
},
didReceiveOptions: (_DidReceiveOptions value) {
final result = _makeOptions(state.filter, value.options);
emit(
state.copyWith(
allOptions: value.options,
options: result.options,
createOption: result.createOption,
selectedOptions: value.selectedOptions,
),
);
},
newOption: (_NewOption value) async {
await _createOption(value.optionName);
emit(
state.copyWith(
filter: none(),
),
);
},
deleteOption: (_DeleteOption value) async {
await _deleteOption([value.option]);
},
deleteAllOptions: (_DeleteAllOptions value) async {
if (state.allOptions.isNotEmpty) {
await _deleteOption(state.allOptions);
}
},
updateOption: (_UpdateOption value) async {
await _updateOption(value.option);
},
selectOption: (_SelectOption value) async {
await _selectOptionService.select(optionIds: [value.optionId]);
final selectedOption = [
...state.selectedOptions,
state.options.firstWhere(
(element) => element.id == value.optionId,
),
];
emit(
state.copyWith(
selectedOptions: selectedOption,
),
);
},
unSelectOption: (_UnSelectOption value) async {
await _selectOptionService.unSelect(optionIds: [value.optionId]);
final selectedOptions = [...state.selectedOptions]
..removeWhere((e) => e.id == value.optionId);
emit(
state.copyWith(
selectedOptions: selectedOptions,
),
);
},
trySelectOption: (_TrySelectOption value) {
_trySelectOption(value.optionName, emit);
},
selectMultipleOptions: (_SelectMultipleOptions value) {
if (value.optionNames.isNotEmpty) {
_selectMultipleOptions(value.optionNames);
}
_filterOption(value.remainder, emit);
},
filterOption: (_SelectOptionFilter value) {
_filterOption(value.optionName, emit);
},
);
},
);
}
@override
Future<void> close() async {
await cellController.dispose();
return super.close();
}
Future<void> _createOption(String name) async {
final result = await _selectOptionService.create(name: name);
result.fold((l) => {}, (err) => Log.error(err));
}
Future<void> _deleteOption(List<SelectOptionPB> options) async {
final result = await _selectOptionService.delete(options: options);
result.fold((l) => null, (err) => Log.error(err));
}
Future<void> _updateOption(SelectOptionPB option) async {
final result = await _selectOptionService.update(
option: option,
);
result.fold((l) => null, (err) => Log.error(err));
}
void _trySelectOption(
String optionName,
Emitter<SelectOptionEditorState> emit,
) {
SelectOptionPB? matchingOption;
bool optionExistsButSelected = false;
for (final option in state.options) {
if (option.name.toLowerCase() == optionName.toLowerCase()) {
if (!state.selectedOptions.contains(option)) {
matchingOption = option;
break;
} else {
optionExistsButSelected = true;
}
}
}
// if there isn't a matching option at all, then create it
if (matchingOption == null && !optionExistsButSelected) {
_createOption(optionName);
}
// if there is an unselected matching option, select it
if (matchingOption != null) {
_selectOptionService.select(optionIds: [matchingOption.id]);
}
// clear the filter
emit(state.copyWith(filter: none()));
}
void _selectMultipleOptions(List<String> optionNames) {
// The options are unordered. So in order to keep the inserted [optionNames]
// order, it needs to get the option id in the [optionNames] order.
final lowerCaseNames = optionNames.map((e) => e.toLowerCase());
final Map<String, String> optionIdsMap = {};
for (final option in state.options) {
optionIdsMap[option.name.toLowerCase()] = option.id;
}
final optionIds = lowerCaseNames
.where((name) => optionIdsMap[name] != null)
.map((name) => optionIdsMap[name]!)
.toList();
_selectOptionService.select(optionIds: optionIds);
}
void _filterOption(String optionName, Emitter<SelectOptionEditorState> emit) {
final _MakeOptionResult result = _makeOptions(
Some(optionName),
state.allOptions,
);
emit(
state.copyWith(
filter: Some(optionName),
options: result.options,
createOption: result.createOption,
),
);
}
Future<void> _loadOptions() async {
final result = await _selectOptionService.getCellData();
if (isClosed) {
Log.warn("Unexpected closing the bloc");
return;
}
return result.fold(
(data) => add(
SelectOptionEditorEvent.didReceiveOptions(
data.options,
data.selectOptions,
),
),
(err) {
Log.error(err);
return null;
},
);
}
_MakeOptionResult _makeOptions(
Option<String> filter,
List<SelectOptionPB> allOptions,
) {
final List<SelectOptionPB> options = List.from(allOptions);
Option<String> createOption = filter;
filter.foldRight(null, (filter, previous) {
if (filter.isNotEmpty) {
options.retainWhere((option) {
final name = option.name.toLowerCase();
final lFilter = filter.toLowerCase();
if (name == lFilter) {
createOption = none();
}
return name.contains(lFilter);
});
} else {
createOption = none();
}
});
return _MakeOptionResult(
options: options,
createOption: createOption,
);
}
void _startListening() {
cellController.startListening(
onCellChanged: ((selectOptionContext) {
_loadOptions();
}),
onCellFieldChanged: () {
_loadOptions();
},
);
}
}
@freezed
class SelectOptionEditorEvent with _$SelectOptionEditorEvent {
const factory SelectOptionEditorEvent.initial() = _Initial;
const factory SelectOptionEditorEvent.didReceiveOptions(
List<SelectOptionPB> options,
List<SelectOptionPB> selectedOptions,
) = _DidReceiveOptions;
const factory SelectOptionEditorEvent.newOption(String optionName) =
_NewOption;
const factory SelectOptionEditorEvent.selectOption(String optionId) =
_SelectOption;
const factory SelectOptionEditorEvent.unSelectOption(String optionId) =
_UnSelectOption;
const factory SelectOptionEditorEvent.updateOption(SelectOptionPB option) =
_UpdateOption;
const factory SelectOptionEditorEvent.deleteOption(SelectOptionPB option) =
_DeleteOption;
const factory SelectOptionEditorEvent.deleteAllOptions() = _DeleteAllOptions;
const factory SelectOptionEditorEvent.filterOption(String optionName) =
_SelectOptionFilter;
const factory SelectOptionEditorEvent.trySelectOption(String optionName) =
_TrySelectOption;
const factory SelectOptionEditorEvent.selectMultipleOptions(
List<String> optionNames,
String remainder,
) = _SelectMultipleOptions;
}
@freezed
class SelectOptionEditorState with _$SelectOptionEditorState {
const factory SelectOptionEditorState({
required List<SelectOptionPB> options,
required List<SelectOptionPB> allOptions,
required List<SelectOptionPB> selectedOptions,
required Option<String> createOption,
required Option<String> filter,
}) = _SelectOptionEditorState;
factory SelectOptionEditorState.initial(SelectOptionCellController context) {
final data = context.getCellData(loadIfNotExist: false);
return SelectOptionEditorState(
options: data?.options ?? [],
allOptions: data?.options ?? [],
selectedOptions: data?.selectOptions ?? [],
createOption: none(),
filter: none(),
);
}
}
class _MakeOptionResult {
List<SelectOptionPB> options;
Option<String> createOption;
_MakeOptionResult({
required this.options,
required this.createOption,
});
}

View File

@ -0,0 +1,205 @@
import 'dart:collection';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option.pb.dart';
import 'package:flowy_infra/size.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:textfield_tags/textfield_tags.dart';
import 'extension.dart';
class SelectOptionTextField extends StatefulWidget {
final TextfieldTagsController tagController;
final List<SelectOptionPB> options;
final LinkedHashMap<String, SelectOptionPB> selectedOptionMap;
final double distanceToText;
final List<String> textSeparators;
final TextEditingController? textController;
final Function(String) onSubmitted;
final Function(String) newText;
final Function(List<String>, String) onPaste;
final Function(String) onRemove;
final VoidCallback? onClick;
const SelectOptionTextField({
super.key,
required this.options,
required this.selectedOptionMap,
required this.distanceToText,
required this.tagController,
required this.onSubmitted,
required this.onPaste,
required this.onRemove,
required this.newText,
required this.textSeparators,
this.textController,
this.onClick,
});
@override
State<SelectOptionTextField> createState() => _SelectOptionTextFieldState();
}
class _SelectOptionTextFieldState extends State<SelectOptionTextField> {
final FocusNode focusNode = FocusNode();
late final TextEditingController controller;
@override
void initState() {
super.initState();
controller = widget.textController ?? TextEditingController();
WidgetsBinding.instance.addPostFrameCallback((_) {
focusNode.requestFocus();
});
}
@override
Widget build(BuildContext context) {
return TextFieldTags(
textEditingController: controller,
textfieldTagsController: widget.tagController,
initialTags: widget.selectedOptionMap.keys.toList(),
focusNode: focusNode,
textSeparators: widget.textSeparators,
inputfieldBuilder: (
BuildContext context,
editController,
focusNode,
error,
onChanged,
onSubmitted,
) {
return ((context, sc, tags, onTagDelegate) {
return TextField(
controller: editController,
focusNode: focusNode,
onTap: widget.onClick,
onChanged: (text) {
if (onChanged != null) {
onChanged(text);
}
_newText(text, editController);
},
onSubmitted: (text) {
if (onSubmitted != null) {
onSubmitted(text);
}
if (text.isNotEmpty) {
widget.onSubmitted(text.trim());
focusNode.requestFocus();
}
},
maxLines: 1,
style: Theme.of(context).textTheme.bodyMedium,
decoration: InputDecoration(
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.outline,
width: 1.0,
),
borderRadius: Corners.s10Border,
),
isDense: true,
prefixIcon: _renderTags(context, sc),
hintText: LocaleKeys.grid_selectOption_searchOption.tr(),
hintStyle: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(color: Theme.of(context).hintColor),
prefixIconConstraints:
BoxConstraints(maxWidth: widget.distanceToText),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 1.0,
),
borderRadius: Corners.s10Border,
),
),
);
});
},
);
}
void _newText(String text, TextEditingController editingController) {
if (text.isEmpty) {
widget.newText('');
return;
}
final result = splitInput(text.trimLeft(), widget.textSeparators);
editingController.text = result[1];
editingController.selection =
TextSelection.collapsed(offset: controller.text.length);
widget.onPaste(result[0], result[1]);
}
Widget? _renderTags(BuildContext context, ScrollController sc) {
if (widget.selectedOptionMap.isEmpty) {
return null;
}
final children = widget.selectedOptionMap.values
.map(
(option) => SelectOptionTag(
option: option,
onRemove: (option) => widget.onRemove(option),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
),
)
.toList();
return MouseRegion(
cursor: SystemMouseCursors.basic,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
dragDevices: {
PointerDeviceKind.mouse,
PointerDeviceKind.touch,
PointerDeviceKind.trackpad,
PointerDeviceKind.stylus,
PointerDeviceKind.invertedStylus,
},
),
child: SingleChildScrollView(
controller: sc,
scrollDirection: Axis.horizontal,
child: Wrap(spacing: 4, children: children),
),
),
),
);
}
}
@visibleForTesting
List splitInput(String input, List<String> textSeparators) {
final List<String> splits = [];
String currentString = '';
// split the string into tokens
for (final char in input.split('')) {
if (textSeparators.contains(char)) {
if (currentString.trim().isNotEmpty) {
splits.add(currentString.trim());
}
currentString = '';
continue;
}
currentString += char;
}
// add the remainder (might be '')
splits.add(currentString);
final submittedOptions = splits.sublist(0, splits.length - 1).toList();
final remainder = splits.elementAt(splits.length - 1).trimLeft();
return [submittedOptions, remainder];
}

View File

@ -0,0 +1,154 @@
import 'dart:async';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/text_cell/text_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 '../../../../grid/presentation/layout/sizes.dart';
import '../../cell_builder.dart';
class GridTextCellStyle extends GridCellStyle {
final String? placeholder;
final TextStyle? textStyle;
final EdgeInsets? cellPadding;
final bool autofocus;
final double emojiFontSize;
final double emojiHPadding;
final bool showEmoji;
final bool useRoundedBorder;
const GridTextCellStyle({
this.placeholder,
this.textStyle,
this.cellPadding,
this.autofocus = false,
this.showEmoji = true,
this.emojiFontSize = 16,
this.emojiHPadding = 4,
this.useRoundedBorder = false,
});
}
class GridTextCell extends GridCellWidget {
final CellControllerBuilder cellControllerBuilder;
late final GridTextCellStyle cellStyle;
GridTextCell({
required this.cellControllerBuilder,
GridCellStyle? style,
super.key,
}) {
if (style != null) {
cellStyle = (style as GridTextCellStyle);
} else {
cellStyle = const GridTextCellStyle();
}
}
@override
GridEditableTextCell<GridTextCell> createState() => _GridTextCellState();
}
class _GridTextCellState extends GridEditableTextCell<GridTextCell> {
late TextCellBloc _cellBloc;
late TextEditingController _controller;
@override
SingleListenerFocusNode focusNode = SingleListenerFocusNode();
@override
void initState() {
super.initState();
final cellController =
widget.cellControllerBuilder.build() as TextCellController;
_cellBloc = TextCellBloc(cellController: cellController)
..add(const TextCellEvent.initial());
_controller = TextEditingController(text: _cellBloc.state.content);
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cellBloc,
child: BlocListener<TextCellBloc, TextCellState>(
listener: (context, state) {
if (_controller.text != state.content) {
_controller.text = state.content;
}
},
child: Row(
children: [
if (widget.cellStyle.showEmoji) ...[
// Only build the emoji when it changes
BlocBuilder<TextCellBloc, TextCellState>(
buildWhen: (p, c) => p.emoji != c.emoji,
builder: (context, state) => Center(
child: FlowyText(
state.emoji,
fontSize: widget.cellStyle.emojiFontSize,
),
),
),
HSpace(widget.cellStyle.emojiHPadding),
],
Expanded(
child: widget.cellStyle.useRoundedBorder
? FlowyTextField(
controller: _controller,
textStyle: widget.cellStyle.textStyle ??
Theme.of(context).textTheme.bodyMedium,
focusNode: focusNode,
autoFocus: widget.cellStyle.autofocus,
hintText: widget.cellStyle.placeholder,
onChanged: (text) => _cellBloc.add(
TextCellEvent.updateText(text),
),
debounceDuration: const Duration(milliseconds: 300),
)
: TextField(
controller: _controller,
focusNode: focusNode,
maxLines: null,
style: widget.cellStyle.textStyle ??
Theme.of(context).textTheme.bodyMedium,
autofocus: widget.cellStyle.autofocus,
decoration: InputDecoration(
contentPadding: widget.cellStyle.cellPadding ??
GridSize.cellContentInsets,
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
disabledBorder: InputBorder.none,
hintText: widget.cellStyle.placeholder,
isDense: true,
isCollapsed: true,
),
onTapOutside: (_) => focusNode.unfocus(),
),
),
],
),
),
);
}
@override
Future<void> dispose() async {
_controller.dispose();
_cellBloc.close();
super.dispose();
}
@override
String? onCopy() => _cellBloc.state.content;
@override
Future<void> focusChanged() {
_cellBloc.add(
TextCellEvent.updateText(_controller.text),
);
return super.focusChanged();
}
}

View File

@ -0,0 +1,89 @@
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'dart:async';
part 'text_cell_bloc.freezed.dart';
class TextCellBloc extends Bloc<TextCellEvent, TextCellState> {
final TextCellController cellController;
void Function()? _onCellChangedFn;
TextCellBloc({
required this.cellController,
}) : super(TextCellState.initial(cellController)) {
on<TextCellEvent>(
(event, emit) {
event.when(
initial: () {
_startListening();
},
updateText: (text) {
if (state.content != text) {
cellController.saveCellData(text);
emit(state.copyWith(content: text));
}
},
didReceiveCellUpdate: (content) {
emit(state.copyWith(content: content));
},
didUpdateEmoji: (String emoji) {
emit(state.copyWith(emoji: emoji));
},
enableEdit: (bool enabled) {
emit(state.copyWith(enableEdit: enabled));
},
);
},
);
}
@override
Future<void> close() async {
if (_onCellChangedFn != null) {
cellController.removeListener(_onCellChangedFn!);
_onCellChangedFn = null;
}
await cellController.dispose();
return super.close();
}
void _startListening() {
_onCellChangedFn = cellController.startListening(
onCellChanged: ((cellContent) {
if (!isClosed) {
add(TextCellEvent.didReceiveCellUpdate(cellContent ?? ""));
}
}),
onRowMetaChanged: () {
if (!isClosed) {
add(TextCellEvent.didUpdateEmoji(cellController.emoji ?? ""));
}
},
);
}
}
@freezed
class TextCellEvent with _$TextCellEvent {
const factory TextCellEvent.initial() = _InitialCell;
const factory TextCellEvent.didReceiveCellUpdate(String cellContent) =
_DidReceiveCellUpdate;
const factory TextCellEvent.updateText(String text) = _UpdateText;
const factory TextCellEvent.enableEdit(bool enabled) = _EnableEdit;
const factory TextCellEvent.didUpdateEmoji(String emoji) = _UpdateEmoji;
}
@freezed
class TextCellState with _$TextCellState {
const factory TextCellState({
required String content,
required String emoji,
required bool enableEdit,
}) = _TextCellState;
factory TextCellState.initial(TextCellController context) => TextCellState(
content: context.getCellData() ?? "",
emoji: context.emoji ?? "",
enableEdit: false,
);
}

View File

@ -0,0 +1,132 @@
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/widgets/row/cell_builder.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/timestamp_cell/timestamp_cell_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class TimestampCellStyle extends GridCellStyle {
String? placeholder;
Alignment alignment;
EdgeInsets? cellPadding;
final bool useRoundedBorder;
TimestampCellStyle({
this.placeholder,
this.alignment = Alignment.center,
this.cellPadding,
this.useRoundedBorder = false,
});
}
class GridTimestampCell extends GridCellWidget {
/// The [GridTimestampCell] is used by both [FieldType.CreatedTime]
/// and [FieldType.LastEditedTime]. So it needs to know the field type.
final FieldType fieldType;
final CellControllerBuilder cellControllerBuilder;
late final TimestampCellStyle? cellStyle;
GridTimestampCell({
super.key,
GridCellStyle? style,
required this.fieldType,
required this.cellControllerBuilder,
}) {
if (style != null) {
cellStyle = (style as TimestampCellStyle);
} else {
cellStyle = null;
}
}
@override
GridCellState<GridTimestampCell> createState() => _TimestampCellState();
}
class _TimestampCellState extends GridCellState<GridTimestampCell> {
late TimestampCellBloc _cellBloc;
@override
void initState() {
super.initState();
final cellController =
widget.cellControllerBuilder.build() as TimestampCellController;
_cellBloc = TimestampCellBloc(cellController: cellController)
..add(const TimestampCellEvent.initial());
}
@override
Widget build(BuildContext context) {
final alignment = widget.cellStyle?.alignment ?? Alignment.centerLeft;
final placeholder = widget.cellStyle?.placeholder ?? "";
final padding = widget.cellStyle?.cellPadding ?? GridSize.cellContentInsets;
return BlocProvider.value(
value: _cellBloc,
child: BlocBuilder<TimestampCellBloc, TimestampCellState>(
builder: (context, state) {
final isEmpty = state.dateStr.isEmpty;
final text = isEmpty ? placeholder : state.dateStr;
if (PlatformExtension.isDesktopOrWeb ||
widget.cellStyle == null ||
!widget.cellStyle!.useRoundedBorder) {
return Align(
alignment: alignment,
child: Padding(
padding: padding,
child: FlowyText.medium(
text,
color: isEmpty ? Theme.of(context).hintColor : null,
maxLines: null,
),
),
);
} else {
return 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)),
),
child: Padding(
padding: padding,
child: FlowyText.medium(
text,
fontSize: 16,
color: isEmpty
? Theme.of(context).hintColor
: AFThemeExtension.of(context).textColor,
maxLines: null,
),
),
);
}
},
),
);
}
@override
Future<void> dispose() async {
_cellBloc.close();
super.dispose();
}
@override
String? onCopy() => _cellBloc.state.dateStr;
@override
void requestBeginFocus() {
return;
}
}

View File

@ -0,0 +1,80 @@
import 'dart:async';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/application/field/field_info.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/timestamp_entities.pb.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'timestamp_cell_bloc.freezed.dart';
class TimestampCellBloc extends Bloc<TimestampCellEvent, TimestampCellState> {
final TimestampCellController cellController;
void Function()? _onCellChangedFn;
TimestampCellBloc({required this.cellController})
: super(TimestampCellState.initial(cellController)) {
on<TimestampCellEvent>(
(event, emit) async {
event.when(
initial: () => _startListening(),
didReceiveCellUpdate: (TimestampCellDataPB? cellData) {
emit(
state.copyWith(
data: cellData,
dateStr: cellData?.dateTime ?? "",
),
);
},
);
},
);
}
@override
Future<void> close() async {
if (_onCellChangedFn != null) {
cellController.removeListener(_onCellChangedFn!);
_onCellChangedFn = null;
}
await cellController.dispose();
return super.close();
}
void _startListening() {
_onCellChangedFn = cellController.startListening(
onCellChanged: ((data) {
if (!isClosed) {
add(TimestampCellEvent.didReceiveCellUpdate(data));
}
}),
);
}
}
@freezed
class TimestampCellEvent with _$TimestampCellEvent {
const factory TimestampCellEvent.initial() = _InitialCell;
const factory TimestampCellEvent.didReceiveCellUpdate(
TimestampCellDataPB? data,
) = _DidReceiveCellUpdate;
}
@freezed
class TimestampCellState with _$TimestampCellState {
const factory TimestampCellState({
required TimestampCellDataPB? data,
required String dateStr,
required FieldInfo fieldInfo,
}) = _TimestampCellState;
factory TimestampCellState.initial(TimestampCellController context) {
final cellData = context.getCellData();
return TimestampCellState(
fieldInfo: context.fieldInfo,
data: cellData,
dateStr: cellData?.dateTime ?? "",
);
}
}

View File

@ -0,0 +1,105 @@
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:flutter/material.dart';
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'url_cell_editor_bloc.dart';
class URLCellEditor extends StatefulWidget {
final VoidCallback onExit;
final URLCellController cellController;
const URLCellEditor({
required this.cellController,
required this.onExit,
super.key,
});
@override
State<URLCellEditor> createState() => _URLCellEditorState();
}
class _URLCellEditorState extends State<URLCellEditor> {
late URLCellEditorBloc _cellBloc;
late TextEditingController _controller;
@override
void initState() {
_cellBloc = URLCellEditorBloc(cellController: widget.cellController);
_cellBloc.add(const URLCellEditorEvent.initial());
_controller = TextEditingController(text: _cellBloc.state.content);
super.initState();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cellBloc,
child: BlocListener<URLCellEditorBloc, URLCellEditorState>(
listener: (context, state) {
if (_controller.text != state.content) {
_controller.text = state.content;
}
if (state.isFinishEditing) {
widget.onExit();
}
},
child: TextField(
autofocus: true,
controller: _controller,
onSubmitted: (value) => focusChanged(),
onEditingComplete: () => focusChanged(),
maxLines: 1,
style: Theme.of(context).textTheme.bodyMedium,
decoration: const InputDecoration(
contentPadding: EdgeInsets.zero,
border: InputBorder.none,
hintText: "",
isDense: true,
),
),
),
);
}
@override
Future<void> dispose() async {
_cellBloc.close();
super.dispose();
}
void focusChanged() {
if (mounted) {
if (_cellBloc.isClosed == false &&
_controller.text != _cellBloc.state.content) {
_cellBloc.add(URLCellEditorEvent.updateText(_controller.text));
}
}
}
}
class URLEditorPopover extends StatelessWidget {
final VoidCallback onExit;
final URLCellController cellController;
const URLEditorPopover({
required this.cellController,
required this.onExit,
super.key,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(4),
),
padding: const EdgeInsets.all(12),
child: URLCellEditor(
cellController: cellController,
onExit: onExit,
),
);
}
}

View File

@ -0,0 +1,299 @@
import 'dart:async';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:url_launcher/url_launcher_string.dart';
import '../../../../grid/presentation/layout/sizes.dart';
import '../../accessory/cell_accessory.dart';
import '../../cell_builder.dart';
import 'url_cell_bloc.dart';
class GridURLCellStyle extends GridCellStyle {
String? placeholder;
TextStyle? textStyle;
bool? autofocus;
EdgeInsets? cellPadding;
List<GridURLCellAccessoryType> accessoryTypes;
GridURLCellStyle({
this.placeholder,
this.accessoryTypes = const [],
this.cellPadding,
});
}
enum GridURLCellAccessoryType {
copyURL,
visitURL,
}
typedef URLCellDataNotifier = CellDataNotifier<String>;
class GridURLCell extends GridCellWidget {
GridURLCell({
super.key,
required this.cellControllerBuilder,
GridCellStyle? style,
}) : _cellDataNotifier = CellDataNotifier(value: '') {
if (style != null) {
cellStyle = (style as GridURLCellStyle);
} else {
cellStyle = GridURLCellStyle();
}
}
/// Use
final URLCellDataNotifier _cellDataNotifier;
final CellControllerBuilder cellControllerBuilder;
late final GridURLCellStyle cellStyle;
@override
GridCellState<GridURLCell> createState() => _GridURLCellState();
@override
List<GridCellAccessoryBuilder> Function(
GridCellAccessoryBuildContext buildContext,
) get accessoryBuilder => (buildContext) {
final List<GridCellAccessoryBuilder> accessories = [];
accessories.addAll(
cellStyle.accessoryTypes.map((ty) {
return _accessoryFromType(ty, buildContext);
}),
);
// If the accessories is empty then the default accessory will be GridURLCellAccessoryType.visitURL
if (accessories.isEmpty) {
accessories.add(
_accessoryFromType(
GridURLCellAccessoryType.visitURL,
buildContext,
),
);
}
return accessories;
};
GridCellAccessoryBuilder _accessoryFromType(
GridURLCellAccessoryType ty,
GridCellAccessoryBuildContext buildContext,
) {
switch (ty) {
case GridURLCellAccessoryType.visitURL:
return VisitURLCellAccessoryBuilder(
builder: (Key key) => _VisitURLAccessory(
key: key,
cellDataNotifier: _cellDataNotifier,
),
);
case GridURLCellAccessoryType.copyURL:
return CopyURLCellAccessoryBuilder(
builder: (Key key) => _CopyURLAccessory(
key: key,
cellDataNotifier: _cellDataNotifier,
),
);
}
}
}
class _GridURLCellState extends GridEditableTextCell<GridURLCell> {
late final URLCellBloc _cellBloc;
late final TextEditingController _controller;
@override
SingleListenerFocusNode focusNode = SingleListenerFocusNode();
@override
void initState() {
super.initState();
final cellController =
widget.cellControllerBuilder.build() as URLCellController;
_cellBloc = URLCellBloc(cellController: cellController)
..add(const URLCellEvent.initial());
_controller = TextEditingController(text: _cellBloc.state.content);
}
@override
Future<void> dispose() async {
_cellBloc.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cellBloc,
child: BlocConsumer<URLCellBloc, URLCellState>(
listenWhen: (previous, current) => previous.content != current.content,
listener: (context, state) {
_controller.text = state.content;
},
builder: (context, state) {
final style = widget.cellStyle.textStyle ??
Theme.of(context).textTheme.bodyMedium!;
widget._cellDataNotifier.value = state.content;
return TextField(
controller: _controller,
focusNode: focusNode,
maxLines: null,
style: style.copyWith(
color: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
),
autofocus: false,
decoration: InputDecoration(
contentPadding:
widget.cellStyle.cellPadding ?? GridSize.cellContentInsets,
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
disabledBorder: InputBorder.none,
hintText: widget.cellStyle.placeholder,
hintStyle: style.copyWith(color: Theme.of(context).hintColor),
isDense: true,
),
onTapOutside: (_) => focusNode.unfocus(),
);
},
),
);
}
@override
Future<void> focusChanged() async {
_cellBloc.add(URLCellEvent.updateURL(_controller.text.trim()));
return super.focusChanged();
}
@override
String? onCopy() => _cellBloc.state.content;
}
typedef CopyURLCellAccessoryBuilder
= GridCellAccessoryBuilder<State<_CopyURLAccessory>>;
class _CopyURLAccessory extends StatefulWidget {
const _CopyURLAccessory({
super.key,
required this.cellDataNotifier,
});
final URLCellDataNotifier cellDataNotifier;
@override
State<_CopyURLAccessory> createState() => _CopyURLAccessoryState();
}
class _CopyURLAccessoryState extends State<_CopyURLAccessory>
with GridCellAccessoryState {
@override
Widget build(BuildContext context) {
if (widget.cellDataNotifier.value.isNotEmpty) {
return FlowyTooltip(
message: LocaleKeys.tooltip_urlCopyAccessory.tr(),
preferBelow: false,
child: _URLAccessoryIconContainer(
child: FlowySvg(
FlowySvgs.copy_s,
color: AFThemeExtension.of(context).textColor,
),
),
);
} else {
return const SizedBox.shrink();
}
}
@override
void onTap() {
final content = widget.cellDataNotifier.value;
if (content.isEmpty) {
return;
}
Clipboard.setData(ClipboardData(text: content));
showMessageToast(LocaleKeys.grid_row_copyProperty.tr());
}
}
typedef VisitURLCellAccessoryBuilder
= GridCellAccessoryBuilder<State<_VisitURLAccessory>>;
class _VisitURLAccessory extends StatefulWidget {
const _VisitURLAccessory({
super.key,
required this.cellDataNotifier,
});
final URLCellDataNotifier cellDataNotifier;
@override
State<_VisitURLAccessory> createState() => _VisitURLAccessoryState();
}
class _VisitURLAccessoryState extends State<_VisitURLAccessory>
with GridCellAccessoryState {
@override
Widget build(BuildContext context) {
if (widget.cellDataNotifier.value.isNotEmpty) {
return FlowyTooltip(
message: LocaleKeys.tooltip_urlLaunchAccessory.tr(),
preferBelow: false,
child: _URLAccessoryIconContainer(
child: FlowySvg(
FlowySvgs.attach_s,
color: AFThemeExtension.of(context).textColor,
),
),
);
} else {
return const SizedBox.shrink();
}
}
@override
bool enable() {
return widget.cellDataNotifier.value.isNotEmpty;
}
@override
void onTap() {
final content = widget.cellDataNotifier.value;
if (content.isEmpty) {
return;
}
final shouldAddScheme =
!['http', 'https'].any((pattern) => content.startsWith(pattern));
final url = shouldAddScheme ? 'http://$content' : content;
canLaunchUrlString(url).then((value) => launchUrlString(url));
}
}
class _URLAccessoryIconContainer extends StatelessWidget {
const _URLAccessoryIconContainer({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 26,
height: 26,
child: Padding(
padding: const EdgeInsets.all(3.0),
child: child,
),
);
}
}

View File

@ -0,0 +1,80 @@
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/url_entities.pb.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'dart:async';
part 'url_cell_bloc.freezed.dart';
class URLCellBloc extends Bloc<URLCellEvent, URLCellState> {
final URLCellController cellController;
void Function()? _onCellChangedFn;
URLCellBloc({
required this.cellController,
}) : super(URLCellState.initial(cellController)) {
on<URLCellEvent>(
(event, emit) async {
event.when(
initial: () {
_startListening();
},
didReceiveCellUpdate: (cellData) {
emit(
state.copyWith(
content: cellData?.content ?? "",
url: cellData?.url ?? "",
),
);
},
updateURL: (String url) {
cellController.saveCellData(url, deduplicate: true);
},
);
},
);
}
@override
Future<void> close() async {
if (_onCellChangedFn != null) {
cellController.removeListener(_onCellChangedFn!);
_onCellChangedFn = null;
}
await cellController.dispose();
return super.close();
}
void _startListening() {
_onCellChangedFn = cellController.startListening(
onCellChanged: ((cellData) {
if (!isClosed) {
add(URLCellEvent.didReceiveCellUpdate(cellData));
}
}),
);
}
}
@freezed
class URLCellEvent with _$URLCellEvent {
const factory URLCellEvent.initial() = _InitialCell;
const factory URLCellEvent.updateURL(String url) = _UpdateURL;
const factory URLCellEvent.didReceiveCellUpdate(URLCellDataPB? cell) =
_DidReceiveCellUpdate;
}
@freezed
class URLCellState with _$URLCellState {
const factory URLCellState({
required String content,
required String url,
}) = _URLCellState;
factory URLCellState.initial(URLCellController context) {
final cellData = context.getCellData();
return URLCellState(
content: cellData?.content ?? "",
url: cellData?.url ?? "",
);
}
}

View File

@ -0,0 +1,81 @@
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/url_entities.pb.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'dart:async';
part 'url_cell_editor_bloc.freezed.dart';
class URLCellEditorBloc extends Bloc<URLCellEditorEvent, URLCellEditorState> {
final URLCellController cellController;
void Function()? _onCellChangedFn;
URLCellEditorBloc({
required this.cellController,
}) : super(URLCellEditorState.initial(cellController)) {
on<URLCellEditorEvent>(
(event, emit) async {
await event.when(
initial: () {
_startListening();
},
updateText: (text) async {
await cellController.saveCellData(text);
emit(
state.copyWith(
content: text,
isFinishEditing: true,
),
);
},
didReceiveCellUpdate: (cellData) {
emit(state.copyWith(content: cellData?.content ?? ""));
},
);
},
);
}
@override
Future<void> close() async {
if (_onCellChangedFn != null) {
cellController.removeListener(_onCellChangedFn!);
_onCellChangedFn = null;
}
await cellController.dispose();
return super.close();
}
void _startListening() {
_onCellChangedFn = cellController.startListening(
onCellChanged: ((cellData) {
if (!isClosed) {
add(URLCellEditorEvent.didReceiveCellUpdate(cellData));
}
}),
);
}
}
@freezed
class URLCellEditorEvent with _$URLCellEditorEvent {
const factory URLCellEditorEvent.initial() = _InitialCell;
const factory URLCellEditorEvent.didReceiveCellUpdate(URLCellDataPB? cell) =
_DidReceiveCellUpdate;
const factory URLCellEditorEvent.updateText(String text) = _UpdateText;
}
@freezed
class URLCellEditorState with _$URLCellEditorState {
const factory URLCellEditorState({
required String content,
required bool isFinishEditing,
}) = _URLCellEditorState;
factory URLCellEditorState.initial(URLCellController context) {
final cellData = context.getCellData();
return URLCellEditorState(
content: cellData?.content ?? "",
isFinishEditing: true,
);
}
}

View File

@ -0,0 +1,90 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/row/row_controller.dart';
import 'package:appflowy/plugins/database/application/row/row_service.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
class RowActionList extends StatelessWidget {
final RowController rowController;
const RowActionList({
required this.rowController,
super.key,
});
@override
Widget build(BuildContext context) {
return IntrinsicWidth(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
RowDetailPageDuplicateButton(
viewId: rowController.viewId,
rowId: rowController.rowId,
),
const VSpace(4.0),
RowDetailPageDeleteButton(
viewId: rowController.viewId,
rowId: rowController.rowId,
),
],
),
);
}
}
class RowDetailPageDeleteButton extends StatelessWidget {
const RowDetailPageDeleteButton({
super.key,
required this.viewId,
required this.rowId,
});
final String viewId;
final String rowId;
@override
Widget build(BuildContext context) {
return SizedBox(
height: GridSize.popoverItemHeight,
child: FlowyButton(
text: FlowyText.regular(LocaleKeys.grid_row_delete.tr()),
leftIcon: const FlowySvg(FlowySvgs.trash_m),
onTap: () {
RowBackendService.deleteRow(viewId, rowId);
FlowyOverlay.pop(context);
},
),
);
}
}
class RowDetailPageDuplicateButton extends StatelessWidget {
const RowDetailPageDuplicateButton({
super.key,
required this.viewId,
required this.rowId,
});
final String viewId;
final String rowId;
@override
Widget build(BuildContext context) {
return SizedBox(
height: GridSize.popoverItemHeight,
child: FlowyButton(
text: FlowyText.regular(LocaleKeys.grid_row_duplicate.tr()),
leftIcon: const FlowySvg(FlowySvgs.copy_s),
onTap: () {
RowBackendService.duplicateRow(viewId, rowId);
FlowyOverlay.pop(context);
},
),
);
}
}

View File

@ -0,0 +1,310 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/cell/cell_service.dart';
import 'package:appflowy/plugins/database/application/field/field_info.dart';
import 'package:appflowy/plugins/database/application/row/row_banner_bloc.dart';
import 'package:appflowy/plugins/database/application/row/row_controller.dart';
import 'package:appflowy/plugins/database/widgets/row/row_action.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart';
import 'package:appflowy_popover/appflowy_popover.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 'cell_builder.dart';
import 'cells/cells.dart';
class RowBanner extends StatefulWidget {
final RowController rowController;
final GridCellBuilder cellBuilder;
const RowBanner({
required this.rowController,
required this.cellBuilder,
super.key,
});
@override
State<RowBanner> createState() => _RowBannerState();
}
class _RowBannerState extends State<RowBanner> {
final _isHovering = ValueNotifier(false);
final popoverController = PopoverController();
@override
Widget build(BuildContext context) {
return BlocProvider<RowBannerBloc>(
create: (context) => RowBannerBloc(
viewId: widget.rowController.viewId,
rowMeta: widget.rowController.rowMeta,
)..add(const RowBannerEvent.initial()),
child: MouseRegion(
onEnter: (event) => _isHovering.value = true,
onExit: (event) => _isHovering.value = false,
child: Stack(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(60, 34, 60, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 30,
child: _BannerAction(
isHovering: _isHovering,
popoverController: popoverController,
),
),
const HSpace(4),
_BannerTitle(
cellBuilder: widget.cellBuilder,
popoverController: popoverController,
rowController: widget.rowController,
),
],
),
),
Positioned(
top: 12,
right: 12,
child: RowActionButton(rowController: widget.rowController),
),
],
),
),
);
}
}
class _BannerAction extends StatelessWidget {
final ValueNotifier<bool> isHovering;
final PopoverController popoverController;
const _BannerAction({
required this.isHovering,
required this.popoverController,
});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: isHovering,
builder: (BuildContext context, bool value, Widget? child) {
if (!value) {
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 {
final GridCellBuilder cellBuilder;
final PopoverController popoverController;
final RowController rowController;
const _BannerTitle({
required this.cellBuilder,
required this.popoverController,
required this.rowController,
});
@override
State<_BannerTitle> createState() => _BannerTitleState();
}
class _BannerTitleState extends State<_BannerTitle> {
@override
Widget build(BuildContext context) {
return BlocBuilder<RowBannerBloc, RowBannerState>(
builder: (context, state) {
final children = <Widget>[];
if (state.rowMeta.icon.isNotEmpty) {
children.add(
EmojiButton(
emoji: state.rowMeta.icon,
showEmojiPicker: () => widget.popoverController.show(),
),
);
}
children.add(const HSpace(4));
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(
Expanded(
child: widget.cellBuilder.build(cellContext, style: style),
),
);
}
return AppFlowyPopover(
controller: widget.popoverController,
triggerActions: PopoverTriggerFlags.none,
direction: PopoverDirection.bottomWithLeftAligned,
constraints: const BoxConstraints(maxWidth: 380, maxHeight: 300),
popupBuilder: (popoverContext) => _buildEmojiPicker((emoji) {
context.read<RowBannerBloc>().add(RowBannerEvent.setIcon(emoji));
widget.popoverController.close();
}),
child: Row(children: children),
);
},
);
}
}
typedef OnSubmittedEmoji = void Function(String emoji);
const _kBannerActionHeight = 40.0;
class EmojiButton extends StatelessWidget {
final String emoji;
final VoidCallback showEmojiPicker;
const EmojiButton({
required this.emoji,
required this.showEmojiPicker,
super.key,
});
@override
Widget build(BuildContext context) {
return SizedBox(
height: _kBannerActionHeight,
width: _kBannerActionHeight,
child: FlowyButton(
margin: EdgeInsets.zero,
text: FlowyText.medium(
emoji,
fontSize: 30,
textAlign: TextAlign.center,
),
onTap: showEmojiPicker,
),
);
}
}
class EmojiPickerButton extends StatefulWidget {
final VoidCallback showEmojiPicker;
const EmojiPickerButton({
super.key,
required this.showEmojiPicker,
});
@override
State<EmojiPickerButton> createState() => _EmojiPickerButtonState();
}
class _EmojiPickerButtonState extends State<EmojiPickerButton> {
@override
Widget build(BuildContext context) {
return SizedBox(
height: 26,
child: FlowyButton(
useIntrinsicWidth: true,
text: FlowyText.medium(
LocaleKeys.document_plugins_cover_addIcon.tr(),
),
leftIcon: const FlowySvg(FlowySvgs.emoji_s),
onTap: widget.showEmojiPicker,
margin: const EdgeInsets.all(4),
),
);
}
}
class RemoveEmojiButton extends StatelessWidget {
final VoidCallback onRemoved;
RemoveEmojiButton({
super.key,
required this.onRemoved,
});
final popoverController = PopoverController();
@override
Widget build(BuildContext context) {
return SizedBox(
height: 26,
child: FlowyButton(
useIntrinsicWidth: true,
text: FlowyText.medium(
LocaleKeys.document_plugins_cover_removeIcon.tr(),
),
leftIcon: const FlowySvg(FlowySvgs.emoji_s),
onTap: onRemoved,
margin: const EdgeInsets.all(4),
),
);
}
}
Widget _buildEmojiPicker(OnSubmittedEmoji onSubmitted) {
return EmojiSelectionMenu(
onSubmitted: onSubmitted,
onExit: () {},
);
}
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

@ -0,0 +1,90 @@
import 'package:appflowy/plugins/database/application/field/field_controller.dart';
import 'package:appflowy/plugins/database/application/row/row_controller.dart';
import 'package:appflowy/plugins/database/grid/application/row/row_detail_bloc.dart';
import 'package:appflowy/plugins/database/widgets/row/row_document.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'cell_builder.dart';
import 'row_banner.dart';
import 'row_property.dart';
class RowDetailPage extends StatefulWidget with FlowyOverlayDelegate {
final FieldController fieldController;
final RowController rowController;
final GridCellBuilder cellBuilder;
const RowDetailPage({
super.key,
required this.fieldController,
required this.rowController,
required this.cellBuilder,
});
@override
State<RowDetailPage> createState() => _RowDetailPageState();
static String identifier() {
return (RowDetailPage).toString();
}
}
class _RowDetailPageState extends State<RowDetailPage> {
final scrollController = ScrollController();
@override
void dispose() {
scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FlowyDialog(
child: MultiBlocProvider(
providers: [
BlocProvider(
create: (context) =>
RowDetailBloc(rowController: widget.rowController)
..add(const RowDetailEvent.initial()),
),
BlocProvider.value(
value: getIt<ReminderBloc>(),
),
],
child: ListView(
controller: scrollController,
children: [
RowBanner(
rowController: widget.rowController,
cellBuilder: widget.cellBuilder,
),
const VSpace(16),
Padding(
padding: const EdgeInsets.only(left: 40, right: 60),
child: RowPropertyList(
cellBuilder: widget.cellBuilder,
viewId: widget.rowController.viewId,
fieldController: widget.fieldController,
),
),
const VSpace(20),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 60),
child: Divider(height: 1.0),
),
const VSpace(20),
RowDocument(
viewId: widget.rowController.viewId,
rowId: widget.rowController.rowId,
scrollController: scrollController,
),
],
),
),
);
}
}

View File

@ -0,0 +1,139 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/grid/application/row/row_document_bloc.dart';
import 'package:appflowy/plugins/document/application/doc_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/error_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class RowDocument extends StatelessWidget {
const RowDocument({
super.key,
required this.viewId,
required this.rowId,
required this.scrollController,
});
final String viewId;
final String rowId;
final ScrollController scrollController;
@override
Widget build(BuildContext context) {
return BlocProvider<RowDocumentBloc>(
create: (context) => RowDocumentBloc(viewId: viewId, rowId: rowId)
..add(const RowDocumentEvent.initial()),
child: BlocBuilder<RowDocumentBloc, RowDocumentState>(
builder: (context, state) {
return state.loadingState.when(
loading: () => const Center(
child: CircularProgressIndicator.adaptive(),
),
error: (error) => FlowyErrorPage.message(
error.toString(),
howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(),
),
finish: () => RowEditor(
viewPB: state.viewPB!,
scrollController: scrollController,
onIsEmptyChanged: (isEmpty) => context
.read<RowDocumentBloc>()
.add(RowDocumentEvent.updateIsEmpty(isEmpty)),
),
);
},
),
);
}
}
class RowEditor extends StatefulWidget {
const RowEditor({
super.key,
required this.viewPB,
required this.scrollController,
this.onIsEmptyChanged,
});
final ViewPB viewPB;
final ScrollController scrollController;
final void Function(bool)? onIsEmptyChanged;
@override
State<RowEditor> createState() => _RowEditorState();
}
class _RowEditorState extends State<RowEditor> {
late final DocumentBloc documentBloc;
@override
void initState() {
super.initState();
documentBloc = DocumentBloc(view: widget.viewPB)
..add(const DocumentEvent.initial());
}
@override
void dispose() {
documentBloc.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider.value(value: documentBloc),
],
child: BlocListener<DocumentBloc, DocumentState>(
listenWhen: (previous, current) =>
previous.isDocumentEmpty != current.isDocumentEmpty,
listener: (context, state) {
if (state.isDocumentEmpty != null) {
widget.onIsEmptyChanged?.call(state.isDocumentEmpty!);
}
},
child: BlocBuilder<DocumentBloc, DocumentState>(
builder: (context, state) {
if (state.isLoading) {
return const Center(child: CircularProgressIndicator.adaptive());
}
final editorState = state.editorState;
final error = state.error;
if (error != null || editorState == null) {
Log.error(error);
return FlowyErrorPage.message(
error.toString(),
howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(),
);
}
return IntrinsicHeight(
child: Container(
constraints: const BoxConstraints(minHeight: 300),
child: AppFlowyEditorPage(
shrinkWrap: true,
autoFocus: false,
editorState: editorState,
scrollController: widget.scrollController,
styleCustomizer: EditorStyleCustomizer(
context: context,
padding: const EdgeInsets.only(left: 16, right: 54),
),
showParagraphPlaceholder: (editorState, node) =>
editorState.document.isEmpty,
placeholderText: (node) =>
LocaleKeys.cardDetails_notesPlaceholder.tr(),
),
),
);
},
),
),
);
}
}

View File

@ -0,0 +1,451 @@
import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/cell/cell_service.dart';
import 'package:appflowy/plugins/database/application/field/field_controller.dart';
import 'package:appflowy/plugins/database/application/field/field_service.dart';
import 'package:appflowy/plugins/database/grid/application/row/row_detail_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/header/field_editor.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cells.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_button.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:collection/collection.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:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'accessory/cell_accessory.dart';
import 'cell_builder.dart';
/// Display the row properties in a list. Only use this widget in the
/// [RowDetailPage].
class RowPropertyList extends StatelessWidget {
final String viewId;
final FieldController fieldController;
final GridCellBuilder cellBuilder;
const RowPropertyList({
super.key,
required this.viewId,
required this.fieldController,
required this.cellBuilder,
});
@override
Widget build(BuildContext context) {
return BlocBuilder<RowDetailBloc, RowDetailState>(
builder: (context, state) {
final children = state.visibleCells
.where((element) => !element.fieldInfo.field.isPrimary)
.mapIndexed(
(index, cell) => _PropertyCell(
key: ValueKey('row_detail_${cell.fieldId}'),
cellContext: cell,
cellBuilder: cellBuilder,
fieldController: fieldController,
index: index,
),
)
.toList();
return ReorderableListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
onReorder: (from, to) {
context
.read<RowDetailBloc>()
.add(RowDetailEvent.reorderField(from, to));
},
buildDefaultDragHandles: false,
proxyDecorator: (child, index, animation) => Material(
color: Colors.transparent,
child: Stack(
children: [
child,
MouseRegion(
cursor: Platform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grabbing,
child: const SizedBox(
width: 16,
height: 30,
child: FlowySvg(FlowySvgs.drag_element_s),
),
),
],
),
),
footer: Padding(
padding: const EdgeInsets.only(left: 20),
child: Column(
children: [
if (context.read<RowDetailBloc>().state.numHiddenFields != 0)
const Padding(
padding: EdgeInsets.only(bottom: 4.0),
child: ToggleHiddenFieldsVisibilityButton(),
),
CreateRowFieldButton(
viewId: viewId,
fieldController: fieldController,
),
],
),
),
children: children,
);
},
);
}
}
class _PropertyCell extends StatefulWidget {
final DatabaseCellContext cellContext;
final GridCellBuilder cellBuilder;
final FieldController fieldController;
final int index;
const _PropertyCell({
super.key,
required this.cellContext,
required this.cellBuilder,
required this.fieldController,
required this.index,
});
@override
State<StatefulWidget> createState() => _PropertyCellState();
}
class _PropertyCellState extends State<_PropertyCell> {
final PopoverController _popoverController = PopoverController();
final PopoverController _fieldPopoverController = PopoverController();
final ValueNotifier<bool> _isFieldHover = ValueNotifier(false);
@override
Widget build(BuildContext context) {
final style = customCellStyle(widget.cellContext.fieldType);
final cell = widget.cellBuilder.build(widget.cellContext, style: style);
final dragThumb = MouseRegion(
cursor: Platform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grab,
child: SizedBox(
width: 16,
height: 30,
child: AppFlowyPopover(
controller: _fieldPopoverController,
constraints: BoxConstraints.loose(const Size(240, 600)),
margin: EdgeInsets.zero,
triggerActions: PopoverTriggerFlags.none,
direction: PopoverDirection.bottomWithLeftAligned,
popupBuilder: (popoverContext) => buildFieldEditor(),
child: ValueListenableBuilder(
valueListenable: _isFieldHover,
builder: ((context, value, child) {
return value ? child! : const SizedBox.shrink();
}),
child: BlockActionButton(
onTap: () => _fieldPopoverController.show(),
svg: FlowySvgs.drag_element_s,
richMessage: TextSpan(
text: LocaleKeys.grid_rowPage_fieldDragElementTooltip.tr(),
),
),
),
),
),
);
final gesture = GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => cell.requestFocus.notify(),
child: AccessoryHover(
fieldType: widget.cellContext.fieldType,
child: cell,
),
);
return Container(
margin: const EdgeInsets.only(bottom: 8),
constraints: const BoxConstraints(minHeight: 30),
child: MouseRegion(
onEnter: (event) => _isFieldHover.value = true,
onExit: (event) => _isFieldHover.value = false,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
ValueListenableBuilder(
valueListenable: _isFieldHover,
builder: (context, value, _) {
return ReorderableDragStartListener(
index: widget.index,
enabled: value,
child: dragThumb,
);
},
),
const HSpace(4),
AppFlowyPopover(
controller: _popoverController,
constraints: BoxConstraints.loose(const Size(240, 600)),
margin: EdgeInsets.zero,
triggerActions: PopoverTriggerFlags.none,
direction: PopoverDirection.bottomWithLeftAligned,
popupBuilder: (popoverContext) => buildFieldEditor(),
child: SizedBox(
width: 160,
height: 30,
child: Tooltip(
waitDuration: const Duration(seconds: 1),
preferBelow: false,
verticalOffset: 15,
message: widget.cellContext.fieldInfo.field.name,
child: FieldCellButton(
field: widget.cellContext.fieldInfo.field,
onTap: () => _popoverController.show(),
radius: BorderRadius.circular(6),
margin:
const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
),
),
),
),
const HSpace(8),
Expanded(child: gesture),
],
),
),
);
}
Widget buildFieldEditor() {
return FieldEditor(
viewId: widget.cellContext.viewId,
field: widget.cellContext.fieldInfo.field,
fieldController: widget.fieldController,
);
}
}
GridCellStyle? customCellStyle(FieldType fieldType) {
switch (fieldType) {
case FieldType.Checkbox:
return GridCheckboxCellStyle(
cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
);
case FieldType.DateTime:
return DateCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
alignment: Alignment.centerLeft,
cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
);
case FieldType.LastEditedTime:
case FieldType.CreatedTime:
return TimestampCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
alignment: Alignment.centerLeft,
cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
);
case FieldType.MultiSelect:
return SelectOptionCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
);
case FieldType.Checklist:
return ChecklistCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
cellPadding: EdgeInsets.zero,
showTasksInline: true,
);
case FieldType.Number:
return GridNumberCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
);
case FieldType.RichText:
return GridTextCellStyle(
cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
showEmoji: false,
);
case FieldType.SingleSelect:
return SelectOptionCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
);
case FieldType.URL:
return GridURLCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
accessoryTypes: [
GridURLCellAccessoryType.copyURL,
GridURLCellAccessoryType.visitURL,
],
);
}
throw UnimplementedError;
}
class ToggleHiddenFieldsVisibilityButton extends StatelessWidget {
const ToggleHiddenFieldsVisibilityButton({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<RowDetailBloc, RowDetailState>(
builder: (context, state) {
final text = switch (state.showHiddenFields) {
false => LocaleKeys.grid_rowPage_showHiddenFields.plural(
state.numHiddenFields,
namedArgs: {'count': '${state.numHiddenFields}'},
),
true => LocaleKeys.grid_rowPage_hideHiddenFields.plural(
state.numHiddenFields,
namedArgs: {'count': '${state.numHiddenFields}'},
),
};
if (PlatformExtension.isDesktop) {
return SizedBox(
height: 30,
child: FlowyButton(
text: FlowyText.medium(text, color: Theme.of(context).hintColor),
hoverColor: AFThemeExtension.of(context).lightGreyHover,
leftIcon: RotatedBox(
quarterTurns: state.showHiddenFields ? 1 : 3,
child: FlowySvg(
FlowySvgs.arrow_left_s,
color: Theme.of(context).hintColor,
),
),
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
onTap: () => context.read<RowDetailBloc>().add(
const RowDetailEvent.toggleHiddenFieldVisibility(),
),
),
);
} else {
return ConstrainedBox(
constraints: const BoxConstraints(minWidth: double.infinity),
child: TextButton.icon(
style: Theme.of(context).textButtonTheme.style?.copyWith(
shape: MaterialStateProperty.all<RoundedRectangleBorder>(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
side: BorderSide.none,
),
),
overlayColor: MaterialStateProperty.all<Color>(
Theme.of(context).hoverColor,
),
alignment: AlignmentDirectional.centerStart,
splashFactory: NoSplash.splashFactory,
padding: const MaterialStatePropertyAll(
EdgeInsets.symmetric(vertical: 14, horizontal: 6),
),
),
label: FlowyText.medium(
text,
fontSize: 15,
color: Theme.of(context).hintColor,
),
onPressed: () => context
.read<RowDetailBloc>()
.add(const RowDetailEvent.toggleHiddenFieldVisibility()),
icon: RotatedBox(
quarterTurns: state.showHiddenFields ? 1 : 3,
child: FlowySvg(
FlowySvgs.arrow_left_s,
color: Theme.of(context).hintColor,
),
),
),
);
}
},
);
}
}
class CreateRowFieldButton extends StatefulWidget {
final String viewId;
final FieldController fieldController;
const CreateRowFieldButton({
super.key,
required this.viewId,
required this.fieldController,
});
@override
State<CreateRowFieldButton> createState() => _CreateRowFieldButtonState();
}
class _CreateRowFieldButtonState extends State<CreateRowFieldButton> {
late PopoverController popoverController;
FieldPB? createdField;
@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 FieldBackendService.createField(
viewId: widget.viewId,
);
result.fold(
(newField) {
createdField = newField;
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) {
if (createdField == null) {
return const SizedBox.shrink();
}
return FieldEditor(
viewId: widget.viewId,
field: createdField!,
fieldController: widget.fieldController,
);
},
);
}
}

View File

@ -0,0 +1,96 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/database/application/layout/layout_bloc.dart';
import 'package:appflowy/plugins/database/widgets/database_layout_ext.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pb.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../grid/presentation/layout/sizes.dart';
class DatabaseLayoutSelector extends StatefulWidget {
const DatabaseLayoutSelector({
super.key,
required this.viewId,
required this.currentLayout,
});
final String viewId;
final DatabaseLayoutPB currentLayout;
@override
State<StatefulWidget> createState() => _DatabaseLayoutSelectorState();
}
class _DatabaseLayoutSelectorState extends State<DatabaseLayoutSelector> {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => DatabaseLayoutBloc(
viewId: widget.viewId,
databaseLayout: widget.currentLayout,
)..add(const DatabaseLayoutEvent.initial()),
child: BlocBuilder<DatabaseLayoutBloc, DatabaseLayoutState>(
builder: (context, state) {
final cells = DatabaseLayoutPB.values
.map(
(layout) => DatabaseViewLayoutCell(
databaseLayout: layout,
isSelected: state.databaseLayout == layout,
onTap: (selectedLayout) => context
.read<DatabaseLayoutBloc>()
.add(DatabaseLayoutEvent.updateLayout(selectedLayout)),
),
)
.toList();
return ListView.separated(
shrinkWrap: true,
itemCount: cells.length,
padding: const EdgeInsets.symmetric(vertical: 6.0),
itemBuilder: (_, int index) => cells[index],
separatorBuilder: (_, __) =>
VSpace(GridSize.typeOptionSeparatorHeight),
);
},
),
);
}
}
class DatabaseViewLayoutCell extends StatelessWidget {
const DatabaseViewLayoutCell({
super.key,
required this.isSelected,
required this.databaseLayout,
required this.onTap,
});
final bool isSelected;
final DatabaseLayoutPB databaseLayout;
final void Function(DatabaseLayoutPB) onTap;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: SizedBox(
height: GridSize.popoverItemHeight,
child: FlowyButton(
hoverColor: AFThemeExtension.of(context).lightGreyHover,
text: FlowyText.medium(
databaseLayout.layoutName,
color: AFThemeExtension.of(context).textColor,
),
leftIcon: FlowySvg(
databaseLayout.icon,
color: Theme.of(context).iconTheme.color,
),
rightIcon: isSelected ? const FlowySvg(FlowySvgs.check_s) : null,
onTap: () => onTap(databaseLayout),
),
),
);
}
}

View File

@ -0,0 +1,97 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/calendar/presentation/toolbar/calendar_layout_setting.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/widgets/setting/database_layout_selector.dart';
import 'package:appflowy/plugins/database/widgets/group/database_group.dart';
import 'package:appflowy/plugins/database/widgets/setting/setting_property_list.dart';
import 'package:appflowy_popover/appflowy_popover.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:flutter/material.dart';
enum DatabaseSettingAction {
showProperties,
showLayout,
showGroup,
showCalendarLayout,
}
extension DatabaseSettingActionExtension on DatabaseSettingAction {
FlowySvgData iconData() {
switch (this) {
case DatabaseSettingAction.showProperties:
return FlowySvgs.properties_s;
case DatabaseSettingAction.showLayout:
return FlowySvgs.database_layout_m;
case DatabaseSettingAction.showGroup:
return FlowySvgs.group_s;
case DatabaseSettingAction.showCalendarLayout:
return FlowySvgs.calendar_layout_m;
}
}
String title() {
switch (this) {
case DatabaseSettingAction.showProperties:
return LocaleKeys.grid_settings_properties.tr();
case DatabaseSettingAction.showLayout:
return LocaleKeys.grid_settings_databaseLayout.tr();
case DatabaseSettingAction.showGroup:
return LocaleKeys.grid_settings_group.tr();
case DatabaseSettingAction.showCalendarLayout:
return LocaleKeys.calendar_settings_name.tr();
}
}
Widget build(
BuildContext context,
DatabaseController databaseController,
PopoverMutex popoverMutex,
) {
final popover = switch (this) {
DatabaseSettingAction.showLayout => DatabaseLayoutSelector(
viewId: databaseController.viewId,
currentLayout: databaseController.databaseLayout,
),
DatabaseSettingAction.showGroup => DatabaseGroupList(
viewId: databaseController.viewId,
databaseController: databaseController,
onDismissed: () {},
),
DatabaseSettingAction.showProperties => DatabasePropertyList(
viewId: databaseController.viewId,
fieldController: databaseController.fieldController,
),
DatabaseSettingAction.showCalendarLayout => CalendarLayoutSetting(
databaseController: databaseController,
),
};
return AppFlowyPopover(
triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click,
direction: PopoverDirection.leftWithTopAligned,
mutex: popoverMutex,
margin: EdgeInsets.zero,
offset: const Offset(-14, 0),
child: SizedBox(
height: GridSize.popoverItemHeight,
child: FlowyButton(
onTap: null,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
text: FlowyText.medium(
title(),
color: AFThemeExtension.of(context).textColor,
),
leftIcon: FlowySvg(
iconData(),
color: Theme.of(context).iconTheme.color,
),
),
),
popupBuilder: (context) => popover,
);
}
}

View File

@ -0,0 +1,74 @@
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/widgets/setting/database_setting_action.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/style_widget/scrolling/styled_list.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/widgets.dart';
class DatabaseSettingsList extends StatefulWidget {
const DatabaseSettingsList({
super.key,
required this.databaseController,
});
final DatabaseController databaseController;
@override
State<StatefulWidget> createState() => _DatabaseSettingsListState();
}
class _DatabaseSettingsListState extends State<DatabaseSettingsList> {
late final PopoverMutex popoverMutex = PopoverMutex();
@override
Widget build(BuildContext context) {
final cells =
actionsForDatabaseLayout(widget.databaseController.databaseLayout)
.map(
(action) => action.build(
context,
widget.databaseController,
popoverMutex,
),
)
.toList();
return ListView.separated(
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: cells.length,
separatorBuilder: (context, index) =>
VSpace(GridSize.typeOptionSeparatorHeight),
physics: StyledScrollPhysics(),
itemBuilder: (BuildContext context, int index) => cells[index],
);
}
}
/// Returns the list of actions that should be shown for the given database layout.
List<DatabaseSettingAction> actionsForDatabaseLayout(DatabaseLayoutPB? layout) {
switch (layout) {
case DatabaseLayoutPB.Board:
return [
DatabaseSettingAction.showProperties,
DatabaseSettingAction.showLayout,
if (!PlatformExtension.isMobile) DatabaseSettingAction.showGroup,
];
case DatabaseLayoutPB.Calendar:
return [
DatabaseSettingAction.showProperties,
DatabaseSettingAction.showLayout,
DatabaseSettingAction.showCalendarLayout,
];
case DatabaseLayoutPB.Grid:
return [
DatabaseSettingAction.showProperties,
DatabaseSettingAction.showLayout,
];
default:
return [];
}
}

View File

@ -0,0 +1,16 @@
import 'package:appflowy_backend/protobuf/flowy-database2/field_settings_entities.pb.dart';
extension ToggleVisibility on FieldVisibility {
FieldVisibility toggle() => switch (this) {
FieldVisibility.AlwaysShown => FieldVisibility.AlwaysHidden,
FieldVisibility.AlwaysHidden => FieldVisibility.AlwaysShown,
_ => FieldVisibility.AlwaysHidden,
};
bool isVisibleState() => switch (this) {
FieldVisibility.AlwaysShown => true,
FieldVisibility.HideWhenEmpty => true,
FieldVisibility.AlwaysHidden => false,
_ => false,
};
}

View File

@ -0,0 +1,141 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/database/view/database_view_list.dart';
import 'package:appflowy/mobile/presentation/database/view/edit_database_view_screen.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart';
import 'package:appflowy/plugins/database/grid/application/filter/filter_menu_bloc.dart';
import 'package:appflowy/plugins/database/grid/application/sort/sort_menu_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class MobileDatabaseControls extends StatelessWidget {
const MobileDatabaseControls({
super.key,
required this.controller,
required this.toggleExtension,
});
final DatabaseController controller;
final ToggleExtensionNotifier toggleExtension;
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider<GridFilterMenuBloc>(
create: (context) => GridFilterMenuBloc(
viewId: controller.viewId,
fieldController: controller.fieldController,
)..add(const GridFilterMenuEvent.initial()),
),
BlocProvider<SortMenuBloc>(
create: (context) => SortMenuBloc(
viewId: controller.viewId,
fieldController: controller.fieldController,
)..add(const SortMenuEvent.initial()),
),
],
child: MultiBlocListener(
listeners: [
BlocListener<GridFilterMenuBloc, GridFilterMenuState>(
listenWhen: (p, c) => p.isVisible != c.isVisible,
listener: (context, state) => toggleExtension.toggle(),
),
BlocListener<SortMenuBloc, SortMenuState>(
listenWhen: (p, c) => p.isVisible != c.isVisible,
listener: (context, state) => toggleExtension.toggle(),
),
],
child: ValueListenableBuilder<bool>(
valueListenable: controller.isLoading,
builder: (context, isLoading, child) {
if (isLoading) {
return const SizedBox.shrink();
}
return Row(
children: [
_DatabaseControlButton(
icon: FlowySvgs.settings_s,
onTap: () {
showMobileBottomSheet(
context,
padding: EdgeInsets.zero,
builder: (_) {
return BlocProvider<ViewBloc>(
create: (_) {
return ViewBloc(
view: context
.read<DatabaseTabBarBloc>()
.state
.tabBarControllerByViewId[controller.viewId]!
.view,
)..add(const ViewEvent.initial());
},
child: MobileEditDatabaseViewScreen(
databaseController: controller,
),
);
},
);
},
),
_DatabaseControlButton(
icon: FlowySvgs.align_left_s,
onTap: () {
showMobileBottomSheet(
context,
padding: EdgeInsets.zero,
builder: (_) {
return MultiBlocProvider(
providers: [
BlocProvider<ViewBloc>.value(
value: context.read<ViewBloc>(),
),
BlocProvider<DatabaseTabBarBloc>.value(
value: context.read<DatabaseTabBarBloc>(),
),
],
child: const MobileDatabaseViewList(),
);
},
);
},
),
],
);
},
),
),
);
}
}
class _DatabaseControlButton extends StatelessWidget {
const _DatabaseControlButton({
required this.onTap,
required this.icon,
});
final VoidCallback onTap;
final FlowySvgData icon;
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: 36,
child: IconButton(
splashRadius: 18,
padding: EdgeInsets.zero,
onPressed: onTap,
icon: FlowySvg(
icon,
size: const Size.square(20),
),
),
);
}
}

View File

@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/widgets/setting/database_settings_list.dart';
import 'package:appflowy_popover/appflowy_popover.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';
class SettingButton extends StatefulWidget {
const SettingButton({
super.key,
required this.databaseController,
});
final DatabaseController databaseController;
@override
State<SettingButton> createState() => _SettingButtonState();
}
class _SettingButtonState extends State<SettingButton> {
final PopoverController _popoverController = PopoverController();
@override
Widget build(BuildContext context) {
return AppFlowyPopover(
controller: _popoverController,
constraints: BoxConstraints.loose(const Size(200, 400)),
direction: PopoverDirection.bottomWithCenterAligned,
offset: const Offset(0, 8),
triggerActions: PopoverTriggerFlags.none,
child: FlowyTextButton(
LocaleKeys.settings_title.tr(),
fontColor: AFThemeExtension.of(context).textColor,
fontSize: FontSizes.s11,
fontWeight: FontWeight.w400,
fillColor: Colors.transparent,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
padding: GridSize.toolbarSettingButtonInsets,
radius: Corners.s4Border,
onPressed: _popoverController.show,
),
popupBuilder: (BuildContext context) => DatabaseSettingsList(
databaseController: widget.databaseController,
),
);
}
}

View File

@ -0,0 +1,205 @@
import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/database/application/field/field_controller.dart';
import 'package:appflowy/plugins/database/application/field/field_info.dart';
import 'package:appflowy/plugins/database/application/setting/property_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/header/field_editor.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/header/field_type_extension.dart';
import 'package:appflowy/plugins/database/widgets/setting/field_visibility_extension.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:collection/collection.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:styled_widget/styled_widget.dart';
class DatabasePropertyList extends StatefulWidget {
final String viewId;
final FieldController fieldController;
const DatabasePropertyList({
super.key,
required this.viewId,
required this.fieldController,
});
@override
State<StatefulWidget> createState() => _DatabasePropertyListState();
}
class _DatabasePropertyListState extends State<DatabasePropertyList> {
final PopoverMutex _popoverMutex = PopoverMutex();
late final DatabasePropertyBloc _bloc;
@override
void initState() {
super.initState();
_bloc = DatabasePropertyBloc(
viewId: widget.viewId,
fieldController: widget.fieldController,
)..add(const DatabasePropertyEvent.initial());
}
@override
Widget build(BuildContext context) {
return BlocProvider<DatabasePropertyBloc>.value(
value: _bloc,
child: BlocBuilder<DatabasePropertyBloc, DatabasePropertyState>(
builder: (context, state) {
final cells = state.fieldContexts
.mapIndexed(
(index, field) => DatabasePropertyCell(
key: ValueKey(field.id),
viewId: widget.viewId,
fieldController: widget.fieldController,
fieldInfo: field,
popoverMutex: _popoverMutex,
index: index,
),
)
.toList();
return ReorderableListView(
proxyDecorator: (child, index, _) => Material(
color: Colors.transparent,
child: Stack(
children: [
child,
MouseRegion(
cursor: Platform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grabbing,
child: const SizedBox.expand(),
),
],
),
),
buildDefaultDragHandles: false,
shrinkWrap: true,
onReorder: (from, to) {
context
.read<DatabasePropertyBloc>()
.add(DatabasePropertyEvent.moveField(from, to));
},
onReorderStart: (_) => _popoverMutex.close(),
padding: const EdgeInsets.symmetric(vertical: 6.0),
children: cells,
);
},
),
);
}
}
@visibleForTesting
class DatabasePropertyCell extends StatefulWidget {
const DatabasePropertyCell({
super.key,
required this.fieldInfo,
required this.viewId,
required this.popoverMutex,
required this.index,
required this.fieldController,
});
final FieldInfo fieldInfo;
final String viewId;
final PopoverMutex popoverMutex;
final int index;
final FieldController fieldController;
@override
State<DatabasePropertyCell> createState() => _DatabasePropertyCellState();
}
class _DatabasePropertyCellState extends State<DatabasePropertyCell> {
final PopoverController _popoverController = PopoverController();
@override
Widget build(BuildContext context) {
final visiblity = widget.fieldInfo.visibility;
final visibleIcon = FlowySvg(
visiblity != null && visiblity != FieldVisibility.AlwaysHidden
? FlowySvgs.show_m
: FlowySvgs.hide_m,
size: const Size.square(16),
color: Theme.of(context).iconTheme.color,
);
return AppFlowyPopover(
mutex: widget.popoverMutex,
controller: _popoverController,
offset: const Offset(-8, 0),
direction: PopoverDirection.leftWithTopAligned,
constraints: BoxConstraints.loose(const Size(240, 400)),
triggerActions: PopoverTriggerFlags.none,
margin: EdgeInsets.zero,
child: SizedBox(
height: GridSize.popoverItemHeight,
child: FlowyButton(
hoverColor: AFThemeExtension.of(context).lightGreyHover,
text: FlowyText.medium(
widget.fieldInfo.name,
color: AFThemeExtension.of(context).textColor,
),
leftIconSize: const Size(36, 18),
leftIcon: Row(
children: [
ReorderableDragStartListener(
index: widget.index,
child: MouseRegion(
cursor: Platform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grab,
child: SizedBox(
width: 14,
height: 14,
child: FlowySvg(
FlowySvgs.drag_element_s,
color: Theme.of(context).iconTheme.color,
),
),
),
),
const HSpace(6.0),
FlowySvg(
widget.fieldInfo.fieldType.icon(),
color: Theme.of(context).iconTheme.color,
),
],
),
rightIcon: FlowyIconButton(
hoverColor: Colors.transparent,
onPressed: () {
if (widget.fieldInfo.fieldSettings == null) {
return;
}
final newVisiblity =
widget.fieldInfo.fieldSettings!.visibility.toggle();
context.read<DatabasePropertyBloc>().add(
DatabasePropertyEvent.setFieldVisibility(
widget.fieldInfo.id,
newVisiblity,
),
);
},
icon: visibleIcon,
),
onTap: () => _popoverController.show(),
).padding(horizontal: 6.0),
),
popupBuilder: (BuildContext context) {
return FieldEditor(
viewId: widget.viewId,
field: widget.fieldInfo.field,
fieldController: widget.fieldController,
);
},
);
}
}

View File

@ -0,0 +1,156 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/share_bloc.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/string_extension.dart';
import 'package:appflowy/workspace/application/view/view_listener.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/file_picker/file_picker_service.dart';
import 'package:flowy_infra_ui/widget/rounded_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class DatabaseShareButton extends StatelessWidget {
const DatabaseShareButton({
super.key,
required this.view,
});
final ViewPB view;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => DatabaseShareBloc(view: view),
child: BlocListener<DatabaseShareBloc, DatabaseShareState>(
listener: (context, state) {
state.mapOrNull(
finish: (state) {
state.successOrFail.fold(
(data) => _handleExportData(context),
_handleExportError,
);
},
);
},
child: BlocBuilder<DatabaseShareBloc, DatabaseShareState>(
builder: (context, state) => ConstrainedBox(
constraints: const BoxConstraints.expand(
height: 30,
width: 100,
),
child: DatabaseShareActionList(view: view),
),
),
),
);
}
void _handleExportData(BuildContext context) {
showSnackBarMessage(
context,
LocaleKeys.settings_files_exportFileSuccess.tr(),
);
}
void _handleExportError(FlowyError error) {
showMessageToast(error.msg);
}
}
class DatabaseShareActionList extends StatefulWidget {
const DatabaseShareActionList({
super.key,
required this.view,
});
final ViewPB view;
@override
State<DatabaseShareActionList> createState() =>
DatabaseShareActionListState();
}
@visibleForTesting
class DatabaseShareActionListState extends State<DatabaseShareActionList> {
late String name;
late final ViewListener viewListener = ViewListener(viewId: widget.view.id);
@override
void initState() {
super.initState();
listenOnViewUpdated();
}
@override
void dispose() {
viewListener.stop();
super.dispose();
}
@override
Widget build(BuildContext context) {
final databaseShareBloc = context.read<DatabaseShareBloc>();
return PopoverActionList<ShareActionWrapper>(
direction: PopoverDirection.bottomWithCenterAligned,
offset: const Offset(0, 8),
actions: ShareAction.values
.map((action) => ShareActionWrapper(action))
.toList(),
buildChild: (controller) {
return RoundedTextButton(
title: LocaleKeys.shareAction_buttonText.tr(),
textColor: Theme.of(context).colorScheme.onPrimary,
onPressed: () => controller.show(),
);
},
onSelected: (action, controller) async {
switch (action.inner) {
case ShareAction.csv:
final exportPath = await getIt<FilePickerService>().saveFile(
dialogTitle: '',
fileName: '${name.toFileName()}.csv',
);
if (exportPath != null) {
databaseShareBloc.add(DatabaseShareEvent.shareCSV(exportPath));
}
break;
}
controller.close();
},
);
}
void listenOnViewUpdated() {
name = widget.view.name;
viewListener.start(
onViewUpdated: (view) {
name = view.name;
},
);
}
}
enum ShareAction {
csv,
}
class ShareActionWrapper extends ActionCell {
final ShareAction inner;
ShareActionWrapper(this.inner);
Widget? icon(Color iconColor) => null;
@override
String get name {
switch (inner) {
case ShareAction.csv:
return LocaleKeys.shareAction_csv.tr();
}
}
}