feat: add end date to time cell data (#3369)

* feat: add end date to time cell data

* feat: implement ui for end time

* test: add date to text test

* chore: clippy warnings

* fix: unexpected time parsing

* fix: fix the date logic when toggling end date

---------

Co-authored-by: nathan <nathan@appflowy.io>
This commit is contained in:
Richard Shiue 2023-09-19 09:58:15 +08:00 committed by GitHub
parent b700f95c7f
commit 124d435f09
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 629 additions and 124 deletions

View File

@ -185,7 +185,7 @@ void main() {
await tester.pumpAndSettle();
});
testWidgets('edit time cell', (tester) async {
testWidgets('edit date time cell', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();

View File

@ -25,6 +25,7 @@ import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_type_list.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_type_option_editor.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/type_option/date.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/type_option/timestamp.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/row/row.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/sort/create_sort_list.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/sort/order_panel.dart';
@ -330,7 +331,17 @@ extension AppFlowyDatabaseTest on WidgetTester {
}
Future<void> toggleIncludeTime() async {
final findDateEditor = find.byType(DateCellEditor);
final findDateEditor = find.byType(IncludeTimeButton);
final findToggle = find.byType(Toggle);
final finder = find.descendant(
of: findDateEditor,
matching: findToggle,
);
await tapButton(finder);
}
Future<void> toggleDateRange() async {
final findDateEditor = find.byType(EndTimeButton);
final findToggle = find.byType(Toggle);
final finder = find.descendant(
of: findDateEditor,

View File

@ -20,11 +20,15 @@ final class DateCellBackendService {
Future<Either<Unit, FlowyError>> update({
DateTime? date,
String? time,
DateTime? endDate,
String? endTime,
required includeTime,
required isRange,
}) {
final payload = DateChangesetPB.create()
..cellId = cellId
..includeTime = includeTime;
..includeTime = includeTime
..isRange = isRange;
if (date != null) {
final dateTimestamp = date.millisecondsSinceEpoch ~/ 1000;
@ -33,6 +37,13 @@ final class DateCellBackendService {
if (time != null) {
payload.time = time;
}
if (endDate != null) {
final dateTimestamp = endDate.millisecondsSinceEpoch ~/ 1000;
payload.endDate = Int64(dateTimestamp);
}
if (endTime != null) {
payload.endTime = endTime;
}
return DatabaseEventUpdateDateCell(payload).send();
}

View File

@ -80,10 +80,19 @@ class DateCardCellState with _$DateCardCellState {
String _dateStrFromCellData(DateCellDataPB? cellData) {
String dateStr = "";
if (cellData != null) {
if (cellData.includeTime) {
dateStr = '${cellData.date} ${cellData.time}';
if (cellData.isRange) {
if (cellData.includeTime) {
dateStr =
"${cellData.date} ${cellData.time}${cellData.endDate} ${cellData.endTime}";
} else {
dateStr = "${cellData.date}${cellData.endDate}";
}
} else {
dateStr = cellData.date;
if (cellData.includeTime) {
dateStr = "${cellData.date} ${cellData.time}";
} else {
dateStr = cellData.date;
}
}
}
return dateStr;

View File

@ -10,10 +10,10 @@ 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';
import 'package:table_calendar/table_calendar.dart';
part 'date_cal_bloc.freezed.dart';
@ -38,40 +38,64 @@ class DateCellCalendarBloc
await event.when(
initial: () async => _startListening(),
didReceiveCellUpdate: (DateCellDataPB? cellData) {
final (dateTime, time, includeTime) =
final (dateTime, endDateTime, time, endTime, includeTime, isRange) =
_dateDataFromCellData(cellData);
emit(
state.copyWith(
dateTime: dateTime,
time: time,
endDateTime: endDateTime,
endTime: endTime,
includeTime: includeTime,
isRange: isRange,
startDay: isRange ? dateTime : null,
endDay: isRange ? endDateTime : null,
),
);
},
didReceiveTimeFormatError: (String? timeFormatError) {
emit(state.copyWith(timeFormatError: timeFormatError));
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: (time) async {
await _updateDateData(time: time);
},
selectDateRange: (DateTime? start, DateTime? end) async {
if (end == null) {
emit(state.copyWith(startDay: start, endDay: null));
} else {
await _updateDateData(
date: start!.toLocal().date,
endDate: end.toLocal().date,
);
}
},
setEndTime: (String endTime) async {
await _updateDateData(endTime: endTime);
},
setDateFormat: (dateFormat) async {
await _updateTypeOption(emit, dateFormat: dateFormat);
},
setTimeFormat: (timeFormat) async {
await _updateTypeOption(emit, timeFormat: timeFormat);
},
setCalFormat: (format) {
emit(state.copyWith(format: format));
},
setFocusedDay: (focusedDay) {
emit(state.copyWith(focusedDay: focusedDay));
},
clearDate: () async {
await _clearDate();
},
@ -83,39 +107,66 @@ class DateCellCalendarBloc
Future<void> _updateDateData({
DateTime? date,
String? time,
DateTime? endDate,
String? endTime,
bool? includeTime,
bool? isRange,
}) async {
// make sure that not both date and time are updated at the same time
assert(
date == null && time == null ||
date == null && time != null ||
date != null && time == null,
!(date != null && time != null) || !(endDate != null && endTime != null),
);
// if not updating the time, use the old time in the state
final String? newTime = time ?? state.time;
DateTime? newDate = _utcToLocalAndAddCurrentTime(date);
DateTime? newDate;
if (time != null && time.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 = endTime ?? state.endTime;
DateTime? newEndDate;
if (endTime != null && endTime.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.timeFormatError != null) {
add(const DateCellCalendarEvent.didReceiveTimeFormatError(null));
if (!isClosed &&
(state.parseEndTimeError != null || state.parseTimeError != null)) {
add(
const DateCellCalendarEvent.didReceiveTimeFormatError(null, null),
);
}
},
(err) {
switch (err.code) {
case ErrorCode.InvalidDateTimeFormat:
if (isClosed) return;
if (isClosed) {
return;
}
// to determine which textfield should show error
final (startError, endError) = newDate != null
? (timeFormatPrompt(err), null)
: (null, timeFormatPrompt(err));
add(
DateCellCalendarEvent.didReceiveTimeFormatError(
timeFormatPrompt(err),
startError,
endError,
),
);
break;
@ -130,9 +181,13 @@ class DateCellCalendarBloc
final result = await _dateCellBackendService.clear();
result.fold(
(_) {
if (!isClosed) {
add(const DateCellCalendarEvent.didReceiveTimeFormatError(null));
if (isClosed) {
return;
}
add(
const DateCellCalendarEvent.didReceiveTimeFormatError(null, null),
);
},
(err) => Log.error(err),
);
@ -157,18 +212,13 @@ class DateCellCalendarBloc
}
String timeFormatPrompt(FlowyError error) {
String msg = "${LocaleKeys.grid_field_invalidTimeFormat.tr()}.";
switch (state.dateTypeOptionPB.timeFormat) {
case TimeFormatPB.TwelveHour:
msg = "$msg e.g. 01:00 PM";
break;
case TimeFormatPB.TwentyFourHour:
msg = "$msg e.g. 13:00";
break;
default:
break;
}
return msg;
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
@ -235,19 +285,21 @@ class DateCellCalendarEvent with _$DateCellCalendarEvent {
DateCellDataPB? data,
) = _DidReceiveCellUpdate;
const factory DateCellCalendarEvent.didReceiveTimeFormatError(
String? timeformatError,
String? parseTimeError,
String? parseEndTimeError,
) = _DidReceiveTimeFormatError;
// table calendar's UI settings
const factory DateCellCalendarEvent.setFocusedDay(DateTime day) = _FocusedDay;
const factory DateCellCalendarEvent.setCalFormat(CalendarFormat format) =
_CalendarFormat;
// date cell data is modified
const factory DateCellCalendarEvent.selectDay(DateTime day) = _SelectDay;
const factory DateCellCalendarEvent.selectDateRange(
DateTime? start,
DateTime? end,
) = _SelectDateRange;
const factory DateCellCalendarEvent.setTime(String time) = _Time;
const factory DateCellCalendarEvent.setEndTime(String endTime) = _EndTime;
const factory DateCellCalendarEvent.setIncludeTime(bool includeTime) =
_IncludeTime;
const factory DateCellCalendarEvent.setIsRange(bool isRange) = _IsRange;
// date field type options are modified
const factory DateCellCalendarEvent.setTimeFormat(TimeFormatPB timeFormat) =
@ -262,12 +314,16 @@ class DateCellCalendarEvent with _$DateCellCalendarEvent {
class DateCellCalendarState with _$DateCellCalendarState {
const factory DateCellCalendarState({
required DateTypeOptionPB dateTypeOptionPB,
required CalendarFormat format,
required DateTime focusedDay,
required DateTime? startDay,
required DateTime? endDay,
required DateTime? dateTime,
required DateTime? endDateTime,
required String? time,
required String? endTime,
required bool includeTime,
required String? timeFormatError,
required bool isRange,
required String? parseTimeError,
required String? parseEndTimeError,
required String timeHintText,
}) = _DateCellCalendarState;
@ -275,15 +331,20 @@ class DateCellCalendarState with _$DateCellCalendarState {
DateTypeOptionPB dateTypeOptionPB,
DateCellDataPB? cellData,
) {
final (dateTime, time, includeTime) = _dateDataFromCellData(cellData);
final (dateTime, endDateTime, time, endTime, includeTime, isRange) =
_dateDataFromCellData(cellData);
return DateCellCalendarState(
dateTypeOptionPB: dateTypeOptionPB,
format: CalendarFormat.month,
focusedDay: DateTime.now(),
startDay: isRange ? dateTime : null,
endDay: isRange ? endDateTime : null,
dateTime: dateTime,
endDateTime: endDateTime,
time: time,
endTime: endTime,
includeTime: includeTime,
timeFormatError: null,
isRange: isRange,
parseTimeError: null,
parseEndTimeError: null,
timeHintText: _timeHintText(dateTypeOptionPB),
);
}
@ -300,21 +361,31 @@ String _timeHintText(DateTypeOptionPB typeOption) {
}
}
(DateTime?, String?, bool) _dateDataFromCellData(DateCellDataPB? cellData) {
(DateTime?, DateTime?, String?, String?, bool, bool) _dateDataFromCellData(
DateCellDataPB? cellData,
) {
// a null DateCellDataPB may be returned, indicating that all the fields are
// at their default values: empty strings and false booleans
if (cellData == null) {
return (null, null, false);
return (null, null, null, null, false, false);
}
DateTime? dateTime;
String? time;
DateTime? endDateTime;
String? endTime;
if (cellData.hasTimestamp()) {
final timestamp = cellData.timestamp * 1000;
dateTime = DateTime.fromMillisecondsSinceEpoch(timestamp.toInt());
time = cellData.time;
if (cellData.hasEndTimestamp()) {
final endTimestamp = cellData.endTimestamp * 1000;
endDateTime = DateTime.fromMillisecondsSinceEpoch(endTimestamp.toInt());
endTime = cellData.endTime;
}
}
final bool includeTime = cellData.includeTime;
final bool isRange = cellData.isRange;
return (dateTime, time, includeTime);
return (dateTime, endDateTime, time, endTime, includeTime, isRange);
}

View File

@ -74,7 +74,7 @@ class _DateCellState extends GridCellState<GridDateCell> {
controller: _popover,
triggerActions: PopoverTriggerFlags.none,
direction: PopoverDirection.bottomWithLeftAligned,
constraints: BoxConstraints.loose(const Size(260, 520)),
constraints: BoxConstraints.loose(const Size(260, 620)),
margin: EdgeInsets.zero,
child: GridDateCellText(
dateStr: state.dateStr,

View File

@ -79,8 +79,19 @@ class DateCellState with _$DateCellState {
}
String _dateStrFromCellData(DateCellDataPB? cellData) {
if (cellData == null || !cellData.hasTimestamp()) {
return "";
}
String dateStr = "";
if (cellData != null) {
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 {

View File

@ -3,6 +3,8 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/type_option/timestamp.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle_style.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/errors.pbserver.dart';
@ -111,18 +113,31 @@ class _CellCalendarWidgetState extends State<_CellCalendarWidget> {
duration: const Duration(milliseconds: 300),
child: state.includeTime
? _TimeTextField(
isEndTime: false,
timeStr: state.time,
popoverMutex: popoverMutex,
)
: const SizedBox.shrink(),
),
if (state.includeTime && state.isRange) const VSpace(8.0),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: state.includeTime && state.isRange
? _TimeTextField(
isEndTime: true,
timeStr: state.endTime,
popoverMutex: popoverMutex,
)
: const SizedBox.shrink(),
),
const DatePicker(),
const VSpace(8.0),
const TypeOptionSeparator(spacing: 8.0),
const TypeOptionSeparator(spacing: 12.0),
const EndTimeButton(),
const VSpace(4.0),
const _IncludeTimeButton(),
const TypeOptionSeparator(spacing: 8.0),
DateTypeOptionButton(popoverMutex: popoverMutex),
VSpace(GridSize.typeOptionSeparatorHeight),
const VSpace(4.0),
const ClearDateButton(),
];
@ -145,9 +160,17 @@ class _CellCalendarWidgetState extends State<_CellCalendarWidget> {
}
}
class DatePicker extends StatelessWidget {
class DatePicker extends StatefulWidget {
const DatePicker({super.key});
@override
State<DatePicker> createState() => _DatePickerState();
}
class _DatePickerState extends State<DatePicker> {
DateTime _focusedDay = DateTime.now();
CalendarFormat _calendarFormat = CalendarFormat.month;
@override
Widget build(BuildContext context) {
return BlocBuilder<DateCellCalendarBloc, DateCellCalendarState>(
@ -162,10 +185,15 @@ class DatePicker extends StatelessWidget {
child: TableCalendar(
firstDay: kFirstDay,
lastDay: kLastDay,
focusedDay: state.focusedDay,
focusedDay: _focusedDay,
rowHeight: 26.0 + 7.0,
calendarFormat: state.format,
calendarFormat: _calendarFormat,
daysOfWeekHeight: 17.0 + 8.0,
rangeSelectionMode: state.isRange
? RangeSelectionMode.enforced
: RangeSelectionMode.disabled,
rangeStartDay: state.isRange ? state.startDay : null,
rangeEndDay: state.isRange ? state.endDay : null,
headerStyle: HeaderStyle(
formatButtonVisible: false,
titleCentered: true,
@ -198,15 +226,29 @@ class DatePicker extends StatelessWidget {
),
weekendDecoration: boxDecoration,
outsideDecoration: boxDecoration,
rangeStartDecoration: boxDecoration.copyWith(
color: Theme.of(context).colorScheme.primary,
),
rangeEndDecoration: boxDecoration.copyWith(
color: Theme.of(context).colorScheme.primary,
),
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) {
@ -223,22 +265,24 @@ class DatePicker extends StatelessWidget {
);
},
),
selectedDayPredicate: (day) => isSameDay(state.dateTime, day),
selectedDayPredicate: (day) =>
state.isRange ? false : isSameDay(state.dateTime, day),
onDaySelected: (selectedDay, focusedDay) {
context.read<DateCellCalendarBloc>().add(
DateCellCalendarEvent.selectDay(selectedDay.toLocal().date),
);
},
onFormatChanged: (format) {
context
.read<DateCellCalendarBloc>()
.add(DateCellCalendarEvent.setCalFormat(format));
},
onPageChanged: (focusedDay) {
context
.read<DateCellCalendarBloc>()
.add(DateCellCalendarEvent.setFocusedDay(focusedDay));
onRangeSelected: (start, end, focusedDay) {
context.read<DateCellCalendarBloc>().add(
DateCellCalendarEvent.selectDateRange(start, end),
);
},
onFormatChanged: (calendarFormat) => setState(() {
_calendarFormat = calendarFormat;
}),
onPageChanged: (focusedDay) => setState(() {
_focusedDay = focusedDay;
}),
),
);
},
@ -268,13 +312,57 @@ class _IncludeTimeButton extends StatelessWidget {
}
}
@visibleForTesting
class EndTimeButton extends StatelessWidget {
const EndTimeButton({super.key});
@override
Widget build(BuildContext context) {
return BlocSelector<DateCellCalendarBloc, DateCellCalendarState, bool>(
selector: (state) => state.isRange,
builder: (context, isRange) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: SizedBox(
height: GridSize.popoverItemHeight,
child: Padding(
padding: GridSize.typeOptionContentInsets,
child: Row(
children: [
FlowySvg(
FlowySvgs.date_s,
color: Theme.of(context).iconTheme.color,
),
const HSpace(6),
FlowyText.medium(LocaleKeys.grid_field_isRange.tr()),
const Spacer(),
Toggle(
value: isRange,
onChanged: (value) => context
.read<DateCellCalendarBloc>()
.add(DateCellCalendarEvent.setIsRange(!value)),
style: ToggleStyle.big,
padding: EdgeInsets.zero,
),
],
),
),
),
);
},
);
}
}
class _TimeTextField extends StatefulWidget {
final bool isEndTime;
final String? timeStr;
final PopoverMutex popoverMutex;
const _TimeTextField({
required this.timeStr,
required this.popoverMutex,
required this.isEndTime,
Key? key,
}) : super(key: key);
@ -309,21 +397,41 @@ class _TimeTextFieldState extends State<_TimeTextField> {
@override
Widget build(BuildContext context) {
return BlocConsumer<DateCellCalendarBloc, DateCellCalendarState>(
listener: (context, state) => _textController.text = state.time ?? "",
listener: (context, state) {
if (widget.isEndTime) {
_textController.text = state.endTime ?? "";
} else {
_textController.text = state.time ?? "";
}
},
builder: (context, state) {
String text = "";
if (!widget.isEndTime && state.time != null) {
text = state.time!;
} else if (state.endTime != null) {
text = state.endTime!;
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: FlowyTextField(
text: state.time ?? "",
text: text,
focusNode: _focusNode,
controller: _textController,
submitOnLeave: true,
hintText: state.timeHintText,
errorText: state.timeFormatError,
errorText: widget.isEndTime
? state.parseEndTimeError
: state.parseTimeError,
onSubmitted: (timeStr) {
context
.read<DateCellCalendarBloc>()
.add(DateCellCalendarEvent.setTime(timeStr));
if (widget.isEndTime) {
context
.read<DateCellCalendarBloc>()
.add(DateCellCalendarEvent.setEndTime(timeStr));
} else {
context
.read<DateCellCalendarBloc>()
.add(DateCellCalendarEvent.setTime(timeStr));
}
},
),
);

View File

@ -107,7 +107,8 @@ class FlowyTextFieldState extends State<FlowyTextField> {
maxLengthEnforcement: MaxLengthEnforcement.truncateAfterCompositionEnds,
style: Theme.of(context).textTheme.bodySmall,
decoration: InputDecoration(
constraints: const BoxConstraints(maxHeight: 32),
constraints: BoxConstraints(
maxHeight: widget.errorText?.isEmpty ?? true ? 32 : 58),
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
@ -119,6 +120,10 @@ class FlowyTextFieldState extends State<FlowyTextField> {
isDense: false,
hintText: widget.hintText,
errorText: widget.errorText,
errorStyle: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(color: Theme.of(context).colorScheme.error),
hintStyle: Theme.of(context)
.textTheme
.bodySmall!

View File

@ -424,6 +424,7 @@
"numberFormat": "Number format",
"dateFormat": "Date format",
"includeTime": "Include time",
"isRange": "End date",
"dateFormatFriendly": "Month Day, Year",
"dateFormatISO": "Year-Month-Day",
"dateFormatLocal": "Month/Day/Year",

View File

@ -19,7 +19,19 @@ pub struct DateCellDataPB {
pub timestamp: i64,
#[pb(index = 4)]
pub end_date: String,
#[pb(index = 5)]
pub end_time: String,
#[pb(index = 6)]
pub end_timestamp: i64,
#[pb(index = 7)]
pub include_time: bool,
#[pb(index = 8)]
pub is_range: bool,
}
#[derive(Clone, Debug, Default, ProtoBuf)]
@ -34,9 +46,18 @@ pub struct DateChangesetPB {
pub time: Option<String>,
#[pb(index = 4, one_of)]
pub include_time: Option<bool>,
pub end_date: Option<i64>,
#[pb(index = 5, one_of)]
pub end_time: Option<String>,
#[pb(index = 6, one_of)]
pub include_time: Option<bool>,
#[pb(index = 7, one_of)]
pub is_range: Option<bool>,
#[pb(index = 8, one_of)]
pub clear_flag: Option<bool>,
}

View File

@ -641,7 +641,10 @@ pub(crate) async fn update_date_cell_handler(
let cell_changeset = DateCellChangeset {
date: data.date,
time: data.time,
end_date: data.end_date,
end_time: data.end_time,
include_time: data.include_time,
is_range: data.is_range,
clear_flag: data.clear_flag,
};
let database_editor = manager.get_database_with_view_id(&cell_id.view_id).await?;

View File

@ -210,9 +210,8 @@ pub fn insert_checkbox_cell(is_check: bool, field: &Field) -> Cell {
pub fn insert_date_cell(timestamp: i64, include_time: Option<bool>, field: &Field) -> Cell {
let cell_data = serde_json::to_string(&DateCellChangeset {
date: Some(timestamp),
time: None,
include_time,
clear_flag: None,
..Default::default()
})
.unwrap();
apply_cell_changeset(cell_data, None, field, None).unwrap()

View File

@ -27,7 +27,7 @@ mod tests {
date: Some(1647251762),
time: None,
include_time: None,
clear_flag: None,
..Default::default()
},
None,
"Mar 14, 2022",
@ -41,7 +41,7 @@ mod tests {
date: Some(1647251762),
time: None,
include_time: None,
clear_flag: None,
..Default::default()
},
None,
"2022/03/14",
@ -55,7 +55,7 @@ mod tests {
date: Some(1647251762),
time: None,
include_time: None,
clear_flag: None,
..Default::default()
},
None,
"2022-03-14",
@ -69,7 +69,7 @@ mod tests {
date: Some(1647251762),
time: None,
include_time: None,
clear_flag: None,
..Default::default()
},
None,
"03/14/2022",
@ -83,7 +83,7 @@ mod tests {
date: Some(1647251762),
time: None,
include_time: None,
clear_flag: None,
..Default::default()
},
None,
"14/03/2022",
@ -109,7 +109,7 @@ mod tests {
date: Some(1653609600),
time: None,
include_time: Some(true),
clear_flag: None,
..Default::default()
},
None,
"May 27, 2022 00:00",
@ -121,7 +121,7 @@ mod tests {
date: Some(1653609600),
time: Some("9:00".to_owned()),
include_time: Some(true),
clear_flag: None,
..Default::default()
},
None,
"May 27, 2022 09:00",
@ -133,7 +133,7 @@ mod tests {
date: Some(1653609600),
time: Some("23:00".to_owned()),
include_time: Some(true),
clear_flag: None,
..Default::default()
},
None,
"May 27, 2022 23:00",
@ -147,7 +147,7 @@ mod tests {
date: Some(1653609600),
time: None,
include_time: Some(true),
clear_flag: None,
..Default::default()
},
None,
"May 27, 2022 12:00 AM",
@ -159,7 +159,7 @@ mod tests {
date: Some(1653609600),
time: Some("9:00 AM".to_owned()),
include_time: Some(true),
clear_flag: None,
..Default::default()
},
None,
"May 27, 2022 09:00 AM",
@ -171,7 +171,7 @@ mod tests {
date: Some(1653609600),
time: Some("11:23 pm".to_owned()),
include_time: Some(true),
clear_flag: None,
..Default::default()
},
None,
"May 27, 2022 11:23 PM",
@ -195,7 +195,7 @@ mod tests {
date: Some(1653609600),
time: Some("1:".to_owned()),
include_time: Some(true),
clear_flag: None,
..Default::default()
},
None,
"May 27, 2022 01:00",
@ -216,7 +216,7 @@ mod tests {
date: Some(1653609600),
time: Some("".to_owned()),
include_time: Some(true),
clear_flag: None,
..Default::default()
},
None,
"May 27, 2022 01:00",
@ -235,7 +235,7 @@ mod tests {
date: Some(1653609600),
time: Some("00:00".to_owned()),
include_time: Some(true),
clear_flag: None,
..Default::default()
},
None,
"May 27, 2022 00:00",
@ -256,7 +256,7 @@ mod tests {
date: Some(1653609600),
time: Some("1:00 am".to_owned()),
include_time: Some(true),
clear_flag: None,
..Default::default()
},
None,
"May 27, 2022 01:00 AM",
@ -280,7 +280,7 @@ mod tests {
date: Some(1653609600),
time: Some("20:00".to_owned()),
include_time: Some(true),
clear_flag: None,
..Default::default()
},
None,
"May 27, 2022 08:00 PM",
@ -329,7 +329,7 @@ mod tests {
date: Some(1700006400),
time: Some("08:00".to_owned()),
include_time: Some(true),
clear_flag: None,
..Default::default()
},
);
assert_date(
@ -339,7 +339,7 @@ mod tests {
date: Some(1701302400),
time: None,
include_time: None,
clear_flag: None,
..Default::default()
},
Some(old_cell_data),
"Nov 30, 2023 08:00",
@ -357,7 +357,7 @@ mod tests {
date: Some(1700006400),
time: Some("08:00".to_owned()),
include_time: Some(true),
clear_flag: None,
..Default::default()
},
);
assert_date(
@ -367,7 +367,7 @@ mod tests {
date: None,
time: Some("14:00".to_owned()),
include_time: None,
clear_flag: None,
..Default::default()
},
Some(old_cell_data),
"Nov 15, 2023 14:00",
@ -385,7 +385,7 @@ mod tests {
date: Some(1700006400),
time: Some("08:00".to_owned()),
include_time: Some(true),
clear_flag: None,
..Default::default()
},
);
assert_date(
@ -396,12 +396,142 @@ mod tests {
time: None,
include_time: Some(true),
clear_flag: Some(true),
..Default::default()
},
Some(old_cell_data),
"",
);
}
#[test]
fn end_date_time_test() {
let type_option = DateTypeOption::test();
let field = FieldBuilder::from_field_type(FieldType::DateTime).build();
assert_date(
&type_option,
&field,
DateCellChangeset {
date: Some(1653609600),
end_date: Some(1653782400),
include_time: Some(false),
is_range: Some(true),
..Default::default()
},
None,
"May 27, 2022 → May 29, 2022",
);
assert_date(
&type_option,
&field,
DateCellChangeset {
date: Some(1653609600),
time: Some("20:00".to_owned()),
end_date: Some(1653782400),
end_time: Some("08:00".to_owned()),
include_time: Some(true),
is_range: Some(true),
..Default::default()
},
None,
"May 27, 2022 20:00 → May 29, 2022 08:00",
);
assert_date(
&type_option,
&field,
DateCellChangeset {
date: Some(1653609600),
time: Some("20:00".to_owned()),
end_date: Some(1653782400),
include_time: Some(true),
is_range: Some(true),
..Default::default()
},
None,
"May 27, 2022 20:00 → May 29, 2022 00:00",
);
}
#[test]
fn turn_on_date_range() {
let type_option = DateTypeOption::test();
let field = FieldBuilder::from_field_type(FieldType::DateTime).build();
let old_cell_data = initialize_date_cell(
&type_option,
DateCellChangeset {
date: Some(1653609600),
time: Some("08:00".to_owned()),
include_time: Some(true),
..Default::default()
},
);
assert_date(
&type_option,
&field,
DateCellChangeset {
is_range: Some(true),
..Default::default()
},
Some(old_cell_data),
"May 27, 2022 08:00 → May 27, 2022 08:00",
);
}
#[test]
fn add_an_end_time() {
let type_option = DateTypeOption::test();
let field = FieldBuilder::from_field_type(FieldType::DateTime).build();
let old_cell_data = initialize_date_cell(
&type_option,
DateCellChangeset {
date: Some(1653609600),
time: Some("08:00".to_owned()),
include_time: Some(true),
..Default::default()
},
);
assert_date(
&type_option,
&field,
DateCellChangeset {
date: None,
time: None,
end_date: Some(1700006400),
end_time: Some("16:00".to_owned()),
include_time: Some(true),
is_range: Some(true),
..Default::default()
},
Some(old_cell_data),
"May 27, 2022 08:00 → Nov 15, 2023 16:00",
);
}
#[test]
#[should_panic]
fn end_date_with_no_start_date() {
let type_option = DateTypeOption::test();
let field = FieldBuilder::from_field_type(FieldType::DateTime).build();
assert_date(
&type_option,
&field,
DateCellChangeset {
date: None,
end_date: Some(1653782400),
include_time: Some(false),
is_range: Some(true),
..Default::default()
},
None,
"→ May 29, 2022",
);
}
fn assert_date(
type_option: &DateTypeOption,
field: &Field,

View File

@ -70,15 +70,24 @@ impl TypeOptionCellDataSerde for DateTypeOption {
&self,
cell_data: <Self as TypeOption>::CellData,
) -> <Self as TypeOption>::CellProtobufType {
let timestamp = cell_data.timestamp;
let include_time = cell_data.include_time;
let is_range = cell_data.is_range;
let timestamp = cell_data.timestamp;
let (date, time) = self.formatted_date_time_from_timestamp(&timestamp);
let end_timestamp = cell_data.end_timestamp;
let (end_date, end_time) = self.formatted_date_time_from_timestamp(&end_timestamp);
DateCellDataPB {
date,
time,
timestamp: timestamp.unwrap_or_default(),
end_date,
end_time,
end_timestamp: end_timestamp.unwrap_or_default(),
include_time,
is_range,
}
}
@ -135,6 +144,8 @@ impl DateTypeOption {
}
}
/// combine the changeset_timestamp and parsed_time if provided. if
/// changeset_timestamp is None, fallback to previous_timestamp
fn timestamp_from_parsed_time_previous_and_new_timestamp(
&self,
parsed_time: Option<NaiveTime>,
@ -142,7 +153,7 @@ impl DateTypeOption {
changeset_timestamp: Option<i64>,
) -> Option<i64> {
if let Some(time) = parsed_time {
// a valid time is provided, so we replace the time component of old
// a valid time is provided, so we replace the time component of old timestamp
// (or new timestamp if provided) with it.
let utc_date = changeset_timestamp
.or(previous_timestamp)
@ -206,13 +217,30 @@ impl CellDataDecoder for DateTypeOption {
}
fn stringify_cell_data(&self, cell_data: <Self as TypeOption>::CellData) -> String {
let timestamp = cell_data.timestamp;
let include_time = cell_data.include_time;
let (date_string, time_string) = self.formatted_date_time_from_timestamp(&timestamp);
if include_time && timestamp.is_some() {
format!("{} {}", date_string, time_string)
let timestamp = cell_data.timestamp;
let is_range = cell_data.is_range;
let (date, time) = self.formatted_date_time_from_timestamp(&timestamp);
if is_range {
let (end_date, end_time) = match cell_data.end_timestamp {
Some(timestamp) => self.formatted_date_time_from_timestamp(&Some(timestamp)),
None => (date.clone(), time.clone()),
};
if include_time && timestamp.is_some() {
format!("{} {}{} {}", date, time, end_date, end_time)
.trim()
.to_string()
} else if timestamp.is_some() {
format!("{}{}", date, end_date).trim().to_string()
} else {
"".to_string()
}
} else if include_time {
format!("{} {}", date, time).trim().to_string()
} else {
date_string
date
}
}
@ -229,25 +257,33 @@ impl CellDataChangeset for DateTypeOption {
cell: Option<Cell>,
) -> FlowyResult<(Cell, <Self as TypeOption>::CellData)> {
// old date cell data
let (previous_timestamp, include_time) = match cell {
let (previous_timestamp, previous_end_timestamp, include_time, is_range) = match cell {
Some(cell) => {
let cell_data = DateCellData::from(&cell);
(cell_data.timestamp, cell_data.include_time)
(
cell_data.timestamp,
cell_data.end_timestamp,
cell_data.include_time,
cell_data.is_range,
)
},
None => (None, false),
None => (None, None, false, false),
};
if changeset.clear_flag == Some(true) {
let cell_data = DateCellData {
timestamp: None,
end_timestamp: None,
include_time,
is_range,
};
return Ok((Cell::from(&cell_data), cell_data));
}
// update include_time if necessary
// update include_time and is_range if necessary
let include_time = changeset.include_time.unwrap_or(include_time);
let is_range = changeset.is_range.unwrap_or(is_range);
// Calculate the timestamp in the time zone specified in type option. If
// a new timestamp is included in the changeset without an accompanying
@ -255,17 +291,38 @@ impl CellDataChangeset for DateTypeOption {
// order to change the day without changing the time, the old time string
// should be passed in as well.
let parsed_time = self.naive_time_from_time_string(include_time, changeset.time)?;
// parse the time string, which is in the local timezone
let parsed_start_time = self.naive_time_from_time_string(include_time, changeset.time)?;
let timestamp = self.timestamp_from_parsed_time_previous_and_new_timestamp(
parsed_time,
parsed_start_time,
previous_timestamp,
changeset.date,
);
let end_timestamp =
if is_range && changeset.end_date.is_none() && previous_end_timestamp.is_none() {
// just toggled is_range so no passed in or existing end time data
timestamp
} else if is_range {
// parse the changeset's end time data or fallback to previous version
let parsed_end_time = self.naive_time_from_time_string(include_time, changeset.end_time)?;
self.timestamp_from_parsed_time_previous_and_new_timestamp(
parsed_end_time,
previous_end_timestamp,
changeset.end_date,
)
} else {
// clear the end time data
None
};
let cell_data = DateCellData {
timestamp,
end_timestamp,
include_time,
is_range,
};
Ok((Cell::from(&cell_data), cell_data))

View File

@ -20,7 +20,10 @@ use crate::services::field::{TypeOptionCellData, CELL_DATA};
pub struct DateCellChangeset {
pub date: Option<i64>,
pub time: Option<String>,
pub end_date: Option<i64>,
pub end_time: Option<String>,
pub include_time: Option<bool>,
pub is_range: Option<bool>,
pub clear_flag: Option<bool>,
}
@ -42,15 +45,20 @@ impl ToCellChangeset for DateCellChangeset {
#[derive(Default, Clone, Debug, Serialize)]
pub struct DateCellData {
pub timestamp: Option<i64>,
pub end_timestamp: Option<i64>,
#[serde(default)]
pub include_time: bool,
#[serde(default)]
pub is_range: bool,
}
impl DateCellData {
pub fn new(timestamp: i64, include_time: bool) -> Self {
pub fn new(timestamp: i64, include_time: bool, is_range: bool) -> Self {
Self {
timestamp: Some(timestamp),
end_timestamp: None,
include_time,
is_range,
}
}
}
@ -66,10 +74,16 @@ impl From<&Cell> for DateCellData {
let timestamp = cell
.get_str_value(CELL_DATA)
.and_then(|data| data.parse::<i64>().ok());
let end_timestamp = cell
.get_str_value("end_timestamp")
.and_then(|data| data.parse::<i64>().ok());
let include_time = cell.get_bool_value("include_time").unwrap_or_default();
let is_range = cell.get_bool_value("is_range").unwrap_or_default();
Self {
timestamp,
end_timestamp,
include_time,
is_range,
}
}
}
@ -78,7 +92,9 @@ impl From<&DateCellDataPB> for DateCellData {
fn from(data: &DateCellDataPB) -> Self {
Self {
timestamp: Some(data.timestamp),
end_timestamp: Some(data.end_timestamp),
include_time: data.include_time,
is_range: data.is_range,
}
}
}
@ -89,9 +105,17 @@ impl From<&DateCellData> for Cell {
Some(timestamp) => timestamp.to_string(),
None => "".to_owned(),
};
let end_timestamp_string = match cell_data.end_timestamp {
Some(timestamp) => timestamp.to_string(),
None => "".to_owned(),
};
// Most of the case, don't use these keys in other places. Otherwise, we should define
// constants for them.
new_cell_builder(FieldType::DateTime)
.insert_str_value(CELL_DATA, timestamp_string)
.insert_str_value("end_timestamp", end_timestamp_string)
.insert_bool_value("include_time", cell_data.include_time)
.insert_bool_value("is_range", cell_data.is_range)
.build()
}
}
@ -118,7 +142,9 @@ impl<'de> serde::Deserialize<'de> for DateCellData {
{
Ok(DateCellData {
timestamp: Some(value),
end_timestamp: None,
include_time: false,
is_range: false,
})
}
@ -134,25 +160,36 @@ impl<'de> serde::Deserialize<'de> for DateCellData {
M: serde::de::MapAccess<'de>,
{
let mut timestamp: Option<i64> = None;
let mut end_timestamp: Option<i64> = None;
let mut include_time: Option<bool> = None;
let mut is_range: Option<bool> = None;
while let Some(key) = map.next_key()? {
match key {
"timestamp" => {
timestamp = map.next_value()?;
},
"end_timestamp" => {
end_timestamp = map.next_value()?;
},
"include_time" => {
include_time = map.next_value()?;
},
"is_range" => {
is_range = map.next_value()?;
},
_ => {},
}
}
let include_time = include_time.unwrap_or_default();
let is_range = is_range.unwrap_or_default();
Ok(DateCellData {
timestamp,
end_timestamp,
include_time,
is_range,
})
}
}

View File

@ -26,13 +26,39 @@ mod tests {
let data = DateCellData {
timestamp: Some(1647251762),
end_timestamp: None,
include_time: true,
is_range: false,
};
assert_eq!(
stringify_cell_data(&(&data).into(), &FieldType::RichText, &field_type, &field),
"Mar 14, 2022 09:56"
);
let data = DateCellData {
timestamp: Some(1647251762),
end_timestamp: Some(1648533809),
include_time: true,
is_range: false,
};
assert_eq!(
stringify_cell_data(&(&data).into(), &FieldType::RichText, &field_type, &field),
"Mar 14, 2022 09:56"
);
let data = DateCellData {
timestamp: Some(1647251762),
end_timestamp: Some(1648533809),
include_time: true,
is_range: true,
};
assert_eq!(
stringify_cell_data(&(&data).into(), &FieldType::RichText, &field_type, &field),
"Mar 14, 2022 09:56 → Mar 29, 2022 06:03"
);
}
fn to_text_cell(s: String) -> Cell {

View File

@ -476,6 +476,7 @@ mod tests {
let mar_14_2022_cd = DateCellData {
timestamp: Some(mar_14_2022.timestamp()),
include_time: false,
..Default::default()
};
let today = offset::Local::now();
let three_days_before = today.checked_add_signed(Duration::days(-3)).unwrap();
@ -497,6 +498,7 @@ mod tests {
cell_data: DateCellData {
timestamp: Some(today.timestamp()),
include_time: false,
..Default::default()
},
type_option: &local_date_type_option,
setting_content: r#"{"condition": 0, "hide_empty": false}"#.to_string(),
@ -507,6 +509,7 @@ mod tests {
cell_data: DateCellData {
timestamp: Some(three_days_before.timestamp()),
include_time: false,
..Default::default()
},
type_option: &local_date_type_option,
setting_content: r#"{"condition": 0, "hide_empty": false}"#.to_string(),
@ -533,6 +536,7 @@ mod tests {
.timestamp(),
),
include_time: false,
..Default::default()
},
type_option: &local_date_type_option,
setting_content: r#"{"condition": 2, "hide_empty": false}"#.to_string(),
@ -557,6 +561,7 @@ mod tests {
cell_data: DateCellData {
timestamp: Some(1685715999),
include_time: false,
..Default::default()
},
type_option: &default_date_type_option,
setting_content: r#"{"condition": 1, "hide_empty": false}"#.to_string(),
@ -567,6 +572,7 @@ mod tests {
cell_data: DateCellData {
timestamp: Some(1685802386),
include_time: false,
..Default::default()
},
type_option: &default_date_type_option,
setting_content: r#"{"condition": 1, "hide_empty": false}"#.to_string(),

View File

@ -331,7 +331,7 @@ impl<'a> TestRowBuilder<'a> {
date: Some(data),
time,
include_time,
clear_flag: None,
..Default::default()
})
.unwrap();
let date_field = self.field_with_type(field_type);

View File

@ -102,7 +102,10 @@ pub fn make_date_cell_string(timestamp: i64) -> String {
serde_json::to_string(&DateCellChangeset {
date: Some(timestamp),
time: None,
end_date: None,
end_time: None,
include_time: Some(false),
is_range: Some(false),
clear_flag: None,
})
.unwrap()

View File

@ -528,9 +528,7 @@ async fn update_date_cell_event_test() {
.update_date_cell(DateChangesetPB {
cell_id: cell_path,
date: Some(timestamp),
time: None,
include_time: None,
clear_flag: None,
..Default::default()
})
.await;
assert!(error.is_none());
@ -892,9 +890,7 @@ async fn create_calendar_event_test() {
row_id: row.id,
},
date: Some(timestamp()),
time: None,
include_time: None,
clear_flag: None,
..Default::default()
})
.await;
assert!(error.is_none());