feat: timer field (#5349)

* feat: wip timer field

* feat: timer field fixing errors

* feat: wip timer field frontend

* fix: parsing TimerCellDataPB

* feat: parse time string to minutes

* fix: don't allow none number input

* fix: timer filter

* style: cargo fmt

* fix: clippy errors

* refactor: rename field type timer to time

* refactor: missed some variable and files to rename

* style: cargo fmt fix

* feat: format time field type data in frontend

* style: fix cargo fmt

* fix: fixes after merge

---------

Co-authored-by: Mathias Mogensen <mathiasrieckm@gmail.com>
This commit is contained in:
Mohammad Zolfaghari 2024-06-13 10:22:13 +03:30 committed by GitHub
parent 2d4300e931
commit aa621a8d84
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
57 changed files with 1579 additions and 26 deletions

View File

@ -28,6 +28,7 @@ const mobileSupportedFieldTypes = [
FieldType.CreatedTime,
FieldType.Checkbox,
FieldType.Checklist,
FieldType.Time,
];
Future<FieldType?> showFieldTypeGridBottomSheet(

View File

@ -119,6 +119,7 @@ class FieldOptionValues {
case FieldType.RichText:
case FieldType.URL:
case FieldType.Checkbox:
case FieldType.Time:
return null;
case FieldType.Number:
return NumberTypeOptionPB(

View File

@ -0,0 +1,117 @@
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/util/time.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
part 'time_cell_bloc.freezed.dart';
class TimeCellBloc extends Bloc<TimeCellEvent, TimeCellState> {
TimeCellBloc({
required this.cellController,
}) : super(TimeCellState.initial(cellController)) {
_dispatch();
_startListening();
}
final TimeCellController cellController;
void Function()? _onCellChangedFn;
@override
Future<void> close() async {
if (_onCellChangedFn != null) {
cellController.removeListener(
onCellChanged: _onCellChangedFn!,
onFieldChanged: _onFieldChangedListener,
);
}
await cellController.dispose();
return super.close();
}
void _dispatch() {
on<TimeCellEvent>(
(event, emit) async {
await event.when(
didReceiveCellUpdate: (content) {
emit(
state.copyWith(
content:
content != null ? formatTime(content.time.toInt()) : "",
),
);
},
didUpdateField: (fieldInfo) {
final wrap = fieldInfo.wrapCellContent;
if (wrap != null) {
emit(state.copyWith(wrap: wrap));
}
},
updateCell: (text) async {
text = parseTime(text)?.toString() ?? text;
if (state.content != text) {
emit(state.copyWith(content: 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(
TimeCellEvent.didReceiveCellUpdate(
cellController.getCellData(),
),
);
}
},
);
},
);
}
void _startListening() {
_onCellChangedFn = cellController.addListener(
onCellChanged: (cellContent) {
if (!isClosed) {
add(TimeCellEvent.didReceiveCellUpdate(cellContent));
}
},
onFieldChanged: _onFieldChangedListener,
);
}
void _onFieldChangedListener(FieldInfo fieldInfo) {
if (!isClosed) {
add(TimeCellEvent.didUpdateField(fieldInfo));
}
}
}
@freezed
class TimeCellEvent with _$TimeCellEvent {
const factory TimeCellEvent.didReceiveCellUpdate(TimeCellDataPB? cell) =
_DidReceiveCellUpdate;
const factory TimeCellEvent.didUpdateField(FieldInfo fieldInfo) =
_DidUpdateField;
const factory TimeCellEvent.updateCell(String text) = _UpdateCell;
}
@freezed
class TimeCellState with _$TimeCellState {
const factory TimeCellState({
required String content,
required bool wrap,
}) = _TimeCellState;
factory TimeCellState.initial(TimeCellController cellController) {
final wrap = cellController.fieldInfo.wrapCellContent;
final cellData = cellController.getCellData();
return TimeCellState(
content: cellData != null ? formatTime(cellData.time.toInt()) : "",
wrap: wrap ?? true,
);
}
}

View File

@ -16,6 +16,7 @@ typedef TimestampCellController = CellController<TimestampCellDataPB, String>;
typedef URLCellController = CellController<URLCellDataPB, String>;
typedef RelationCellController = CellController<RelationCellDataPB, String>;
typedef SummaryCellController = CellController<String, String>;
typedef TimeCellController = CellController<TimeCellDataPB, String>;
typedef TranslateCellController = CellController<String, String>;
CellController makeCellController(
@ -121,7 +122,6 @@ CellController makeCellController(
),
cellDataPersistence: TextCellDataPersistence(),
);
case FieldType.Relation:
return RelationCellController(
viewId: viewId,
@ -146,6 +146,18 @@ CellController makeCellController(
),
cellDataPersistence: TextCellDataPersistence(),
);
case FieldType.Time:
return TimeCellController(
viewId: viewId,
fieldController: fieldController,
cellContext: cellContext,
rowCache: rowCache,
cellDataLoader: CellDataLoader(
parser: TimeCellDataParser(),
reloadOnFieldChange: true,
),
cellDataPersistence: TextCellDataPersistence(),
);
case FieldType.Translate:
return TranslateCellController(
viewId: viewId,

View File

@ -181,3 +181,18 @@ class RelationCellDataParser implements CellDataParser<RelationCellDataPB> {
}
}
}
class TimeCellDataParser implements CellDataParser<TimeCellDataPB> {
@override
TimeCellDataPB? parserData(List<int> data) {
if (data.isEmpty) {
return null;
}
try {
return TimeCellDataPB.fromBuffer(data);
} catch (e) {
Log.error("Failed to parse timer data: $e");
return null;
}
}
}

View File

@ -64,6 +64,7 @@ class FieldInfo with _$FieldInfo {
case FieldType.SingleSelect:
case FieldType.Checklist:
case FieldType.URL:
case FieldType.Time:
return true;
default:
return false;
@ -85,6 +86,7 @@ class FieldInfo with _$FieldInfo {
case FieldType.LastEditedTime:
case FieldType.CreatedTime:
case FieldType.Checklist:
case FieldType.Time:
return true;
default:
return false;

View File

@ -202,6 +202,30 @@ class FilterBackendService {
);
}
Future<FlowyResult<void, FlowyError>> insertTimeFilter({
required String fieldId,
String? filterId,
required NumberFilterConditionPB condition,
String content = "",
}) {
final filter = TimeFilterPB()
..condition = condition
..content = content;
return filterId == null
? insertFilter(
fieldId: fieldId,
fieldType: FieldType.Time,
data: filter.writeToBuffer(),
)
: updateFilter(
filterId: filterId,
fieldId: fieldId,
fieldType: FieldType.Time,
data: filter.writeToBuffer(),
);
}
Future<FlowyResult<void, FlowyError>> insertFilter({
required String fieldId,
required FieldType fieldType,

View File

@ -127,6 +127,11 @@ class GridCreateFilterBloc
fieldId: fieldId,
condition: NumberFilterConditionPB.Equal,
);
case FieldType.Time:
return _filterBackendSvc.insertTimeFilter(
fieldId: fieldId,
condition: NumberFilterConditionPB.Equal,
);
case FieldType.RichText:
return _filterBackendSvc.insertTextFilter(
fieldId: fieldId,

View File

@ -0,0 +1,111 @@
import 'dart:async';
import 'package:appflowy/plugins/database/domain/filter_listener.dart';
import 'package:appflowy/plugins/database/domain/filter_service.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/filter_info.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'time_filter_editor_bloc.freezed.dart';
class TimeFilterEditorBloc
extends Bloc<TimeFilterEditorEvent, TimeFilterEditorState> {
TimeFilterEditorBloc({required this.filterInfo})
: _filterBackendSvc = FilterBackendService(viewId: filterInfo.viewId),
_listener = FilterListener(
viewId: filterInfo.viewId,
filterId: filterInfo.filter.id,
),
super(TimeFilterEditorState.initial(filterInfo)) {
_dispatch();
_startListening();
}
final FilterInfo filterInfo;
final FilterBackendService _filterBackendSvc;
final FilterListener _listener;
void _dispatch() {
on<TimeFilterEditorEvent>(
(event, emit) async {
event.when(
didReceiveFilter: (filter) {
final filterInfo = state.filterInfo.copyWith(filter: filter);
emit(
state.copyWith(
filterInfo: filterInfo,
filter: filterInfo.timeFilter()!,
),
);
},
updateCondition: (NumberFilterConditionPB condition) {
_filterBackendSvc.insertTimeFilter(
filterId: filterInfo.filter.id,
fieldId: filterInfo.fieldInfo.id,
condition: condition,
content: state.filter.content,
);
},
updateContent: (content) {
_filterBackendSvc.insertTimeFilter(
filterId: filterInfo.filter.id,
fieldId: filterInfo.fieldInfo.id,
condition: state.filter.condition,
content: content,
);
},
delete: () {
_filterBackendSvc.deleteFilter(
fieldId: filterInfo.fieldInfo.id,
filterId: filterInfo.filter.id,
);
},
);
},
);
}
void _startListening() {
_listener.start(
onUpdated: (filter) {
if (!isClosed) {
add(TimeFilterEditorEvent.didReceiveFilter(filter));
}
},
);
}
@override
Future<void> close() async {
await _listener.stop();
return super.close();
}
}
@freezed
class TimeFilterEditorEvent with _$TimeFilterEditorEvent {
const factory TimeFilterEditorEvent.didReceiveFilter(FilterPB filter) =
_DidReceiveFilter;
const factory TimeFilterEditorEvent.updateCondition(
NumberFilterConditionPB condition,
) = _UpdateCondition;
const factory TimeFilterEditorEvent.updateContent(String content) =
_UpdateContent;
const factory TimeFilterEditorEvent.delete() = _Delete;
}
@freezed
class TimeFilterEditorState with _$TimeFilterEditorState {
const factory TimeFilterEditorState({
required FilterInfo filterInfo,
required TimeFilterPB filter,
}) = _TimeFilterEditorState;
factory TimeFilterEditorState.initial(FilterInfo filterInfo) {
return TimeFilterEditorState(
filterInfo: filterInfo,
filter: filterInfo.timeFilter()!,
);
}
}

View File

@ -0,0 +1,227 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/grid/application/filter/time_filter_editor_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.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 '../condition_button.dart';
import '../disclosure_button.dart';
import '../filter_info.dart';
import 'choicechip.dart';
class TimeFilterChoiceChip extends StatefulWidget {
const TimeFilterChoiceChip({
super.key,
required this.filterInfo,
});
final FilterInfo filterInfo;
@override
State<TimeFilterChoiceChip> createState() => _TimeFilterChoiceChipState();
}
class _TimeFilterChoiceChipState extends State<TimeFilterChoiceChip> {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => TimeFilterEditorBloc(
filterInfo: widget.filterInfo,
),
child: BlocBuilder<TimeFilterEditorBloc, TimeFilterEditorState>(
builder: (context, state) {
return AppFlowyPopover(
constraints: BoxConstraints.loose(const Size(200, 100)),
direction: PopoverDirection.bottomWithCenterAligned,
popupBuilder: (_) {
return BlocProvider.value(
value: context.read<TimeFilterEditorBloc>(),
child: const TimeFilterEditor(),
);
},
child: ChoiceChipButton(
filterInfo: state.filterInfo,
),
);
},
),
);
}
}
class TimeFilterEditor extends StatefulWidget {
const TimeFilterEditor({super.key});
@override
State<TimeFilterEditor> createState() => _TimeFilterEditorState();
}
class _TimeFilterEditorState extends State<TimeFilterEditor> {
final popoverMutex = PopoverMutex();
@override
Widget build(BuildContext context) {
return BlocBuilder<TimeFilterEditorBloc, TimeFilterEditorState>(
builder: (context, state) {
final List<Widget> children = [
_buildFilterPanel(context, state),
if (state.filter.condition != NumberFilterConditionPB.NumberIsEmpty &&
state.filter.condition !=
NumberFilterConditionPB.NumberIsNotEmpty) ...[
const VSpace(4),
_buildFilterTimeField(context, state),
],
];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
child: IntrinsicHeight(child: Column(children: children)),
);
},
);
}
Widget _buildFilterPanel(
BuildContext context,
TimeFilterEditorState state,
) {
return SizedBox(
height: 20,
child: Row(
children: [
Expanded(
child: FlowyText(
state.filterInfo.fieldInfo.name,
overflow: TextOverflow.ellipsis,
),
),
const HSpace(4),
Expanded(
child: TimeFilterConditionPBList(
filterInfo: state.filterInfo,
popoverMutex: popoverMutex,
onCondition: (condition) {
context
.read<TimeFilterEditorBloc>()
.add(TimeFilterEditorEvent.updateCondition(condition));
},
),
),
const HSpace(4),
DisclosureButton(
popoverMutex: popoverMutex,
onAction: (action) {
switch (action) {
case FilterDisclosureAction.delete:
context
.read<TimeFilterEditorBloc>()
.add(const TimeFilterEditorEvent.delete());
break;
}
},
),
],
),
);
}
Widget _buildFilterTimeField(
BuildContext context,
TimeFilterEditorState state,
) {
return FlowyTextField(
text: state.filter.content,
hintText: LocaleKeys.grid_settings_typeAValue.tr(),
debounceDuration: const Duration(milliseconds: 300),
autoFocus: false,
onChanged: (text) {
context
.read<TimeFilterEditorBloc>()
.add(TimeFilterEditorEvent.updateContent(text));
},
);
}
}
class TimeFilterConditionPBList extends StatelessWidget {
const TimeFilterConditionPBList({
super.key,
required this.filterInfo,
required this.popoverMutex,
required this.onCondition,
});
final FilterInfo filterInfo;
final PopoverMutex popoverMutex;
final Function(NumberFilterConditionPB) onCondition;
@override
Widget build(BuildContext context) {
final timeFilter = filterInfo.timeFilter()!;
return PopoverActionList<ConditionWrapper>(
asBarrier: true,
mutex: popoverMutex,
direction: PopoverDirection.bottomWithCenterAligned,
actions: NumberFilterConditionPB.values
.map(
(action) => ConditionWrapper(
action,
timeFilter.condition == action,
),
)
.toList(),
buildChild: (controller) {
return ConditionButton(
conditionName: timeFilter.condition.filterName,
onTap: () => controller.show(),
);
},
onSelected: (action, controller) {
onCondition(action.inner);
controller.close();
},
);
}
}
class ConditionWrapper extends ActionCell {
ConditionWrapper(this.inner, this.isSelected);
final NumberFilterConditionPB inner;
final bool isSelected;
@override
Widget? rightIcon(Color iconColor) =>
isSelected ? const FlowySvg(FlowySvgs.check_s) : null;
@override
String get name => inner.filterName;
}
extension TimeFilterConditionPBExtension on NumberFilterConditionPB {
String get filterName {
return switch (this) {
NumberFilterConditionPB.Equal => LocaleKeys.grid_numberFilter_equal.tr(),
NumberFilterConditionPB.NotEqual =>
LocaleKeys.grid_numberFilter_notEqual.tr(),
NumberFilterConditionPB.LessThan =>
LocaleKeys.grid_numberFilter_lessThan.tr(),
NumberFilterConditionPB.LessThanOrEqualTo =>
LocaleKeys.grid_numberFilter_lessThanOrEqualTo.tr(),
NumberFilterConditionPB.GreaterThan =>
LocaleKeys.grid_numberFilter_greaterThan.tr(),
NumberFilterConditionPB.GreaterThanOrEqualTo =>
LocaleKeys.grid_numberFilter_greaterThanOrEqualTo.tr(),
NumberFilterConditionPB.NumberIsEmpty =>
LocaleKeys.grid_numberFilter_isEmpty.tr(),
NumberFilterConditionPB.NumberIsNotEmpty =>
LocaleKeys.grid_numberFilter_isNotEmpty.tr(),
_ => "",
};
}
}

View File

@ -60,4 +60,10 @@ class FilterInfo {
? NumberFilterPB.fromBuffer(filter.data.data)
: null;
}
TimeFilterPB? timeFilter() {
return filter.data.fieldType == FieldType.Time
? TimeFilterPB.fromBuffer(filter.data.data)
: null;
}
}

View File

@ -8,6 +8,7 @@ import 'choicechip/number.dart';
import 'choicechip/select_option/select_option.dart';
import 'choicechip/text.dart';
import 'choicechip/url.dart';
import 'choicechip/time.dart';
import 'filter_info.dart';
class FilterMenuItem extends StatelessWidget {
@ -22,12 +23,15 @@ class FilterMenuItem extends StatelessWidget {
FieldType.DateTime => DateFilterChoicechip(filterInfo: filterInfo),
FieldType.MultiSelect =>
SelectOptionFilterChoicechip(filterInfo: filterInfo),
FieldType.Number => NumberFilterChoiceChip(filterInfo: filterInfo),
FieldType.Number =>
NumberFilterChoiceChip(filterInfo: filterInfo),
FieldType.RichText => TextFilterChoicechip(filterInfo: filterInfo),
FieldType.SingleSelect =>
SelectOptionFilterChoicechip(filterInfo: filterInfo),
FieldType.URL => URLFilterChoiceChip(filterInfo: filterInfo),
FieldType.Checklist => ChecklistFilterChoicechip(filterInfo: filterInfo),
FieldType.Time =>
TimeFilterChoiceChip(filterInfo: filterInfo),
_ => const SizedBox(),
};
}

View File

@ -1,19 +1,21 @@
import 'package:flutter/widgets.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/relation_card_cell.dart';
import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/timestamp_card_cell.dart';
import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/translate_card_cell.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:flutter/widgets.dart';
import 'card_cell_skeleton/card_cell.dart';
import 'card_cell_skeleton/checkbox_card_cell.dart';
import 'card_cell_skeleton/checklist_card_cell.dart';
import 'card_cell_skeleton/date_card_cell.dart';
import 'card_cell_skeleton/number_card_cell.dart';
import 'card_cell_skeleton/relation_card_cell.dart';
import 'card_cell_skeleton/select_option_card_cell.dart';
import 'card_cell_skeleton/summary_card_cell.dart';
import 'card_cell_skeleton/text_card_cell.dart';
import 'card_cell_skeleton/time_card_cell.dart';
import 'card_cell_skeleton/timestamp_card_cell.dart';
import 'card_cell_skeleton/translate_card_cell.dart';
import 'card_cell_skeleton/url_card_cell.dart';
typedef CardCellStyleMap = Map<FieldType, CardCellStyle>;
@ -99,6 +101,12 @@ class CardCellBuilder {
databaseController: databaseController,
cellContext: cellContext,
),
FieldType.Time => TimeCardCell(
key: key,
style: isStyleOrNull(style),
databaseController: databaseController,
cellContext: cellContext,
),
FieldType.Translate => TranslateCardCell(
key: key,
style: isStyleOrNull(style),

View File

@ -0,0 +1,62 @@
import 'package:appflowy/plugins/database/application/cell/cell_controller.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/time_cell_bloc.dart';
import 'card_cell.dart';
class TimeCardCellStyle extends CardCellStyle {
const TimeCardCellStyle({
required super.padding,
required this.textStyle,
});
final TextStyle textStyle;
}
class TimeCardCell extends CardCell<TimeCardCellStyle> {
const TimeCardCell({
super.key,
required super.style,
required this.databaseController,
required this.cellContext,
});
final DatabaseController databaseController;
final CellContext cellContext;
@override
State<TimeCardCell> createState() => _TimeCellState();
}
class _TimeCellState extends State<TimeCardCell> {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) {
return TimeCellBloc(
cellController: makeCellController(
widget.databaseController,
widget.cellContext,
).as(),
);
},
child: BlocBuilder<TimeCellBloc, TimeCellState>(
buildWhen: (previous, current) => previous.content != current.content,
builder: (context, state) {
if (state.content.isEmpty) {
return const SizedBox.shrink();
}
return Container(
alignment: AlignmentDirectional.centerStart,
padding: widget.style.padding,
child: Text(state.content, style: widget.style.textStyle),
);
},
),
);
}
}

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/summary_card_cell.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:flutter/material.dart';
import '../card_cell_builder.dart';
import '../card_cell_skeleton/checkbox_card_cell.dart';
@ -10,6 +11,7 @@ import '../card_cell_skeleton/number_card_cell.dart';
import '../card_cell_skeleton/relation_card_cell.dart';
import '../card_cell_skeleton/select_option_card_cell.dart';
import '../card_cell_skeleton/text_card_cell.dart';
import '../card_cell_skeleton/time_card_cell.dart';
import '../card_cell_skeleton/timestamp_card_cell.dart';
import '../card_cell_skeleton/url_card_cell.dart';
@ -84,6 +86,10 @@ CardCellStyleMap desktopBoardCardCellStyleMap(BuildContext context) {
padding: padding,
textStyle: textStyle,
),
FieldType.Time: TimeCardCellStyle(
padding: padding,
textStyle: textStyle,
),
FieldType.Translate: SummaryCardCellStyle(
padding: padding,
textStyle: textStyle,

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/summary_card_cell.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:flutter/material.dart';
import '../card_cell_builder.dart';
import '../card_cell_skeleton/checkbox_card_cell.dart';
@ -10,6 +11,7 @@ import '../card_cell_skeleton/number_card_cell.dart';
import '../card_cell_skeleton/relation_card_cell.dart';
import '../card_cell_skeleton/select_option_card_cell.dart';
import '../card_cell_skeleton/text_card_cell.dart';
import '../card_cell_skeleton/time_card_cell.dart';
import '../card_cell_skeleton/timestamp_card_cell.dart';
import '../card_cell_skeleton/url_card_cell.dart';
@ -83,6 +85,10 @@ CardCellStyleMap mobileBoardCardCellStyleMap(BuildContext context) {
padding: padding,
textStyle: textStyle,
),
FieldType.Time: TimeCardCellStyle(
padding: padding,
textStyle: textStyle,
),
FieldType.Translate: SummaryCardCellStyle(
padding: padding,
textStyle: textStyle,

View File

@ -0,0 +1,37 @@
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/time_cell_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../editable_cell_skeleton/time.dart';
class DesktopGridTimeCellSkin extends IEditableTimeCellSkin {
@override
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
TimeCellBloc bloc,
FocusNode focusNode,
TextEditingController textEditingController,
) {
return TextField(
controller: textEditingController,
focusNode: focusNode,
onEditingComplete: () => focusNode.unfocus(),
onSubmitted: (_) => focusNode.unfocus(),
maxLines: context.watch<TimeCellBloc>().state.wrap ? null : 1,
style: Theme.of(context).textTheme.bodyMedium,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
contentPadding: GridSize.cellContentInsets,
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
disabledBorder: InputBorder.none,
isDense: true,
),
);
}
}

View File

@ -0,0 +1,40 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/time_cell_bloc.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import '../editable_cell_skeleton/time.dart';
class DesktopRowDetailTimeCellSkin extends IEditableTimeCellSkin {
@override
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
TimeCellBloc bloc,
FocusNode focusNode,
TextEditingController textEditingController,
) {
return TextField(
controller: textEditingController,
focusNode: focusNode,
onEditingComplete: () => focusNode.unfocus(),
onSubmitted: (_) => focusNode.unfocus(),
style: Theme.of(context).textTheme.bodyMedium,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 9),
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
disabledBorder: InputBorder.none,
hintText: LocaleKeys.grid_row_textPlaceholder.tr(),
hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).hintColor,
),
isDense: true,
),
);
}
}

