Sfoglia il codice sorgente

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 <[email protected]>
Richard Shiue 1 anno fa
parent
commit
124d435f09
21 ha cambiato i file con 629 aggiunte e 124 eliminazioni
  1. 1 1
      frontend/appflowy_flutter/integration_test/database_cell_test.dart
  2. 12 1
      frontend/appflowy_flutter/integration_test/util/database_test_op.dart
  3. 12 1
      frontend/appflowy_flutter/lib/plugins/database_view/application/cell/date_cell_service.dart
  4. 12 3
      frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/bloc/date_card_cell_bloc.dart
  5. 119 48
      frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/date_cell/date_cal_bloc.dart
  6. 1 1
      frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/date_cell/date_cell.dart
  7. 12 1
      frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/date_cell/date_cell_bloc.dart
  8. 130 22
      frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/date_cell/date_editor.dart
  9. 6 1
      frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_field.dart
  10. 1 0
      frontend/resources/translations/en.json
  11. 22 1
      frontend/rust-lib/flowy-database2/src/entities/type_option_entities/date_entities.rs
  12. 3 0
      frontend/rust-lib/flowy-database2/src/event_handler.rs
  13. 1 2
      frontend/rust-lib/flowy-database2/src/services/cell/cell_operation.rs
  14. 151 21
      frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_tests.rs
  15. 70 13
      frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option.rs
  16. 38 1
      frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option_entities.rs
  17. 26 0
      frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_tests.rs
  18. 6 0
      frontend/rust-lib/flowy-database2/src/services/group/controller_impls/date_controller.rs
  19. 1 1
      frontend/rust-lib/flowy-database2/tests/database/database_editor.rs
  20. 3 0
      frontend/rust-lib/flowy-database2/tests/database/field_test/util.rs
  21. 2 6
      frontend/rust-lib/flowy-test/tests/database/local_test/test.rs

+ 1 - 1
frontend/appflowy_flutter/integration_test/database_cell_test.dart

@@ -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();
 

+ 12 - 1
frontend/appflowy_flutter/integration_test/util/database_test_op.dart

@@ -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,

+ 12 - 1
frontend/appflowy_flutter/lib/plugins/database_view/application/cell/date_cell_service.dart

@@ -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();
   }

+ 12 - 3
frontend/appflowy_flutter/lib/plugins/database_view/widgets/card/bloc/date_card_cell_bloc.dart

@@ -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;

+ 119 - 48
frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/date_cell/date_cal_bloc.dart

@@ -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);
 }

+ 1 - 1
frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/date_cell/date_cell.dart

@@ -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,

+ 12 - 1
frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/date_cell/date_cell_bloc.dart

@@ -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 {

+ 130 - 22
frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/date_cell/date_editor.dart

@@ -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));
+              }
             },
           ),
         );

+ 6 - 1
frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_field.dart

@@ -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!

+ 1 - 0
frontend/resources/translations/en.json

@@ -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",

+ 22 - 1
frontend/rust-lib/flowy-database2/src/entities/type_option_entities/date_entities.rs

@@ -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>,
 }
 

+ 3 - 0
frontend/rust-lib/flowy-database2/src/event_handler.rs

@@ -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?;

+ 1 - 2
frontend/rust-lib/flowy-database2/src/services/cell/cell_operation.rs

@@ -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()

+ 151 - 21
frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_tests.rs

@@ -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,

+ 70 - 13
frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option.rs

@@ -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))

+ 38 - 1
frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option_entities.rs

@@ -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,
         })
       }
     }

+ 26 - 0
frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_tests.rs

@@ -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 {

+ 6 - 0
frontend/rust-lib/flowy-database2/src/services/group/controller_impls/date_controller.rs

@@ -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(),

+ 1 - 1
frontend/rust-lib/flowy-database2/tests/database/database_editor.rs

@@ -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);

+ 3 - 0
frontend/rust-lib/flowy-database2/tests/database/field_test/util.rs

@@ -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()

+ 2 - 6
frontend/rust-lib/flowy-test/tests/database/local_test/test.rs

@@ -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());