mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
refactor: rename database code path from database_view to database (#4310)
This commit is contained in:
@ -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,
|
||||
});
|
||||
}
|
@ -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,
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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,
|
||||
);
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class CardSizes {
|
||||
static EdgeInsets get cardCellPadding => const EdgeInsets.all(4);
|
||||
}
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
444
frontend/appflowy_flutter/lib/plugins/database/widgets/row/cell_builder.dart
Executable file
444
frontend/appflowy_flutter/lib/plugins/database/widgets/row/cell_builder.dart
Executable 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!);
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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';
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
@ -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";
|
||||
}
|
@ -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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -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();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -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();
|
||||
}
|
@ -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,
|
||||
});
|
||||
}
|
@ -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());
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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() ?? "",
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -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 ?? [],
|
||||
);
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
});
|
||||
}
|
@ -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];
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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,
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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 ?? "",
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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 ?? "",
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
@ -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);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
@ -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 [];
|
||||
}
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user