View File

@ -1,9 +1,9 @@
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/translate.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/translate.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import '../row/accessory/cell_accessory.dart';
@ -18,6 +18,7 @@ import 'editable_cell_skeleton/relation.dart';
import 'editable_cell_skeleton/select_option.dart';
import 'editable_cell_skeleton/summary.dart';
import 'editable_cell_skeleton/text.dart';
import 'editable_cell_skeleton/time.dart';
import 'editable_cell_skeleton/timestamp.dart';
import 'editable_cell_skeleton/url.dart';
@ -121,6 +122,12 @@ class EditableCellBuilder {
skin: IEditableSummaryCellSkin.fromStyle(style),
key: key,
),
FieldType.Time => EditableTimeCell(
databaseController: databaseController,
cellContext: cellContext,
skin: IEditableTimeCellSkin.fromStyle(style),
key: key,
),
FieldType.Translate => EditableTranslateCell(
databaseController: databaseController,
cellContext: cellContext,
@ -213,6 +220,12 @@ class EditableCellBuilder {
skin: skinMap.relationSkin!,
key: key,
),
FieldType.Time => EditableTimeCell(
databaseController: databaseController,
cellContext: cellContext,
skin: skinMap.timeSkin!,
key: key,
),
_ => throw UnimplementedError(),
};
}
@ -368,6 +381,7 @@ class EditableCellSkinMap {
this.textSkin,
this.urlSkin,
this.relationSkin,
this.timeSkin,
});
final IEditableCheckboxCellSkin? checkboxSkin;
@ -379,6 +393,7 @@ class EditableCellSkinMap {
final IEditableTextCellSkin? textSkin;
final IEditableURLCellSkin? urlSkin;
final IEditableRelationCellSkin? relationSkin;
final IEditableTimeCellSkin? timeSkin;
bool has(FieldType fieldType) {
return switch (fieldType) {
@ -394,6 +409,7 @@ class EditableCellSkinMap {
FieldType.Number => numberSkin != null,
FieldType.RichText => textSkin != null,
FieldType.URL => urlSkin != null,
FieldType.Time => timeSkin != null,
_ => throw UnimplementedError(),
};
}

View File

@ -0,0 +1,120 @@
import 'dart:async';
import 'package:appflowy/plugins/database/application/cell/cell_controller.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/time_cell_bloc.dart';
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../desktop_grid/desktop_grid_time_cell.dart';
import '../desktop_row_detail/desktop_row_detail_time_cell.dart';
import '../mobile_grid/mobile_grid_time_cell.dart';
import '../mobile_row_detail/mobile_row_detail_time_cell.dart';
abstract class IEditableTimeCellSkin {
const IEditableTimeCellSkin();
factory IEditableTimeCellSkin.fromStyle(EditableCellStyle style) {
return switch (style) {
EditableCellStyle.desktopGrid => DesktopGridTimeCellSkin(),
EditableCellStyle.desktopRowDetail => DesktopRowDetailTimeCellSkin(),
EditableCellStyle.mobileGrid => MobileGridTimeCellSkin(),
EditableCellStyle.mobileRowDetail => MobileRowDetailTimeCellSkin(),
};
}
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
TimeCellBloc bloc,
FocusNode focusNode,
TextEditingController textEditingController,
);
}
class EditableTimeCell extends EditableCellWidget {
EditableTimeCell({
super.key,
required this.databaseController,
required this.cellContext,
required this.skin,
});
final DatabaseController databaseController;
final CellContext cellContext;
final IEditableTimeCellSkin skin;
@override
GridEditableTextCell<EditableTimeCell> createState() => _TimeCellState();
}
class _TimeCellState extends GridEditableTextCell<EditableTimeCell> {
late final TextEditingController _textEditingController;
late final cellBloc = TimeCellBloc(
cellController: makeCellController(
widget.databaseController,
widget.cellContext,
).as(),
);
@override
void initState() {
super.initState();
_textEditingController =
TextEditingController(text: cellBloc.state.content);
}
@override
void dispose() {
_textEditingController.dispose();
cellBloc.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: cellBloc,
child: BlocListener<TimeCellBloc, TimeCellState>(
listener: (context, state) =>
_textEditingController.text = state.content,
child: Builder(
builder: (context) {
return widget.skin.build(
context,
widget.cellContainerNotifier,
cellBloc,
focusNode,
_textEditingController,
);
},
),
),
);
}
@override
SingleListenerFocusNode focusNode = SingleListenerFocusNode();
@override
void onRequestFocus() {
focusNode.requestFocus();
}
@override
String? onCopy() => cellBloc.state.content;
@override
Future<void> focusChanged() async {
if (mounted &&
!cellBloc.isClosed &&
cellBloc.state.content != _textEditingController.text.trim()) {
cellBloc
.add(TimeCellEvent.updateCell(_textEditingController.text.trim()));
}
return super.focusChanged();
}
}

View File

@ -0,0 +1,29 @@
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/time_cell_bloc.dart';
import 'package:flutter/material.dart';
import '../editable_cell_skeleton/time.dart';
class MobileGridTimeCellSkin extends IEditableTimeCellSkin {
@override
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
TimeCellBloc bloc,
FocusNode focusNode,
TextEditingController textEditingController,
) {
return TextField(
controller: textEditingController,
focusNode: focusNode,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 15),
decoration: const InputDecoration(
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: EdgeInsets.symmetric(horizontal: 14, vertical: 12),
isCollapsed: true,
),
onTapOutside: (event) => FocusManager.instance.primaryFocus?.unfocus(),
);
}
}

View File

@ -0,0 +1,46 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/time_cell_bloc.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import '../editable_cell_skeleton/time.dart';
class MobileRowDetailTimeCellSkin extends IEditableTimeCellSkin {
@override
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
TimeCellBloc bloc,
FocusNode focusNode,
TextEditingController textEditingController,
) {
return TextField(
controller: textEditingController,
focusNode: focusNode,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 16),
decoration: InputDecoration(
enabledBorder:
_getInputBorder(color: Theme.of(context).colorScheme.outline),
focusedBorder:
_getInputBorder(color: Theme.of(context).colorScheme.primary),
hintText: LocaleKeys.grid_row_textPlaceholder.tr(),
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 13),
isCollapsed: true,
isDense: true,
constraints: const BoxConstraints(),
),
// close keyboard when tapping outside of the text field
onTapOutside: (event) => FocusManager.instance.primaryFocus?.unfocus(),
);
}
InputBorder _getInputBorder({Color? color}) {
return OutlineInputBorder(
borderSide: BorderSide(color: color!),
borderRadius: const BorderRadius.all(Radius.circular(14)),
gapPadding: 0,
);
}
}

View File

@ -1,10 +1,11 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/util/field_type_extension.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
typedef SelectFieldCallback = void Function(FieldType);
@ -21,6 +22,7 @@ const List<FieldType> _supportedFieldTypes = [
FieldType.CreatedTime,
FieldType.Relation,
FieldType.Summary,
FieldType.Time,
FieldType.Translate,
];

View File

@ -1,9 +1,10 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/database/widgets/field/type_option_editor/translate.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flutter/material.dart';
import 'checkbox.dart';
import 'checklist.dart';
@ -14,6 +15,7 @@ import 'relation.dart';
import 'rich_text.dart';
import 'single_select.dart';
import 'summary.dart';
import 'time.dart';
import 'timestamp.dart';
import 'url.dart';
@ -34,6 +36,7 @@ abstract class TypeOptionEditorFactory {
FieldType.Checklist => const ChecklistTypeOptionEditorFactory(),
FieldType.Relation => const RelationTypeOptionEditorFactory(),
FieldType.Summary => const SummaryTypeOptionEditorFactory(),
FieldType.Time => const TimeTypeOptionEditorFactory(),
FieldType.Translate => const TranslateTypeOptionEditorFactory(),
_ => throw UnimplementedError(),
};

View File

@ -0,0 +1,19 @@
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flutter/material.dart';
import 'builder.dart';
class TimeTypeOptionEditorFactory implements TypeOptionEditorFactory {
const TimeTypeOptionEditorFactory();
@override
Widget? build({
required BuildContext context,
required String viewId,
required FieldPB field,
required PopoverMutex popoverMutex,
required TypeOptionDataCallback onTypeOptionUpdated,
}) =>
null;
}

View File

@ -22,6 +22,7 @@ extension FieldTypeExtension on FieldType {
FieldType.CreatedTime => LocaleKeys.grid_field_createdAtFieldName.tr(),
FieldType.Relation => LocaleKeys.grid_field_relationFieldName.tr(),
FieldType.Summary => LocaleKeys.grid_field_summaryFieldName.tr(),
FieldType.Time => LocaleKeys.grid_field_timeFieldName.tr(),
FieldType.Translate => LocaleKeys.grid_field_translateFieldName.tr(),
_ => throw UnimplementedError(),
};
@ -39,6 +40,7 @@ extension FieldTypeExtension on FieldType {
FieldType.CreatedTime => FlowySvgs.created_at_s,
FieldType.Relation => FlowySvgs.relation_s,
FieldType.Summary => FlowySvgs.ai_summary_s,
FieldType.Time => FlowySvgs.timer_start_s,
FieldType.Translate => FlowySvgs.ai_translate_s,
_ => throw UnimplementedError(),
};
@ -62,6 +64,7 @@ extension FieldTypeExtension on FieldType {
FieldType.Checklist => const Color(0xFF98F4CD),
FieldType.Relation => const Color(0xFFFDEDA7),
FieldType.Summary => const Color(0xFFBECCFF),
FieldType.Time => const Color(0xFFFDEDA7),
FieldType.Translate => const Color(0xFFBECCFF),
_ => throw UnimplementedError(),
};
@ -80,6 +83,7 @@ extension FieldTypeExtension on FieldType {
FieldType.Checklist => const Color(0xFF42AD93),
FieldType.Relation => const Color(0xFFFDEDA7),
FieldType.Summary => const Color(0xFF6859A7),
FieldType.Time => const Color(0xFFFDEDA7),
FieldType.Translate => const Color(0xFF6859A7),
_ => throw UnimplementedError(),
};

View File

@ -0,0 +1,43 @@
final RegExp timerRegExp =
RegExp(r'(?:(?<hours>\d*)h)? ?(?:(?<minutes>\d*)m)?');
int? parseTime(String timerStr) {
int? res = int.tryParse(timerStr);
if (res != null) {
return res;
}
final matches = timerRegExp.firstMatch(timerStr);
if (matches == null) {
return null;
}
final hours = int.tryParse(matches.namedGroup('hours') ?? "");
final minutes = int.tryParse(matches.namedGroup('minutes') ?? "");
if (hours == null && minutes == null) {
return null;
}
final expected =
"${hours != null ? '${hours}h' : ''}${hours != null && minutes != null ? ' ' : ''}${minutes != null ? '${minutes}m' : ''}";
if (timerStr != expected) {
return null;
}
res = 0;
res += hours != null ? hours * 60 : res;
res += minutes ?? 0;
return res;
}
String formatTime(int minutes) {
if (minutes >= 60) {
if (minutes % 60 == 0) {
return "${minutes ~/ 60}h";
}
return "${minutes ~/ 60}h ${minutes % 60}m";
} else if (minutes >= 0) {
return "${minutes}m";
}
return "";
}

View File

@ -1041,6 +1041,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.19.0"
intl_utils:
dependency: transitive
description:
name: intl_utils
sha256: c2b1f5c72c25512cbeef5ab015c008fc50fe7e04813ba5541c25272300484bf4
url: "https://pub.dev"
source: hosted
version: "2.8.7"
io:
dependency: transitive
description:
@ -2192,6 +2200,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.2"
universal_html:
dependency: transitive
description:
name: universal_html
sha256: "56536254004e24d9d8cfdb7dbbf09b74cf8df96729f38a2f5c238163e3d58971"
url: "https://pub.dev"
source: hosted
version: "2.2.4"
universal_io:
dependency: transitive
description:
name: universal_io
sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
universal_platform:
dependency: transitive
description:

View File

@ -0,0 +1,24 @@
import 'package:appflowy/util/time.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('parseTime should parse time string to minutes', () {
expect(parseTime('10'), 10);
expect(parseTime('70m'), 70);
expect(parseTime('4h 20m'), 260);
expect(parseTime('1h 80m'), 140);
expect(parseTime('asffsa2h3m'), null);
expect(parseTime('2h3m'), null);
expect(parseTime('blah'), null);
expect(parseTime('10a'), null);
expect(parseTime('2h'), 120);
});
test('formatTime should format time minutes to formatted string', () {
expect(formatTime(5), "5m");
expect(formatTime(75), "1h 15m");
expect(formatTime(120), "2h");
expect(formatTime(-50), "");
expect(formatTime(0), "0m");
});
}

View File

@ -30,7 +30,6 @@
"passwordHint": "Password",
"repeatPasswordHint": "Repeat password",
"signUpWith": "Sign up with:"
},
"signIn": {
"loginTitle": "Login to @:appName",
@ -1012,6 +1011,7 @@
"checklistFieldName": "Checklist",
"relationFieldName": "Relation",
"summaryFieldName": "AI Summary",
"timeFieldName": "Time",
"translateFieldName": "AI Translate",
"translateTo": "Translate to",
"numberFormat": "Number format",
@ -1915,4 +1915,3 @@
"title": "Spaces"
}
}

View File

@ -657,6 +657,12 @@ impl<'a> TestRowBuilder<'a> {
checklist_field.id.clone()
}
pub fn insert_time_cell(&mut self, time: i64) -> String {
let time_field = self.field_with_type(&FieldType::Time);
self.cell_build.insert_number_cell(&time_field.id, time);
time_field.id.clone()
}
pub fn field_with_type(&self, field_type: &FieldType) -> Field {
self
.fields

View File

@ -450,6 +450,7 @@ pub enum FieldType {
Relation = 10,
Summary = 11,
Translate = 12,
Time = 13,
}
impl Display for FieldType {
@ -491,6 +492,7 @@ impl FieldType {
FieldType::Relation => "Relation",
FieldType::Summary => "Summarize",
FieldType::Translate => "Translate",
FieldType::Time => "Time",
};
s.to_string()
}
@ -547,6 +549,10 @@ impl FieldType {
matches!(self, FieldType::Relation)
}
pub fn is_time(&self) -> bool {
matches!(self, FieldType::Time)
}
pub fn can_be_group(&self) -> bool {
self.is_select_option() || self.is_checkbox() || self.is_url()
}

View File

@ -6,6 +6,7 @@ mod number_filter;
mod relation_filter;
mod select_option_filter;
mod text_filter;
mod time_filter;
mod util;
pub use checkbox_filter::*;
@ -16,4 +17,5 @@ pub use number_filter::*;
pub use relation_filter::*;
pub use select_option_filter::*;
pub use text_filter::*;
pub use time_filter::*;
pub use util::*;

View File

@ -0,0 +1,23 @@
use flowy_derive::ProtoBuf;
use crate::entities::NumberFilterConditionPB;
use crate::services::filter::ParseFilterData;
#[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)]
pub struct TimeFilterPB {
#[pb(index = 1)]
pub condition: NumberFilterConditionPB,
#[pb(index = 2)]
pub content: String,
}
impl ParseFilterData for TimeFilterPB {
fn parse(condition: u8, content: String) -> Self {
TimeFilterPB {
condition: NumberFilterConditionPB::try_from(condition)
.unwrap_or(NumberFilterConditionPB::Equal),
content,
}
}
}

View File

@ -10,7 +10,7 @@ use validator::Validate;
use crate::entities::{
CheckboxFilterPB, ChecklistFilterPB, DateFilterPB, FieldType, NumberFilterPB, RelationFilterPB,
SelectOptionFilterPB, TextFilterPB,
SelectOptionFilterPB, TextFilterPB, TimeFilterPB,
};
use crate::services::filter::{Filter, FilterChangeset, FilterInner};
@ -109,6 +109,10 @@ impl From<&Filter> for FilterPB {
.cloned::<TextFilterPB>()
.unwrap()
.try_into(),
FieldType::Time => condition_and_content
.cloned::<TimeFilterPB>()
.unwrap()
.try_into(),
FieldType::Translate => condition_and_content
.cloned::<TextFilterPB>()
.unwrap()
@ -160,6 +164,9 @@ impl TryFrom<FilterDataPB> for FilterInner {
FieldType::Summary => {
BoxAny::new(TextFilterPB::try_from(bytes).map_err(|_| ErrorCode::ProtobufSerde)?)
},
FieldType::Time => {
BoxAny::new(TimeFilterPB::try_from(bytes).map_err(|_| ErrorCode::ProtobufSerde)?)
},
FieldType::Translate => {
BoxAny::new(TextFilterPB::try_from(bytes).map_err(|_| ErrorCode::ProtobufSerde)?)
},

View File

@ -17,6 +17,7 @@ macro_rules! impl_into_field_type {
10 => FieldType::Relation,
11 => FieldType::Summary,
12 => FieldType::Translate,
13 => FieldType::Time,
_ => {
tracing::error!("🔴Can't parse FieldType from value: {}", ty);
FieldType::RichText

View File

@ -6,6 +6,7 @@ mod relation_entities;
mod select_option_entities;
mod summary_entities;
mod text_entities;
mod time_entities;
mod timestamp_entities;
mod translate_entities;
mod url_entities;
@ -18,6 +19,7 @@ pub use relation_entities::*;
pub use select_option_entities::*;
pub use summary_entities::*;
pub use text_entities::*;
pub use time_entities::*;
pub use timestamp_entities::*;
pub use translate_entities::*;
pub use url_entities::*;

View File

@ -0,0 +1,28 @@
use crate::services::field::TimeTypeOption;
use flowy_derive::ProtoBuf;
#[derive(Clone, Debug, Default, ProtoBuf)]
pub struct TimeTypeOptionPB {
#[pb(index = 1)]
pub dummy: String,
}
impl From<TimeTypeOption> for TimeTypeOptionPB {
fn from(_data: TimeTypeOption) -> Self {
Self {
dummy: "".to_string(),
}
}
}
impl From<TimeTypeOptionPB> for TimeTypeOption {
fn from(_data: TimeTypeOptionPB) -> Self {
Self
}
}
#[derive(Clone, Debug, Default, ProtoBuf)]
pub struct TimeCellDataPB {
#[pb(index = 2)]
pub time: i64,
}

View File

@ -222,7 +222,7 @@ impl<'a> CellBuilder<'a> {
FieldType::RichText => {
cells.insert(field_id, insert_text_cell(cell_str, field));
},
FieldType::Number => {
FieldType::Number | FieldType::Time => {
if let Ok(num) = cell_str.parse::<i64>() {
cells.insert(field_id, insert_number_cell(num, field));
}

View File

@ -6,6 +6,7 @@ pub mod relation_type_option;
pub mod selection_type_option;
pub mod summary_type_option;
pub mod text_type_option;
pub mod time_type_option;
pub mod timestamp_type_option;
pub mod translate_type_option;
mod type_option;
@ -20,6 +21,7 @@ pub use number_type_option::*;
pub use relation_type_option::*;
pub use selection_type_option::*;
pub use text_type_option::*;
pub use time_type_option::*;
pub use timestamp_type_option::*;
pub use type_option::*;
pub use type_option_cell::*;

View File

@ -79,13 +79,14 @@ impl CellDataDecoder for RichTextTypeOption {
| FieldType::SingleSelect
| FieldType::MultiSelect
| FieldType::Checkbox
| FieldType::URL => Some(StringCellData::from(stringify_cell(cell, field))),
| FieldType::URL
| FieldType::Summary
| FieldType::Translate
| FieldType::Time => Some(StringCellData::from(stringify_cell(cell, field))),
FieldType::Checklist
| FieldType::LastEditedTime
| FieldType::CreatedTime
| FieldType::Relation => None,
FieldType::Summary => Some(StringCellData::from(stringify_cell(cell, field))),
FieldType::Translate => Some(StringCellData::from(stringify_cell(cell, field))),
}
}

View File

@ -0,0 +1,6 @@
mod time;
mod time_entities;
mod time_filter;
pub use time::*;
pub use time_entities::*;

View File

@ -0,0 +1,115 @@
use crate::entities::{TimeCellDataPB, TimeFilterPB};
use crate::services::cell::{CellDataChangeset, CellDataDecoder};
use crate::services::field::{
TimeCellData, TypeOption, TypeOptionCellDataCompare, TypeOptionCellDataFilter,
TypeOptionCellDataSerde, TypeOptionTransform,
};
use crate::services::sort::SortCondition;
use collab_database::fields::{TypeOptionData, TypeOptionDataBuilder};
use collab_database::rows::Cell;
use flowy_error::FlowyResult;
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
pub struct TimeTypeOption;
impl TypeOption for TimeTypeOption {
type CellData = TimeCellData;
type CellChangeset = TimeCellChangeset;
type CellProtobufType = TimeCellDataPB;
type CellFilter = TimeFilterPB;
}
impl From<TypeOptionData> for TimeTypeOption {
fn from(_data: TypeOptionData) -> Self {
Self
}
}
impl From<TimeTypeOption> for TypeOptionData {
fn from(_data: TimeTypeOption) -> Self {
TypeOptionDataBuilder::new().build()
}
}
impl TypeOptionCellDataSerde for TimeTypeOption {
fn protobuf_encode(
&self,
cell_data: <Self as TypeOption>::CellData,
) -> <Self as TypeOption>::CellProtobufType {
if let Some(time) = cell_data.0 {
return TimeCellDataPB { time };
}
TimeCellDataPB {
time: i64::default(),
}
}
fn parse_cell(&self, cell: &Cell) -> FlowyResult<<Self as TypeOption>::CellData> {
Ok(TimeCellData::from(cell))
}
}
impl TimeTypeOption {
pub fn new() -> Self {
Self
}
}
impl TypeOptionTransform for TimeTypeOption {}
impl CellDataDecoder for TimeTypeOption {
fn decode_cell(&self, cell: &Cell) -> FlowyResult<<Self as TypeOption>::CellData> {
self.parse_cell(cell)
}
fn stringify_cell_data(&self, cell_data: <Self as TypeOption>::CellData) -> String {
if let Some(time) = cell_data.0 {
return time.to_string();
}
"".to_string()
}
fn numeric_cell(&self, cell: &Cell) -> Option<f64> {
let time_cell_data = self.parse_cell(cell).ok()?;
Some(time_cell_data.0.unwrap() as f64)
}
}
pub type TimeCellChangeset = String;
impl CellDataChangeset for TimeTypeOption {
fn apply_changeset(
&self,
changeset: <Self as TypeOption>::CellChangeset,
_cell: Option<Cell>,
) -> FlowyResult<(Cell, <Self as TypeOption>::CellData)> {
let str = changeset.trim().to_string();
let cell_data = TimeCellData(str.parse::<i64>().ok());
Ok((Cell::from(&cell_data), cell_data))
}
}
impl TypeOptionCellDataFilter for TimeTypeOption {
fn apply_filter(
&self,
filter: &<Self as TypeOption>::CellFilter,
cell_data: &<Self as TypeOption>::CellData,
) -> bool {
filter.is_visible(cell_data.0)
}
}
impl TypeOptionCellDataCompare for TimeTypeOption {
fn apply_cmp(
&self,
cell_data: &<Self as TypeOption>::CellData,
other_cell_data: &<Self as TypeOption>::CellData,
sort_condition: SortCondition,
) -> Ordering {
let order = cell_data.0.cmp(&other_cell_data.0);
sort_condition.evaluate_order(order)
}
}

View File

@ -0,0 +1,47 @@
use crate::entities::FieldType;
use crate::services::field::{TypeOptionCellData, CELL_DATA};
use collab::core::any_map::AnyMapExtension;
use collab_database::rows::{new_cell_builder, Cell};
#[derive(Clone, Debug, Default)]
pub struct TimeCellData(pub Option<i64>);
impl TypeOptionCellData for TimeCellData {
fn is_cell_empty(&self) -> bool {
self.0.is_none()
}
}
impl From<&Cell> for TimeCellData {
fn from(cell: &Cell) -> Self {
Self(
cell
.get_str_value(CELL_DATA)
.and_then(|data| data.parse::<i64>().ok()),
)
}
}
impl std::convert::From<String> for TimeCellData {
fn from(s: String) -> Self {
Self(s.trim().to_string().parse::<i64>().ok())
}
}
impl ToString for TimeCellData {
fn to_string(&self) -> String {
if let Some(time) = self.0 {
time.to_string()
} else {
"".to_string()
}
}
}
impl From<&TimeCellData> for Cell {
fn from(data: &TimeCellData) -> Self {
new_cell_builder(FieldType::Time)
.insert_str_value(CELL_DATA, data.to_string())
.build()
}
}

View File

@ -0,0 +1,72 @@
use collab_database::fields::Field;
use collab_database::rows::Cell;
use crate::entities::{NumberFilterConditionPB, TimeFilterPB};
use crate::services::cell::insert_text_cell;
use crate::services::filter::PreFillCellsWithFilter;
impl TimeFilterPB {
pub fn is_visible(&self, cell_time: Option<i64>) -> bool {
if self.content.is_empty() {
match self.condition {
NumberFilterConditionPB::NumberIsEmpty => {
return cell_time.is_none();
},
NumberFilterConditionPB::NumberIsNotEmpty => {
return cell_time.is_some();
},
_ => {},
}
}
if cell_time.is_none() {
return false;
}
let time = cell_time.unwrap();
let content_time = self.content.parse::<i64>().unwrap_or_default();
match self.condition {
NumberFilterConditionPB::Equal => time == content_time,
NumberFilterConditionPB::NotEqual => time != content_time,
NumberFilterConditionPB::GreaterThan => time > content_time,
NumberFilterConditionPB::LessThan => time < content_time,
NumberFilterConditionPB::GreaterThanOrEqualTo => time >= content_time,
NumberFilterConditionPB::LessThanOrEqualTo => time <= content_time,
_ => true,
}
}
}
impl PreFillCellsWithFilter for TimeFilterPB {
fn get_compliant_cell(&self, field: &Field) -> (Option<Cell>, bool) {
let expected_decimal = || self.content.parse::<i64>().ok();
let text = match self.condition {
NumberFilterConditionPB::Equal
| NumberFilterConditionPB::GreaterThanOrEqualTo
| NumberFilterConditionPB::LessThanOrEqualTo
if !self.content.is_empty() =>
{
Some(self.content.clone())
},
NumberFilterConditionPB::GreaterThan if !self.content.is_empty() => {
expected_decimal().map(|value| {
let answer = value + 1;
answer.to_string()
})
},
NumberFilterConditionPB::LessThan if !self.content.is_empty() => {
expected_decimal().map(|value| {
let answer = value - 1;
answer.to_string()
})
},
_ => None,
};
let open_after_create = matches!(self.condition, NumberFilterConditionPB::NumberIsNotEmpty);
// use `insert_text_cell` because self.content might not be a parsable i64.
(text.map(|s| insert_text_cell(s, field)), open_after_create)
}
}

View File

@ -11,7 +11,7 @@ use flowy_error::FlowyResult;
use crate::entities::{
CheckboxTypeOptionPB, ChecklistTypeOptionPB, DateTypeOptionPB, FieldType,
MultiSelectTypeOptionPB, NumberTypeOptionPB, RelationTypeOptionPB, RichTextTypeOptionPB,
SingleSelectTypeOptionPB, SummarizationTypeOptionPB, TimestampTypeOptionPB,
SingleSelectTypeOptionPB, SummarizationTypeOptionPB, TimeTypeOptionPB, TimestampTypeOptionPB,
TranslateTypeOptionPB, URLTypeOptionPB,
};
use crate::services::cell::CellDataDecoder;
@ -20,7 +20,7 @@ use crate::services::field::summary_type_option::summary::SummarizationTypeOptio
use crate::services::field::translate_type_option::translate::TranslateTypeOption;
use crate::services::field::{
CheckboxTypeOption, DateTypeOption, MultiSelectTypeOption, NumberTypeOption, RelationTypeOption,
RichTextTypeOption, SingleSelectTypeOption, TimestampTypeOption, URLTypeOption,
RichTextTypeOption, SingleSelectTypeOption, TimeTypeOption, TimestampTypeOption, URLTypeOption,
};
use crate::services::filter::{ParseFilterData, PreFillCellsWithFilter};
use crate::services::sort::SortCondition;
@ -187,6 +187,7 @@ pub fn type_option_data_from_pb<T: Into<Bytes>>(
FieldType::Summary => {
SummarizationTypeOptionPB::try_from(bytes).map(|pb| SummarizationTypeOption::from(pb).into())
},
FieldType::Time => TimeTypeOptionPB::try_from(bytes).map(|pb| TimeTypeOption::from(pb).into()),
FieldType::Translate => {
TranslateTypeOptionPB::try_from(bytes).map(|pb| TranslateTypeOption::from(pb).into())
},
@ -257,6 +258,10 @@ pub fn type_option_to_pb(type_option: TypeOptionData, field_type: &FieldType) ->
.try_into()
.unwrap()
},
FieldType::Time => {
let time_type_option: TimeTypeOption = type_option.into();
TimeTypeOptionPB::from(time_type_option).try_into().unwrap()
},
FieldType::Translate => {
let translate_type_option: TranslateTypeOption = type_option.into();
TranslateTypeOptionPB::from(translate_type_option)
@ -284,5 +289,6 @@ pub fn default_type_option_data_from_type(field_type: FieldType) -> TypeOptionDa
FieldType::Relation => RelationTypeOption::default().into(),
FieldType::Summary => SummarizationTypeOption::default().into(),
FieldType::Translate => TranslateTypeOption::default().into(),
FieldType::Time => TimeTypeOption.into(),
}
}

View File

@ -14,9 +14,9 @@ use crate::services::field::summary_type_option::summary::SummarizationTypeOptio
use crate::services::field::translate_type_option::translate::TranslateTypeOption;
use crate::services::field::{
CheckboxTypeOption, ChecklistTypeOption, DateTypeOption, MultiSelectTypeOption, NumberTypeOption,
RelationTypeOption, RichTextTypeOption, SingleSelectTypeOption, TimestampTypeOption, TypeOption,
TypeOptionCellData, TypeOptionCellDataCompare, TypeOptionCellDataFilter, TypeOptionCellDataSerde,
TypeOptionTransform, URLTypeOption,
RelationTypeOption, RichTextTypeOption, SingleSelectTypeOption, TimeTypeOption,
TimestampTypeOption, TypeOption, TypeOptionCellData, TypeOptionCellDataCompare,
TypeOptionCellDataFilter, TypeOptionCellDataSerde, TypeOptionTransform, URLTypeOption,
};
use crate::services::sort::SortCondition;
@ -450,6 +450,16 @@ impl<'a> TypeOptionCellExt<'a> {
self.cell_data_cache.clone(),
)
}),
FieldType::Time => self
.field
.get_type_option::<TimeTypeOption>(field_type)
.map(|type_option| {
TypeOptionCellDataHandlerImpl::new_with_boxed(
type_option,
field_type,
self.cell_data_cache.clone(),
)
}),
FieldType::Translate => self
.field
.get_type_option::<TranslateTypeOption>(field_type)
@ -563,6 +573,9 @@ fn get_type_option_transform_handler(
},
FieldType::Summary => Box::new(SummarizationTypeOption::from(type_option_data))
as Box<dyn TypeOptionTransformHandler>,
FieldType::Time => {
Box::new(TimeTypeOption::from(type_option_data)) as Box<dyn TypeOptionTransformHandler>
},
FieldType::Translate => {
Box::new(TranslateTypeOption::from(type_option_data)) as Box<dyn TypeOptionTransformHandler>
},

View File

@ -303,6 +303,10 @@ impl FilterController {
let filter = condition_and_content.cloned::<ChecklistFilterPB>().unwrap();
filter.get_compliant_cell(field)
},
FieldType::Time => {
let filter = condition_and_content.cloned::<TimeFilterPB>().unwrap();
filter.get_compliant_cell(field)
},
_ => (None, false),
};

View File

@ -12,6 +12,7 @@ use lib_infra::box_any::BoxAny;
use crate::entities::{
CheckboxFilterPB, ChecklistFilterPB, DateFilterContent, DateFilterPB, FieldType, FilterType,
InsertedRowPB, NumberFilterPB, RelationFilterPB, SelectOptionFilterPB, TextFilterPB,
TimeFilterPB,
};
use crate::services::field::SelectOptionIds;
@ -282,6 +283,7 @@ impl FilterInner {
FieldType::Relation => BoxAny::new(RelationFilterPB::parse(condition as u8, content)),
FieldType::Summary => BoxAny::new(TextFilterPB::parse(condition as u8, content)),
FieldType::Translate => BoxAny::new(TextFilterPB::parse(condition as u8, content)),
FieldType::Time => BoxAny::new(TimeFilterPB::parse(condition as u8, content)),
};
FilterInner::Data {
@ -368,6 +370,10 @@ impl<'a> From<&'a Filter> for FilterMap {
let filter = condition_and_content.cloned::<TextFilterPB>()?;
(filter.condition as u8, filter.content)
},
FieldType::Time => {
let filter = condition_and_content.cloned::<TimeFilterPB>()?;
(filter.condition as u8, filter.content)
},
FieldType::Translate => {
let filter = condition_and_content.cloned::<TextFilterPB>()?;
(filter.condition as u8, filter.content)

View File

@ -4,7 +4,7 @@ use flowy_database2::entities::FieldType;
use flowy_database2::services::field::{
ChecklistCellChangeset, DateCellChangeset, DateCellData, MultiSelectTypeOption,
RelationCellChangeset, SelectOptionCellChangeset, SingleSelectTypeOption, StringCellData,
URLCellData,
TimeCellData, URLCellData,
};
use lib_infra::box_any::BoxAny;
@ -200,3 +200,20 @@ async fn update_updated_at_field_on_other_cell_update() {
}
}
}
#[tokio::test]
async fn time_cell_data_test() {
let test = DatabaseCellTest::new().await;
let time_field = test.get_first_field(FieldType::Time);
let cells = test
.editor
.get_cells_for_field(&test.view_id, &time_field.id)
.await;
if let Some(cell) = cells[0].cell.as_ref() {
let cell = TimeCellData::from(cell);
assert!(cell.0.is_some());
assert_eq!(cell.0.unwrap_or_default(), 75);
}
}

View File

@ -40,6 +40,26 @@ async fn grid_create_field() {
},
];
test.run_scripts(scripts).await;
let (params, field) = create_time_field(&test.view_id());
let scripts = vec![
CreateField { params },
AssertFieldTypeOptionEqual {
field_index: test.field_count(),
expected_type_option_data: field.get_any_type_option(field.field_type).unwrap(),
},
];
test.run_scripts(scripts).await;
let (params, field) = create_time_field(&test.view_id());
let scripts = vec![
CreateField { params },
AssertFieldTypeOptionEqual {
field_index: test.field_count(),
expected_type_option_data: field.get_any_type_option(field.field_type).unwrap(),
},
];
test.run_scripts(scripts).await;
}
#[tokio::test]

View File

@ -4,7 +4,7 @@ use collab_database::views::OrderObjectPosition;
use flowy_database2::entities::{CreateFieldParams, FieldType};
use flowy_database2::services::field::{
type_option_to_pb, DateFormat, DateTypeOption, FieldBuilder, RichTextTypeOption, SelectOption,
SingleSelectTypeOption, TimeFormat, TimestampTypeOption,
SingleSelectTypeOption, TimeFormat, TimeTypeOption, TimestampTypeOption,
};
pub fn create_text_field(grid_id: &str) -> (CreateFieldParams, Field) {
@ -98,3 +98,21 @@ pub fn create_timestamp_field(grid_id: &str, field_type: FieldType) -> (CreateFi
};
(params, field)
}
pub fn create_time_field(grid_id: &str) -> (CreateFieldParams, Field) {
let field_type = FieldType::Time;
let type_option = TimeTypeOption;
let text_field = FieldBuilder::new(field_type, type_option.clone())
.name("Time field")
.build();
let type_option_data = type_option_to_pb(type_option.into(), &field_type).to_vec();
let params = CreateFieldParams {
view_id: grid_id.to_owned(),
field_type,
type_option_data: Some(type_option_data),
field_name: None,
position: OrderObjectPosition::default(),
};
(params, text_field)
}

View File

@ -6,3 +6,4 @@ mod number_filter_test;
mod script;
mod select_option_filter_test;
mod text_filter_test;
mod time_filter_test;

View File

@ -0,0 +1,121 @@
use flowy_database2::entities::{FieldType, NumberFilterConditionPB, TimeFilterPB};
use lib_infra::box_any::BoxAny;
use crate::database::filter_test::script::FilterScript::*;
use crate::database::filter_test::script::{DatabaseFilterTest, FilterRowChanged};
#[tokio::test]
async fn grid_filter_time_is_equal_test() {
let mut test = DatabaseFilterTest::new().await;
let row_count = test.row_details.len();
let expected = 1;
let scripts = vec![
CreateDataFilter {
parent_filter_id: None,
field_type: FieldType::Time,
data: BoxAny::new(TimeFilterPB {
condition: NumberFilterConditionPB::Equal,
content: "75".to_string(),
}),
changed: Some(FilterRowChanged {
showing_num_of_rows: 0,
hiding_num_of_rows: row_count - expected,
}),
},
AssertNumberOfVisibleRows { expected },
];
test.run_scripts(scripts).await;
}
#[tokio::test]
async fn grid_filter_time_is_less_than_test() {
let mut test = DatabaseFilterTest::new().await;
let row_count = test.row_details.len();
let expected = 1;
let scripts = vec![
CreateDataFilter {
parent_filter_id: None,
field_type: FieldType::Time,
data: BoxAny::new(TimeFilterPB {
condition: NumberFilterConditionPB::LessThan,
content: "80".to_string(),
}),
changed: Some(FilterRowChanged {
showing_num_of_rows: 0,
hiding_num_of_rows: row_count - expected,
}),
},
AssertNumberOfVisibleRows { expected },
];
test.run_scripts(scripts).await;
}
#[tokio::test]
async fn grid_filter_time_is_less_than_or_equal_test() {
let mut test = DatabaseFilterTest::new().await;
let row_count = test.row_details.len();
let expected = 1;
let scripts = vec![
CreateDataFilter {
parent_filter_id: None,
field_type: FieldType::Time,
data: BoxAny::new(TimeFilterPB {
condition: NumberFilterConditionPB::LessThanOrEqualTo,
content: "75".to_string(),
}),
changed: Some(FilterRowChanged {
showing_num_of_rows: 0,
hiding_num_of_rows: row_count - expected,
}),
},
AssertNumberOfVisibleRows { expected },
];
test.run_scripts(scripts).await;
}
#[tokio::test]
async fn grid_filter_time_is_empty_test() {
let mut test = DatabaseFilterTest::new().await;
let row_count = test.row_details.len();
let expected = 6;
let scripts = vec![
CreateDataFilter {
parent_filter_id: None,
field_type: FieldType::Time,
data: BoxAny::new(TimeFilterPB {
condition: NumberFilterConditionPB::NumberIsEmpty,
content: "".to_string(),
}),
changed: Some(FilterRowChanged {
showing_num_of_rows: 0,
hiding_num_of_rows: row_count - expected,
}),
},
AssertNumberOfVisibleRows { expected },
];
test.run_scripts(scripts).await;
}
#[tokio::test]
async fn grid_filter_time_is_not_empty_test() {
let mut test = DatabaseFilterTest::new().await;
let row_count = test.row_details.len();
let expected = 1;
let scripts = vec![
CreateDataFilter {
parent_filter_id: None,
field_type: FieldType::Time,
data: BoxAny::new(TimeFilterPB {
condition: NumberFilterConditionPB::NumberIsNotEmpty,
content: "".to_string(),
}),
changed: Some(FilterRowChanged {
showing_num_of_rows: 0,
hiding_num_of_rows: row_count - expected,
}),
},
AssertNumberOfVisibleRows { expected },
];
test.run_scripts(scripts).await;
}

View File

@ -134,6 +134,12 @@ pub fn make_test_board() -> DatabaseData {
.build();
fields.push(relation_field);
},
FieldType::Time => {
let time_field = FieldBuilder::from_field_type(field_type)
.name("Estimated time")
.build();
fields.push(time_field);
},
FieldType::Translate => {},
}
}

View File

@ -10,7 +10,7 @@ use flowy_database2::services::field::translate_type_option::translate::Translat
use flowy_database2::services::field::{
ChecklistTypeOption, DateFormat, DateTypeOption, FieldBuilder, MultiSelectTypeOption,
NumberFormat, NumberTypeOption, RelationTypeOption, SelectOption, SelectOptionColor,
SingleSelectTypeOption, TimeFormat, TimestampTypeOption,
SingleSelectTypeOption, TimeFormat, TimeTypeOption, TimestampTypeOption,
};
use flowy_database2::services::field_settings::default_field_settings_for_fields;
@ -133,6 +133,13 @@ pub fn make_test_grid() -> DatabaseData {
.build();
fields.push(relation_field);
},
FieldType::Time => {
let type_option = TimeTypeOption;
let time_field = FieldBuilder::new(field_type, type_option)
.name("Estimated time")
.build();
fields.push(time_field);
},
FieldType::Translate => {
let type_option = TranslateTypeOption {
auto_fill: false,
@ -168,6 +175,7 @@ pub fn make_test_grid() -> DatabaseData {
FieldType::Checklist => {
row_builder.insert_checklist_cell(vec![("First thing".to_string(), false)])
},
FieldType::Time => row_builder.insert_time_cell(75),
_ => "".to_owned(),
};
}

View File

@ -83,6 +83,7 @@ async fn export_and_then_import_meta_csv_test() {
FieldType::CreatedTime => {},
FieldType::Relation => {},
FieldType::Summary => {},
FieldType::Time => {},
FieldType::Translate => {},
}
} else {
@ -167,6 +168,7 @@ async fn history_database_import_test() {
FieldType::CreatedTime => {},
FieldType::Relation => {},
FieldType::Summary => {},
FieldType::Time => {},
FieldType::Translate => {},
}
} else {