feat: reminder on date (#4288)

* feat: support reminder on date

* feat: support reminder on date in database

* fix: include time static

* fix: do not force unwrap

* chore: clean flutter code

* test: add test for reminder in database

* fix: interpret reminder option

* feat: date and reminder on mobile

* feat: improve notification actions and support open row

* feat: support dates in document

* fix: minor changes + review

* feat: support reminder on mobile in document

* feat: support open row on database reminder mobile

* test: add more tests

* fix: first part of review

* fix: open row responsibility

* fix: abstract application logic from presentation layer

* fix: update reminder on date cell update

* test: fix failing test

* fix: show correct selected day after end date toggled
This commit is contained in:
Mathias Mogensen 2024-01-24 15:15:57 +01:00 committed by GitHub
parent 8105da1c2b
commit baa7c8d826
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
62 changed files with 2556 additions and 1315 deletions

View File

@ -0,0 +1,122 @@
import 'package:appflowy/workspace/presentation/notifications/widgets/notification_item.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../util/database_test_op.dart';
import '../util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('reminder in database', () {
testWidgets('add date field and add reminder', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
// Invoke the field editor
await tester.tapGridFieldWithName('Type');
await tester.tapEditPropertyButton();
// Change to date type
await tester.tapTypeOptionButton();
await tester.selectFieldType(FieldType.DateTime);
await tester.dismissFieldEditor();
// Open date picker
await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.DateTime);
await tester.findDateEditor(findsOneWidget);
// Select date
await tester.selectLastDateInPicker();
// Select Time of event reminder
await tester.selectReminderOption(ReminderOption.atTimeOfEvent);
// Expect Time of event to be displayed
tester.expectSelectedReminder(ReminderOption.atTimeOfEvent);
// Dismiss the cell/date editor
await tester.dismissCellEditor();
// Open date picker again
await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.DateTime);
await tester.findDateEditor(findsOneWidget);
// Expect Time of event to be displayed
tester.expectSelectedReminder(ReminderOption.atTimeOfEvent);
// Dismiss the cell/date editor
await tester.dismissCellEditor();
// Open "Upcoming" in Notification hub
await tester.openNotificationHub(tabIndex: 1);
// Expect 1 notification
tester.expectNotificationItems(1);
});
testWidgets('navigate from reminder to open row', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
// Invoke the field editor
await tester.tapGridFieldWithName('Type');
await tester.tapEditPropertyButton();
// Change to date type
await tester.tapTypeOptionButton();
await tester.selectFieldType(FieldType.DateTime);
await tester.dismissFieldEditor();
// Open date picker
await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.DateTime);
await tester.findDateEditor(findsOneWidget);
// Select date
await tester.selectLastDateInPicker();
// Select Time of event reminder
await tester.selectReminderOption(ReminderOption.atTimeOfEvent);
// Expect Time of event to be displayed
tester.expectSelectedReminder(ReminderOption.atTimeOfEvent);
// Dismiss the cell/date editor
await tester.dismissCellEditor();
// Open date picker again
await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.DateTime);
await tester.findDateEditor(findsOneWidget);
// Expect Time of event to be displayed
tester.expectSelectedReminder(ReminderOption.atTimeOfEvent);
// Dismiss the cell/date editor
await tester.dismissCellEditor();
// Create and Navigate to a new document
await tester.createNewPageWithNameUnderParent();
await tester.pumpAndSettle();
// Open "Upcoming" in Notification hub
await tester.openNotificationHub(tabIndex: 1);
// Expect 1 notification
tester.expectNotificationItems(1);
// Tap on the notification
await tester.tap(find.byType(NotificationItem));
await tester.pumpAndSettle();
// Expect to see Row Editor Dialog
tester.expectToSeeRowDetailsPageDialog();
});
});
}

View File

@ -1,20 +1,23 @@
import 'package:flutter/services.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
import 'package:appflowy/user/application/user_settings_service.dart';
import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart';
import 'package:appflowy/workspace/presentation/notifications/widgets/notification_item.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:calendar_view/calendar_view.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text_field.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../util/base.dart';
import '../util/common_operations.dart';
import '../util/editor_test_operations.dart';
import '../util/expectation.dart';
import '../util/keyboard.dart';
void main() {
@ -35,7 +38,7 @@ void main() {
await tester.pumpAndSettle();
// Trigger iline action menu and type 'remind tomorrow'
// Trigger inline action menu and type 'remind tomorrow'
final tomorrow = await _insertReminderTomorrow(tester);
Node node = tester.editor.getCurrentEditorState().getNodeAtPath([1])!;
@ -43,7 +46,7 @@ void main() {
node.delta!.first.attributes![MentionBlockKeys.mention];
expect(node.type, 'paragraph');
expect(mentionAttr['type'], MentionType.reminder.name);
expect(mentionAttr['type'], MentionType.date.name);
expect(mentionAttr['date'], tomorrow.toIso8601String());
await tester.tap(
@ -67,9 +70,57 @@ void main() {
_dateWithTime(dateTimeSettings.timeFormat, tomorrow, time);
expect(node.type, 'paragraph');
expect(mentionAttr['type'], MentionType.reminder.name);
expect(mentionAttr['type'], MentionType.date.name);
expect(mentionAttr['date'], tomorrowWithTime.toIso8601String());
});
testWidgets('Add reminder for tomorrow, and navigate to it',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.getCurrentEditorState().insertNewLine();
await tester.pumpAndSettle();
// Trigger inline action menu and type 'remind tomorrow'
final tomorrow = await _insertReminderTomorrow(tester);
final Node node =
tester.editor.getCurrentEditorState().getNodeAtPath([1])!;
final Map<String, dynamic> mentionAttr =
node.delta!.first.attributes![MentionBlockKeys.mention];
expect(node.type, 'paragraph');
expect(mentionAttr['type'], MentionType.date.name);
expect(mentionAttr['date'], tomorrow.toIso8601String());
// Create and Navigate to a new document
await tester.createNewPageWithNameUnderParent();
await tester.pumpAndSettle();
// Open "Upcoming" in Notification hub
await tester.openNotificationHub(tabIndex: 1);
// Expect 1 notification
tester.expectNotificationItems(1);
// Tap on the notification
await tester.tap(find.byType(NotificationItem));
await tester.pumpAndSettle();
// Expect node at path 1 to be the date/reminder
expect(
tester.editor
.getCurrentEditorState()
.getNodeAtPath([1])
?.delta
?.first
.attributes?[MentionBlockKeys.mention]['type'],
MentionType.date.name,
);
});
});
}

View File

@ -14,6 +14,9 @@ import 'package:appflowy/workspace/presentation/home/menu/view/draggable_view_it
import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_add_button.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_more_action_button.dart';
import 'package:appflowy/workspace/presentation/notifications/widgets/flowy_tab.dart';
import 'package:appflowy/workspace/presentation/notifications/widgets/notification_button.dart';
import 'package:appflowy/workspace/presentation/notifications/widgets/notification_tab_bar.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/settings_language_view.dart';
import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart';
import 'package:appflowy_backend/log.dart';
@ -493,6 +496,30 @@ extension CommonOperations on WidgetTester {
await tapEmoji(icon);
await pumpAndSettle();
}
Future<void> openNotificationHub({
int tabIndex = 0,
}) async {
final finder = find.descendant(
of: find.byType(NotificationButton),
matching: find.byWidgetPredicate(
(widget) => widget is FlowySvg && widget.svg == FlowySvgs.clock_alarm_s,
),
);
await tap(finder);
await pumpAndSettle();
if (tabIndex == 1) {
final tabFinder = find.descendant(
of: find.byType(NotificationTabBar),
matching: find.byType(FlowyTabItem).at(1),
);
await tap(tabFinder);
await pumpAndSettle();
}
}
}
extension ViewLayoutPBTest on ViewLayoutPB {

View File

@ -60,6 +60,7 @@ import 'package:appflowy/plugins/database/widgets/setting/setting_property_list.
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/clear_date_button.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_type_option_button.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/end_time_button.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
@ -76,6 +77,9 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:table_calendar/table_calendar.dart';
// Non-exported member of the table_calendar library
import 'package:table_calendar/src/widgets/cell_content.dart';
import 'base.dart';
import 'common_operations.dart';
import 'expectation.dart';
@ -343,6 +347,23 @@ extension AppFlowyDatabaseTest on WidgetTester {
await tapButton(finder);
}
Future<void> selectReminderOption(ReminderOption option) async {
await hoverOnWidget(find.byType(ReminderSelector));
final finder = find.descendant(
of: find.byType(FlowyButton),
matching: find.text(option.label),
);
await tapButton(finder);
}
Future<void> selectLastDateInPicker() async {
final finder = find.byType(CellContent).last;
await tapButton(finder);
}
Future<void> toggleDateRange() async {
final findDateEditor = find.byType(EndTimeButton);
final findToggle = find.byType(Toggle);

View File

@ -1,14 +1,18 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/widgets/row/row_detail.dart';
import 'package:appflowy/plugins/document/presentation/banner.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy/workspace/presentation/notifications/widgets/notification_item.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart';
import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'util.dart';
@ -242,4 +246,29 @@ extension Expectation on WidgetTester {
);
expect(icon, findsOneWidget);
}
void expectSelectedReminder(ReminderOption option) {
final findSelectedText = find.descendant(
of: find.byType(ReminderSelector),
matching: find.text(option.label),
);
expect(findSelectedText, findsOneWidget);
}
void expectNotificationItems(int amount) {
final findItems = find.byType(NotificationItem);
expect(findItems, findsNWidgets(amount));
}
void expectToSeeRowDetailsPageDialog() {
expect(
find.descendant(
of: find.byType(RowDetailPage),
matching: find.byType(SimpleDialog),
),
findsOneWidget,
);
}
}

View File

@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:appflowy/mobile/presentation/database/board/mobile_board_screen.dart';
import 'package:appflowy/mobile/presentation/database/mobile_calendar_screen.dart';
import 'package:appflowy/mobile/presentation/database/mobile_grid_screen.dart';
@ -8,11 +10,11 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
extension MobileRouter on BuildContext {
Future<void> pushView(ViewPB view) async {
Future<void> pushView(ViewPB view, [Map<String, dynamic>? arguments]) async {
push(
Uri(
path: view.routeName,
queryParameters: view.queryParameters,
queryParameters: view.queryParameters(arguments),
).toString(),
).then((value) {
RecentService().updateRecentViews([view.id], true);
@ -36,7 +38,7 @@ extension on ViewPB {
}
}
Map<String, dynamic> get queryParameters {
Map<String, dynamic> queryParameters([Map<String, dynamic>? arguments]) {
switch (layout) {
case ViewLayoutPB.Document:
return {
@ -47,6 +49,7 @@ extension on ViewPB {
return {
MobileGridScreen.viewId: id,
MobileGridScreen.viewTitle: name,
MobileGridScreen.viewArgs: jsonEncode(arguments),
};
case ViewLayoutPB.Calendar:
return {

View File

@ -1,9 +1,13 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/app_bar_actions.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_state_container.dart';
import 'package:appflowy/plugins/base/emoji/emoji_text.dart';
import 'package:appflowy/plugins/document/document_page.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
@ -13,7 +17,6 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:dartz/dartz.dart' hide State;
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
@ -21,14 +24,16 @@ class MobileViewPage extends StatefulWidget {
const MobileViewPage({
super.key,
required this.id,
this.title,
required this.viewLayout,
this.title,
this.arguments,
});
/// view id
final String id;
final String? title;
final ViewLayoutPB viewLayout;
final String? title;
final Map<String, dynamic>? arguments;
@override
State<MobileViewPage> createState() => _MobileViewPageState();
@ -40,7 +45,6 @@ class _MobileViewPageState extends State<MobileViewPage> {
@override
void initState() {
super.initState();
future = ViewBackendService.getView(widget.id);
}
@ -67,7 +71,10 @@ class _MobileViewPageState extends State<MobileViewPage> {
body = state.data!.fold((view) {
viewPB = view;
actions.add(_buildAppBarMoreButton(view));
return view.plugin().widgetBuilder.buildWidget(shrinkWrap: false);
return view
.plugin(arguments: widget.arguments ?? const {})
.widgetBuilder
.buildWidget(shrinkWrap: false);
}, (error) {
return FlowyMobileStateContainer.error(
emoji: '😔',
@ -89,6 +96,10 @@ class _MobileViewPageState extends State<MobileViewPage> {
create: (_) =>
ViewBloc(view: viewPB!)..add(const ViewEvent.initial()),
),
BlocProvider.value(
value: getIt<ReminderBloc>()
..add(const ReminderEvent.started()),
),
],
child: Builder(
builder: (context) {
@ -131,9 +142,7 @@ class _MobileViewPageState extends State<MobileViewPage> {
leading: const AppBarBackButton(),
actions: actions,
),
body: SafeArea(
child: child,
),
body: SafeArea(child: child),
);
}

View File

@ -1,24 +1,24 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/plugins/base/drag_handler.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/date_cell/date_cell_editor_bloc.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/date_cell/mobile_date_editor.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/mobile_appflowy_date_picker.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/mobile_date_header.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
class MobileDateCellEditScreen extends StatefulWidget {
static const routeName = '/edit_date_cell';
// the type is DateCellController
static const dateCellController = 'date_cell_controller';
// bool value, default is true
static const fullScreen = 'full_screen';
@ -38,20 +38,13 @@ class MobileDateCellEditScreen extends StatefulWidget {
class _MobileDateCellEditScreenState extends State<MobileDateCellEditScreen> {
@override
Widget build(BuildContext context) {
return widget.showAsFullScreen ? _buildFullScreen() : _buildNotFullScreen();
}
Widget build(BuildContext context) =>
widget.showAsFullScreen ? _buildFullScreen() : _buildNotFullScreen();
Widget _buildFullScreen() {
return Scaffold(
appBar: AppBar(
title: FlowyText.medium(
LocaleKeys.titleBar_date.tr(),
),
),
body: _DateCellEditBody(
dateCellController: widget.controller,
),
appBar: AppBar(title: FlowyText.medium(LocaleKeys.titleBar_date.tr())),
body: _buildDatePicker(),
);
}
@ -71,353 +64,73 @@ class _MobileDateCellEditScreenState extends State<MobileDateCellEditScreen> {
color: Theme.of(context).colorScheme.surface,
child: const Center(child: DragHandler()),
),
_buildHeader(),
_DateCellEditBody(
dateCellController: widget.controller,
),
const MobileDateHeader(),
_buildDatePicker(),
],
),
),
);
}
Widget _buildHeader() {
const iconWidth = 30.0;
const height = 44.0;
return Container(
color: Theme.of(context).colorScheme.surface,
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Stack(
children: [
Align(
alignment: Alignment.centerLeft,
child: FlowyIconButton(
icon: const FlowySvg(
FlowySvgs.close_s,
size: Size.square(iconWidth),
),
width: iconWidth,
iconPadding: EdgeInsets.zero,
onPressed: () => context.pop(),
),
Widget _buildDatePicker() => MultiBlocProvider(
providers: [
BlocProvider<DateCellEditorBloc>(
create: (_) => DateCellEditorBloc(
reminderBloc: getIt<ReminderBloc>(),
cellController: widget.controller,
)..add(const DateCellEditorEvent.initial()),
),
Align(
alignment: Alignment.center,
child: FlowyText.medium(
LocaleKeys.grid_field_dateFieldName.tr(),
fontSize: 16,
),
),
].map((e) => SizedBox(height: height, child: e)).toList(),
),
);
}
}
class _DateCellEditBody extends StatelessWidget {
const _DateCellEditBody({
required this.dateCellController,
});
final DateCellController dateCellController;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => DateCellEditorBloc(
cellController: dateCellController,
)..add(const DateCellEditorEvent.initial()),
child: const Column(
children: [
FlowyOptionDecorateBox(
showTopBorder: false,
child: _IncludeTimePicker(),
),
_Divider(),
FlowyOptionDecorateBox(
child: MobileDatePicker(),
),
_Divider(),
_EndDateSwitch(),
_IncludeTimeSwitch(),
_Divider(),
_ClearDateButton(),
_Divider(),
],
),
);
}
}
class _Divider extends StatelessWidget {
const _Divider();
@override
Widget build(BuildContext context) {
return const VSpace(20.0);
}
}
class _IncludeTimePicker extends StatefulWidget {
const _IncludeTimePicker();
@override
State<_IncludeTimePicker> createState() => _IncludeTimePickerState();
}
class _IncludeTimePickerState extends State<_IncludeTimePicker> {
String? _selectedTime;
@override
Widget build(BuildContext context) {
return BlocBuilder<DateCellEditorBloc, DateCellEditorState>(
builder: (context, state) {
final startDay = state.dateStr;
final endDay = state.endDateStr;
final includeTime = state.includeTime;
final use24hFormat =
state.dateTypeOptionPB.timeFormat == TimeFormatPB.TwentyFourHour;
if (startDay == null || startDay.isEmpty) {
return const Divider(
height: 1,
);
}
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTime(
context,
includeTime,
use24hFormat,
true,
startDay,
state.timeStr,
),
VSpace(
8.0,
color: Theme.of(context).colorScheme.surface,
),
_buildTime(
context,
includeTime,
use24hFormat,
false,
endDay,
state.endTimeStr,
),
],
),
);
},
);
}
Widget _buildTime(
BuildContext context,
bool isIncludeTime,
bool use24hFormat,
bool isStartDay,
String? dateStr,
String? timeStr,
) {
if (dateStr == null) {
return const SizedBox.shrink();
}
final List<Widget> children = [];
if (!isIncludeTime) {
children.addAll([
const HSpace(12.0),
FlowyText(
dateStr,
),
]);
} else {
children.addAll([
Expanded(
child: FlowyText(
dateStr,
textAlign: TextAlign.center,
),
),
Container(
width: 1,
height: 16,
color: Colors.grey,
),
Expanded(
child: FlowyText(
timeStr ?? '',
textAlign: TextAlign.center,
),
),
]);
}
return GestureDetector(
onTap: () async {
final bloc = context.read<DateCellEditorBloc>();
await showMobileBottomSheet(
context,
builder: (context) {
return ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: 300,
),
child: CupertinoDatePicker(
showDayOfWeek: false,
mode: CupertinoDatePickerMode.time,
use24hFormat: use24hFormat,
onDateTimeChanged: (dateTime) {
_selectedTime = use24hFormat
? DateFormat('HH:mm').format(dateTime)
: DateFormat('hh:mm a').format(dateTime);
},
),
child: BlocBuilder<DateCellEditorBloc, DateCellEditorState>(
builder: (context, state) {
return MobileAppFlowyDatePicker(
selectedDay: state.dateTime,
dateStr: state.dateStr,
endDateStr: state.endDateStr,
timeStr: state.timeStr,
endTimeStr: state.endTimeStr,
startDay: state.startDay,
endDay: state.endDay,
enableRanges: true,
isRange: state.isRange,
includeTime: state.includeTime,
use24hFormat: state.dateTypeOptionPB.timeFormat ==
TimeFormatPB.TwentyFourHour,
selectedReminderOption: state.reminderOption,
onStartTimeChanged: (String? time) {
if (time != null) {
context
.read<DateCellEditorBloc>()
.add(DateCellEditorEvent.setTime(time));
}
},
onEndTimeChanged: (String? time) {
if (time != null) {
context
.read<DateCellEditorBloc>()
.add(DateCellEditorEvent.setEndTime(time));
}
},
onDaySelected: (selectedDay, focusedDay) => context
.read<DateCellEditorBloc>()
.add(DateCellEditorEvent.selectDay(selectedDay)),
onRangeSelected: (start, end, focused) => context
.read<DateCellEditorBloc>()
.add(DateCellEditorEvent.selectDateRange(start, end)),
onRangeChanged: (value) => context
.read<DateCellEditorBloc>()
.add(DateCellEditorEvent.setIsRange(value)),
onIncludeTimeChanged: (value) => context
.read<DateCellEditorBloc>()
.add(DateCellEditorEvent.setIncludeTime(value)),
onClearDate: () => context
.read<DateCellEditorBloc>()
.add(const DateCellEditorEvent.clearDate()),
onReminderSelected: (option) => context
.read<DateCellEditorBloc>()
.add(DateCellEditorEvent.setReminderOption(option: option)),
);
},
);
if (_selectedTime != null) {
bloc.add(
isStartDay
? DateCellEditorEvent.setTime(_selectedTime!)
: DateCellEditorEvent.setEndTime(_selectedTime!),
);
}
},
child: Container(
constraints: const BoxConstraints(
minHeight: 36,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: Theme.of(context).colorScheme.secondaryContainer,
border: Border.all(
color: Theme.of(context).colorScheme.outline,
width: 1,
),
),
child: Row(
children: children,
),
),
);
}
}
class _EndDateSwitch extends StatelessWidget {
const _EndDateSwitch();
@override
Widget build(BuildContext context) {
return BlocSelector<DateCellEditorBloc, DateCellEditorState, bool>(
selector: (state) => state.isRange,
builder: (context, isRange) {
return FlowyOptionTile.toggle(
text: LocaleKeys.grid_field_isRange.tr(),
isSelected: isRange,
onValueChanged: (value) {
context
.read<DateCellEditorBloc>()
.add(DateCellEditorEvent.setIsRange(value));
},
);
},
);
}
}
class _IncludeTimeSwitch extends StatelessWidget {
const _IncludeTimeSwitch();
@override
Widget build(BuildContext context) {
return BlocSelector<DateCellEditorBloc, DateCellEditorState, bool>(
selector: (state) => state.includeTime,
builder: (context, includeTime) {
return FlowyOptionTile.toggle(
showTopBorder: false,
text: LocaleKeys.grid_field_includeTime.tr(),
isSelected: includeTime,
onValueChanged: (value) {
context
.read<DateCellEditorBloc>()
.add(DateCellEditorEvent.setIncludeTime(value));
},
);
},
);
}
}
class _TimeTextField extends StatefulWidget {
const _TimeTextField({
required this.timeStr,
required this.isEndTime,
});
final String? timeStr;
final bool isEndTime;
@override
State<_TimeTextField> createState() => _TimeTextFieldState();
}
class _TimeTextFieldState extends State<_TimeTextField> {
late final TextEditingController _textController =
TextEditingController(text: widget.timeStr);
@override
Widget build(BuildContext context) {
return BlocConsumer<DateCellEditorBloc, DateCellEditorState>(
listener: (context, state) {
_textController.text =
widget.isEndTime ? state.endTimeStr ?? "" : state.timeStr ?? "";
},
builder: (context, state) {
return TextFormField(
controller: _textController,
textAlign: TextAlign.end,
decoration: InputDecoration(
hintText: state.timeHintText,
errorText: widget.isEndTime
? state.parseEndTimeError
: state.parseTimeError,
),
keyboardType: TextInputType.datetime,
onFieldSubmitted: (timeStr) {
context.read<DateCellEditorBloc>().add(
widget.isEndTime
? DateCellEditorEvent.setEndTime(timeStr)
: DateCellEditorEvent.setTime(timeStr),
);
},
);
},
);
}
@override
void dispose() {
_textController.dispose();
super.dispose();
}
}
class _ClearDateButton extends StatelessWidget {
const _ClearDateButton();
@override
Widget build(BuildContext context) {
return FlowyOptionTile.text(
text: LocaleKeys.grid_field_clearDate.tr(),
onTap: () => context
.read<DateCellEditorBloc>()
.add(const DateCellEditorEvent.clearDate()),
);
}
);
}

View File

@ -1,28 +1,33 @@
import 'package:flutter/material.dart';
import 'package:appflowy/mobile/presentation/base/mobile_view_page.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flutter/material.dart';
class MobileGridScreen extends StatelessWidget {
static const routeName = '/grid';
static const viewId = 'id';
static const viewTitle = 'title';
static const viewArgs = 'arguments';
const MobileGridScreen({
super.key,
required this.id,
this.title,
this.arguments,
});
/// view id
final String id;
final String? title;
final Map<String, dynamic>? arguments;
@override
Widget build(BuildContext context) {
return MobileViewPage(
id: id,
title: title,
viewLayout: ViewLayoutPB.Document,
viewLayout: ViewLayoutPB.Grid,
arguments: arguments,
);
}
}

View File

@ -159,7 +159,7 @@ class _NotificationScreenContent extends StatelessWidget {
);
void _onDelete(ReminderPB reminder) =>
reminderBloc.add(ReminderEvent.remove(reminder: reminder));
reminderBloc.add(ReminderEvent.remove(reminderId: reminder.id));
void _onReadChanged(ReminderPB reminder, bool isRead) => reminderBloc.add(
ReminderEvent.update(ReminderUpdate(id: reminder.id, isRead: isRead)),

View File

@ -18,12 +18,13 @@ final class DateCellBackendService {
..rowId = rowId;
Future<Either<Unit, FlowyError>> update({
required bool includeTime,
required bool isRange,
DateTime? date,
String? time,
DateTime? endDate,
String? endTime,
required includeTime,
required isRange,
String? reminderId,
}) {
final payload = DateChangesetPB.create()
..cellId = cellId
@ -44,6 +45,9 @@ final class DateCellBackendService {
if (endTime != null) {
payload.endTime = endTime;
}
if (reminderId != null) {
payload.reminderId = reminderId;
}
return DatabaseEventUpdateDateCell(payload).send();
}

View File

@ -1,5 +1,8 @@
import 'dart:collection';
import 'package:flutter/material.dart' hide Card;
import 'package:flutter/services.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/database/board/mobile_board_content.dart';
@ -21,8 +24,6 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flowy_infra_ui/widget/error_page.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/material.dart' hide Card;
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../widgets/card/card.dart';
@ -30,6 +31,7 @@ import '../../widgets/card/card_cell_builder.dart';
import '../../widgets/card/cells/card_cell.dart';
import '../../widgets/row/cell_builder.dart';
import '../application/board_bloc.dart';
import 'toolbar/board_setting_bar.dart';
import 'widgets/board_hidden_groups.dart';
@ -40,6 +42,7 @@ class BoardPageTabBarBuilderImpl implements DatabaseTabBarItemBuilder {
ViewPB view,
DatabaseController controller,
bool shrinkWrap,
String? initialRowId,
) =>
BoardPage(view: view, databaseController: controller);

View File

@ -1,3 +1,5 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
@ -19,7 +21,6 @@ import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
@ -27,6 +28,7 @@ import '../../application/row/row_cache.dart';
import '../../application/row/row_controller.dart';
import '../../widgets/row/cell_builder.dart';
import '../../widgets/row/row_detail.dart';
import 'calendar_day.dart';
import 'layout/sizes.dart';
import 'toolbar/calendar_setting_bar.dart';
@ -38,6 +40,7 @@ class CalendarPageTabBarBuilderImpl implements DatabaseTabBarItemBuilder {
ViewPB view,
DatabaseController controller,
bool shrinkWrap,
String? initialRowId,
) {
return CalendarPage(
key: _makeValueKey(controller),

View File

@ -1,9 +1,15 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/row/row_service.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/grid_setting_bar.dart';
import 'package:appflowy/plugins/database/tab_bar/desktop/setting_menu.dart';
import 'package:appflowy/plugins/database/widgets/row/cell_builder.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/notifications/notification_action.dart';
import 'package:appflowy/workspace/application/notifications/notification_action_bloc.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
@ -11,22 +17,22 @@ import 'package:flowy_infra_ui/flowy_infra_ui_web.dart';
import 'package:flowy_infra_ui/style_widget/scrolling/styled_scroll_bar.dart';
import 'package:flowy_infra_ui/style_widget/scrolling/styled_scrollview.dart';
import 'package:flowy_infra_ui/widget/error_page.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter/material.dart';
import 'package:linked_scroll_controller/linked_scroll_controller.dart';
import '../../application/database_controller.dart';
import '../../application/row/row_cache.dart';
import '../../application/row/row_controller.dart';
import '../application/grid_bloc.dart';
import '../../application/database_controller.dart';
import 'grid_scroll.dart';
import '../../tab_bar/tab_bar_view.dart';
import '../../widgets/row/row_detail.dart';
import '../application/grid_bloc.dart';
import 'grid_scroll.dart';
import 'layout/layout.dart';
import 'layout/sizes.dart';
import 'widgets/row/row.dart';
import 'widgets/footer/grid_footer.dart';
import 'widgets/header/grid_header.dart';
import '../../widgets/row/row_detail.dart';
import 'widgets/row/row.dart';
import 'widgets/shortcuts.dart';
class ToggleExtensionNotifier extends ChangeNotifier {
@ -49,11 +55,13 @@ class DesktopGridTabBarBuilderImpl implements DatabaseTabBarItemBuilder {
ViewPB view,
DatabaseController controller,
bool shrinkWrap,
String? initialRowId,
) {
return GridPage(
key: _makeValueKey(controller),
view: view,
databaseController: controller,
initialRowId: initialRowId,
);
}
@ -85,31 +93,33 @@ class DesktopGridTabBarBuilderImpl implements DatabaseTabBarItemBuilder {
}
class GridPage extends StatefulWidget {
final DatabaseController databaseController;
const GridPage({
super.key,
required this.view,
required this.databaseController,
this.onDeleted,
super.key,
this.initialRowId,
});
final ViewPB view;
final DatabaseController databaseController;
final VoidCallback? onDeleted;
final String? initialRowId;
@override
State<GridPage> createState() => _GridPageState();
}
class _GridPageState extends State<GridPage> {
@override
void initState() {
super.initState();
}
bool _didOpenInitialRow = false;
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider<NotificationActionBloc>.value(
value: getIt<NotificationActionBloc>(),
),
BlocProvider<GridBloc>(
create: (context) => GridBloc(
view: widget.view,
@ -117,35 +127,88 @@ class _GridPageState extends State<GridPage> {
)..add(const GridEvent.initial()),
),
],
child: BlocBuilder<GridBloc, GridState>(
builder: (context, state) {
return state.loadingState.map(
child: BlocListener<NotificationActionBloc, NotificationActionState>(
listener: (context, state) {
final action = state.action;
if (action?.type == ActionType.openRow &&
action?.objectId == widget.view.id) {
final rowId = action!.arguments?[ActionArgumentKeys.rowId];
if (rowId != null) {
// If Reminder in existing database is pressed
// then open the row
_openRow(context, rowId);
}
}
},
child: BlocConsumer<GridBloc, GridState>(
listener: (context, state) => state.loadingState.whenOrNull(
// If initial row id is defined, open row details overlay
finish: (_) {
if (widget.initialRowId != null && !_didOpenInitialRow) {
_didOpenInitialRow = true;
_openRow(context, widget.initialRowId!);
}
return;
},
),
builder: (context, state) => state.loadingState.map(
loading: (_) =>
const Center(child: CircularProgressIndicator.adaptive()),
finish: (result) => result.successOrFail.fold(
(_) => GridShortcuts(
child: GridPageContent(view: widget.view),
),
(_) => GridShortcuts(child: GridPageContent(view: widget.view)),
(err) => FlowyErrorPage.message(
err.toString(),
howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(),
),
),
idle: (_) => const SizedBox.shrink(),
);
},
),
),
),
);
}
void _openRow(
BuildContext context,
String rowId,
) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final gridBloc = context.read<GridBloc>();
final rowCache = gridBloc.getRowCache(rowId);
final rowMeta = rowCache.getRow(rowId)?.rowMeta;
if (rowMeta == null) {
return;
}
final fieldController = gridBloc.databaseController.fieldController;
final rowController = RowController(
viewId: widget.view.id,
rowMeta: rowMeta,
rowCache: rowCache,
);
FlowyOverlay.show(
context: context,
builder: (_) => RowDetailPage(
cellBuilder: GridCellBuilder(cellCache: rowController.cellCache),
rowController: rowController,
fieldController: fieldController,
),
);
});
}
}
class GridPageContent extends StatefulWidget {
final ViewPB view;
const GridPageContent({
required this.view,
super.key,
required this.view,
});
final ViewPB view;
@override
State<GridPageContent> createState() => _GridPageContentState();
}

View File

@ -1,3 +1,5 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart';
@ -7,6 +9,8 @@ import 'package:appflowy/plugins/database/application/row/row_service.dart';
import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/shortcuts.dart';
import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/notifications/notification_action_bloc.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
@ -15,7 +19,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/error_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:linked_scroll_controller/linked_scroll_controller.dart';
@ -33,11 +36,13 @@ class MobileGridTabBarBuilderImpl implements DatabaseTabBarItemBuilder {
ViewPB view,
DatabaseController controller,
bool shrinkWrap,
String? initialRowId,
) {
return MobileGridPage(
key: _makeValueKey(controller),
view: view,
databaseController: controller,
initialRowId: initialRowId,
);
}
@ -58,26 +63,33 @@ class MobileGridTabBarBuilderImpl implements DatabaseTabBarItemBuilder {
}
class MobileGridPage extends StatefulWidget {
final DatabaseController databaseController;
const MobileGridPage({
super.key,
required this.view,
required this.databaseController,
this.onDeleted,
super.key,
this.initialRowId,
});
final ViewPB view;
final DatabaseController databaseController;
final VoidCallback? onDeleted;
final String? initialRowId;
@override
State<MobileGridPage> createState() => _MobileGridPageState();
}
class _MobileGridPageState extends State<MobileGridPage> {
bool _didOpenInitialRow = false;
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider<NotificationActionBloc>.value(
value: getIt<NotificationActionBloc>(),
),
BlocProvider<GridBloc>(
create: (context) => GridBloc(
view: widget.view,
@ -90,19 +102,43 @@ class _MobileGridPageState extends State<MobileGridPage> {
return state.loadingState.map(
loading: (_) =>
const Center(child: CircularProgressIndicator.adaptive()),
finish: (result) => result.successOrFail.fold(
(_) => GridShortcuts(child: GridPageContent(view: widget.view)),
(err) => FlowyErrorPage.message(
err.toString(),
howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(),
),
),
finish: (result) {
_openRow(context, widget.initialRowId, true);
return result.successOrFail.fold(
(_) => GridShortcuts(child: GridPageContent(view: widget.view)),
(err) => FlowyErrorPage.message(
err.toString(),
howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(),
),
);
},
idle: (_) => const SizedBox.shrink(),
);
},
),
);
}
void _openRow(
BuildContext context,
String? rowId, [
bool initialRow = false,
]) {
if (rowId != null && (!initialRow || (initialRow && !_didOpenInitialRow))) {
_didOpenInitialRow = initialRow;
WidgetsBinding.instance.addPostFrameCallback((_) {
context.push(
MobileRowDetailPage.routeName,
extra: {
MobileRowDetailPage.argRowId: rowId,
MobileRowDetailPage.argDatabaseController:
widget.databaseController,
},
);
});
}
}
}
class GridPageContent extends StatefulWidget {

View File

@ -1,3 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import "package:appflowy/generated/locale_keys.g.dart";
import 'package:appflowy/plugins/database/application/cell/cell_service.dart';
@ -9,14 +12,13 @@ import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import '../../../../widgets/row/accessory/cell_accessory.dart';
import '../../../../widgets/row/cells/cell_container.dart';
import '../../layout/sizes.dart';
import 'action.dart';
class GridRow extends StatefulWidget {
@ -186,15 +188,15 @@ class InsertRowButton extends StatelessWidget {
}
class RowMenuButton extends StatefulWidget {
final VoidCallback openMenu;
final bool isDragEnabled;
const RowMenuButton({
super.key,
required this.openMenu,
this.isDragEnabled = false,
super.key,
});
final VoidCallback openMenu;
final bool isDragEnabled;
@override
State<RowMenuButton> createState() => _RowMenuButtonState();
}
@ -227,14 +229,15 @@ class _RowMenuButtonState extends State<RowMenuButton> {
}
class RowContent extends StatelessWidget {
final VoidCallback onExpand;
final GridCellBuilder builder;
const RowContent({
super.key,
required this.builder,
required this.onExpand,
super.key,
});
final GridCellBuilder builder;
final VoidCallback onExpand;
@override
Widget build(BuildContext context) {
return BlocBuilder<RowBloc, RowState>(

View File

@ -1,3 +1,5 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart';
@ -10,7 +12,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'tab_bar_add_button.dart';
@ -37,9 +38,7 @@ class TabBarHeader extends StatelessWidget {
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Flexible(
child: DatabaseTabBar(),
),
const Flexible(child: DatabaseTabBar()),
BlocBuilder<DatabaseTabBarBloc, DatabaseTabBarState>(
builder: (context, state) {
return SizedBox(

View File

@ -1,3 +1,5 @@
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
@ -10,7 +12,6 @@ import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'desktop/tab_bar_header.dart';
@ -26,6 +27,7 @@ abstract class DatabaseTabBarItemBuilder {
ViewPB view,
DatabaseController controller,
bool shrinkWrap,
String? initialRowId,
);
/// Returns the setting bar of the tab bar item. The setting bar is shown on the
@ -44,10 +46,16 @@ abstract class DatabaseTabBarItemBuilder {
class DatabaseTabBarView extends StatefulWidget {
final ViewPB view;
final bool shrinkWrap;
/// Used to open a Row on plugin load
///
final String? initialRowId;
const DatabaseTabBarView({
super.key,
required this.view,
required this.shrinkWrap,
super.key,
this.initialRowId,
});
@override
@ -55,19 +63,12 @@ class DatabaseTabBarView extends StatefulWidget {
}
class _DatabaseTabBarViewState extends State<DatabaseTabBarView> {
PageController? _pageController;
@override
void initState() {
super.initState();
_pageController = PageController(
initialPage: 0,
);
}
final PageController _pageController = PageController(initialPage: 0);
late String? _initialRowId = widget.initialRowId;
@override
void dispose() {
_pageController?.dispose();
_pageController.dispose();
super.dispose();
}
@ -75,15 +76,14 @@ class _DatabaseTabBarViewState extends State<DatabaseTabBarView> {
Widget build(BuildContext context) {
return BlocProvider<DatabaseTabBarBloc>(
create: (context) => DatabaseTabBarBloc(view: widget.view)
..add(
const DatabaseTabBarEvent.initial(),
),
..add(const DatabaseTabBarEvent.initial()),
child: MultiBlocListener(
listeners: [
BlocListener<DatabaseTabBarBloc, DatabaseTabBarState>(
listenWhen: (p, c) => p.selectedIndex != c.selectedIndex,
listener: (context, state) {
_pageController?.jumpToPage(state.selectedIndex);
_initialRowId = null;
_pageController.jumpToPage(state.selectedIndex);
},
),
],
@ -120,20 +120,17 @@ class _DatabaseTabBarViewState extends State<DatabaseTabBarView> {
},
),
BlocBuilder<DatabaseTabBarBloc, DatabaseTabBarState>(
builder: (context, state) {
return pageSettingBarExtensionFromState(state);
},
builder: (context, state) =>
pageSettingBarExtensionFromState(state),
),
Expanded(
child: BlocBuilder<DatabaseTabBarBloc, DatabaseTabBarState>(
builder: (context, state) {
return PageView(
pageSnapping: false,
physics: const NeverScrollableScrollPhysics(),
controller: _pageController,
children: pageContentFromState(state),
);
},
builder: (context, state) => PageView(
pageSnapping: false,
physics: const NeverScrollableScrollPhysics(),
controller: _pageController,
children: pageContentFromState(state),
),
),
),
],
@ -146,11 +143,13 @@ class _DatabaseTabBarViewState extends State<DatabaseTabBarView> {
return state.tabBars.map((tabBar) {
final controller =
state.tabBarControllerByViewId[tabBar.viewId]!.controller;
return tabBar.builder.content(
context,
tabBar.view,
controller,
widget.shrinkWrap,
_initialRowId,
);
}).toList();
}
@ -174,15 +173,21 @@ class DatabaseTabBarViewPlugin extends Plugin {
final ViewPluginNotifier notifier;
final PluginType _pluginType;
/// Used to open a Row on plugin load
///
final String? initialRowId;
DatabaseTabBarViewPlugin({
required ViewPB view,
required PluginType pluginType,
this.initialRowId,
}) : _pluginType = pluginType,
notifier = ViewPluginNotifier(view: view);
@override
PluginWidgetBuilder get widgetBuilder => DatabasePluginWidgetBuilder(
notifier: notifier,
initialRowId: initialRowId,
);
@override
@ -195,9 +200,14 @@ class DatabaseTabBarViewPlugin extends Plugin {
class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {
final ViewPluginNotifier notifier;
/// Used to open a Row on plugin load
///
final String? initialRowId;
DatabasePluginWidgetBuilder({
required this.notifier,
Key? key,
required this.notifier,
this.initialRowId,
});
@override
@ -219,6 +229,7 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {
key: ValueKey(notifier.view.id),
view: notifier.view,
shrinkWrap: shrinkWrap,
initialRowId: initialRowId,
);
}

View File

@ -1,14 +1,14 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/material.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
import '../cell_builder.dart';
@ -52,14 +52,15 @@ abstract mixin class GridCellAccessoryState {
}
class PrimaryCellAccessory extends StatefulWidget {
final VoidCallback onTapCallback;
final bool isCellEditing;
const PrimaryCellAccessory({
super.key,
required this.onTapCallback,
required this.isCellEditing,
super.key,
});
final VoidCallback onTapCallback;
final bool isCellEditing;
@override
State<StatefulWidget> createState() => _PrimaryCellAccessoryState();
}

View File

@ -1,14 +1,20 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../grid/presentation/layout/sizes.dart';
import '../../cell_builder.dart';
import 'date_cell_bloc.dart';
import 'date_editor.dart';
@ -85,22 +91,32 @@ class _DateCellState extends GridCellState<GridDateCell> {
child: Container(
alignment: alignment,
padding: padding,
child: FlowyText.medium(
text,
color: color,
overflow: TextOverflow.ellipsis,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: FlowyText.medium(
text,
color: color,
overflow: TextOverflow.ellipsis,
),
),
if (state.data?.reminderId.isNotEmpty == true) ...[
const HSpace(5),
FlowyTooltip(
message:
LocaleKeys.grid_field_reminderOnDateTooltip.tr(),
child: const FlowySvg(FlowySvgs.clock_alarm_s),
),
],
],
),
),
popupBuilder: (BuildContext popoverContent) {
return DateCellEditor(
cellController: _cellController,
onDismissed: () =>
widget.cellContainerNotifier.isFocus = false,
);
},
onClose: () {
widget.cellContainerNotifier.isFocus = false;
},
popupBuilder: (_) => DateCellEditor(
cellController: _cellController,
onDismissed: () => widget.cellContainerNotifier.isFocus = false,
),
onClose: () => widget.cellContainerNotifier.isFocus = false,
);
} else if (widget.cellStyle.useRoundedBorder) {
return InkWell(
@ -108,12 +124,10 @@ class _DateCellState extends GridCellState<GridDateCell> {
onTap: () => showMobileBottomSheet(
context,
padding: EdgeInsets.zero,
builder: (context) {
return MobileDateCellEditScreen(
controller: _cellController,
showAsFullScreen: false,
);
},
builder: (_) => MobileDateCellEditScreen(
controller: _cellController,
showAsFullScreen: false,
),
),
child: Container(
constraints: const BoxConstraints(
@ -146,28 +160,36 @@ class _DateCellState extends GridCellState<GridDateCell> {
scrollDirection: Axis.horizontal,
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: FlowyText(
text,
color: color,
fontSize: 15,
maxLines: 1,
child: Row(
children: [
if (state.data?.reminderId.isNotEmpty == true) ...[
FlowyTooltip(
message:
LocaleKeys.grid_field_reminderOnDateTooltip.tr(),
child: const FlowySvg(FlowySvgs.clock_alarm_s),
),
const HSpace(5),
],
FlowyText(
text,
color: color,
fontSize: 15,
maxLines: 1,
),
],
),
),
),
onTap: () {
showMobileBottomSheet(
context,
padding: EdgeInsets.zero,
backgroundColor:
Theme.of(context).colorScheme.secondaryContainer,
builder: (context) {
return MobileDateCellEditScreen(
controller: _cellController,
showAsFullScreen: false,
);
},
);
},
onTap: () => showMobileBottomSheet(
context,
padding: EdgeInsets.zero,
backgroundColor:
Theme.of(context).colorScheme.secondaryContainer,
builder: (_) => MobileDateCellEditScreen(
controller: _cellController,
showAsFullScreen: false,
),
),
);
}
},

View File

@ -5,15 +5,22 @@ import 'package:appflowy/plugins/database/application/cell/cell_controller_build
import 'package:appflowy/plugins/database/application/cell/date_cell_service.dart';
import 'package:appflowy/plugins/database/application/field/field_service.dart';
import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy/user/application/reminder/reminder_extension.dart';
import 'package:appflowy/util/int64_extension.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart'
show StringTranslateExtension;
import 'package:fixnum/fixnum.dart';
import 'package:flowy_infra/time/duration.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:nanoid/non_secure.dart';
import 'package:protobuf/protobuf.dart';
part 'date_cell_editor_bloc.freezed.dart';
@ -22,16 +29,19 @@ class DateCellEditorBloc
extends Bloc<DateCellEditorEvent, DateCellEditorState> {
final DateCellBackendService _dateCellBackendService;
final DateCellController cellController;
final ReminderBloc _reminderBloc;
void Function()? _onCellChangedFn;
DateCellEditorBloc({
required this.cellController,
}) : _dateCellBackendService = DateCellBackendService(
required ReminderBloc reminderBloc,
}) : _reminderBloc = reminderBloc,
_dateCellBackendService = DateCellBackendService(
viewId: cellController.viewId,
fieldId: cellController.fieldId,
rowId: cellController.rowId,
),
super(DateCellEditorState.initial(cellController)) {
super(DateCellEditorState.initial(cellController, reminderBloc)) {
on<DateCellEditorEvent>(
(event, emit) async {
await event.when(
@ -42,6 +52,41 @@ class DateCellEditorBloc
dateCellData.isRange == state.isRange && dateCellData.isRange
? dateCellData.endDateTime
: null;
if (dateCellData.dateTime != null &&
(state.reminderId?.isEmpty ?? true) &&
(dateCellData.reminderId?.isNotEmpty ?? false) &&
state.reminderOption != ReminderOption.none) {
// Add Reminder
_reminderBloc.add(
ReminderEvent.addById(
reminderId: dateCellData.reminderId!,
objectId: cellController.viewId,
meta: {ReminderMetaKeys.rowId: cellController.rowId},
scheduledAt: Int64(
dateCellData.dateTime!
.subtract(state.reminderOption.time)
.millisecondsSinceEpoch ~/
1000,
),
),
);
}
if ((dateCellData.reminderId?.isNotEmpty ?? false) &&
dateCellData.dateTime != null) {
// Update Reminder
_reminderBloc.add(
ReminderEvent.update(
ReminderUpdate(
id: state.reminderId!,
scheduledAt: dateCellData.dateTime!
.subtract(state.reminderOption.time),
),
),
);
}
emit(
state.copyWith(
dateTime: dateCellData.dateTime,
@ -54,11 +99,14 @@ class DateCellEditorBloc
endDay: endDay,
dateStr: dateCellData.dateStr,
endDateStr: dateCellData.endDateStr,
reminderId: dateCellData.reminderId,
),
);
},
didReceiveTimeFormatError:
(String? parseTimeError, String? parseEndTimeError) {
didReceiveTimeFormatError: (
String? parseTimeError,
String? parseEndTimeError,
) {
emit(
state.copyWith(
parseTimeError: parseTimeError,
@ -67,17 +115,14 @@ class DateCellEditorBloc
);
},
selectDay: (date) async {
if (state.isRange) {
return;
if (!state.isRange) {
await _updateDateData(date: date);
}
await _updateDateData(date: date);
},
setIncludeTime: (includeTime) async {
await _updateDateData(includeTime: includeTime);
},
setIsRange: (isRange) async {
await _updateDateData(isRange: isRange);
},
setIncludeTime: (includeTime) async =>
await _updateDateData(includeTime: includeTime),
setIsRange: (isRange) async =>
await _updateDateData(isRange: isRange),
setTime: (timeStr) async {
emit(state.copyWith(timeStr: timeStr));
await _updateDateData(timeStr: timeStr);
@ -87,89 +132,88 @@ class DateCellEditorBloc
final (newStart, newEnd) = state.startDay!.isBefore(start!)
? (state.startDay!, start)
: (start, state.startDay!);
emit(
state.copyWith(
startDay: null,
endDay: null,
),
);
await _updateDateData(
date: newStart.date,
endDate: newEnd.date,
);
emit(state.copyWith(startDay: null, endDay: null));
await _updateDateData(date: newStart.date, endDate: newEnd.date);
} else if (end == null) {
emit(
state.copyWith(
startDay: start,
endDay: null,
),
);
emit(state.copyWith(startDay: start, endDay: null));
} else {
await _updateDateData(
date: start!.date,
endDate: end.date,
);
await _updateDateData(date: start!.date, endDate: end.date);
}
},
setStartDay: (DateTime startDay) async {
if (state.endDay == null) {
emit(
state.copyWith(
startDay: startDay,
),
);
emit(state.copyWith(startDay: startDay));
} else if (startDay.isAfter(state.endDay!)) {
emit(
state.copyWith(
startDay: startDay,
endDay: null,
),
);
emit(state.copyWith(startDay: startDay, endDay: null));
} else {
emit(
state.copyWith(
startDay: startDay,
),
emit(state.copyWith(startDay: startDay));
await _updateDateData(
date: startDay.date,
endDate: state.endDay!.date,
);
_updateDateData(date: startDay.date, endDate: state.endDay!.date);
}
},
setEndDay: (DateTime endDay) async {
setEndDay: (DateTime endDay) {
if (state.startDay == null) {
emit(
state.copyWith(
endDay: endDay,
),
);
emit(state.copyWith(endDay: endDay));
} else if (endDay.isBefore(state.startDay!)) {
emit(
state.copyWith(
startDay: null,
endDay: endDay,
),
);
emit(state.copyWith(startDay: null, endDay: endDay));
} else {
emit(
state.copyWith(
endDay: endDay,
),
);
emit(state.copyWith(endDay: endDay));
_updateDateData(date: state.startDay!.date, endDate: endDay.date);
}
},
setEndTime: (String endTime) async {
setEndTime: (String? endTime) async {
emit(state.copyWith(endTimeStr: endTime));
await _updateDateData(endTimeStr: endTime);
},
setDateFormat: (dateFormat) async {
await _updateTypeOption(emit, dateFormat: dateFormat);
},
setTimeFormat: (timeFormat) async {
await _updateTypeOption(emit, timeFormat: timeFormat);
},
setDateFormat: (DateFormatPB dateFormat) async =>
await _updateTypeOption(emit, dateFormat: dateFormat),
setTimeFormat: (TimeFormatPB timeFormat) async =>
await _updateTypeOption(emit, timeFormat: timeFormat),
clearDate: () async {
// Remove reminder if neccessary
if (state.reminderId != null) {
_reminderBloc
.add(ReminderEvent.remove(reminderId: state.reminderId!));
}
await _clearDate();
},
setReminderOption: (ReminderOption option) async {
if (state.reminderId?.isEmpty ??
true &&
state.dateTime != null &&
option != ReminderOption.none) {
// New Reminder
final reminderId = nanoid();
await _updateDateData(reminderId: reminderId);
emit(state.copyWith(reminderOption: option));
} else if (option == ReminderOption.none &&
(state.reminderId?.isNotEmpty ?? false)) {
// Remove reminder
_reminderBloc
.add(ReminderEvent.remove(reminderId: state.reminderId!));
await _updateDateData(reminderId: "");
emit(state.copyWith(reminderOption: option));
} else if (state.dateTime != null &&
(state.reminderId?.isNotEmpty ?? false)) {
// Update reminder
_reminderBloc.add(
ReminderEvent.update(
ReminderUpdate(
id: state.reminderId!,
scheduledAt: state.dateTime!.subtract(option.time),
),
),
);
}
},
// Empty String signifies no reminder
removeReminder: () async => await _updateDateData(reminderId: ""),
);
},
);
@ -182,6 +226,7 @@ class DateCellEditorBloc
String? endTimeStr,
bool? includeTime,
bool? isRange,
String? reminderId,
}) async {
// make sure that not both date and time are updated at the same time
assert(
@ -191,21 +236,15 @@ class DateCellEditorBloc
// if not updating the time, use the old time in the state
final String? newTime = timeStr ?? state.timeStr;
DateTime? newDate;
if (timeStr != null && timeStr.isNotEmpty) {
newDate = state.dateTime ?? DateTime.now();
} else {
newDate = _utcToLocalAndAddCurrentTime(date);
}
final DateTime? newDate = timeStr != null && timeStr.isNotEmpty
? state.dateTime ?? DateTime.now()
: _utcToLocalAndAddCurrentTime(date);
// if not updating the time, use the old time in the state
final String? newEndTime = endTimeStr ?? state.endTimeStr;
DateTime? newEndDate;
if (endTimeStr != null && endTimeStr.isNotEmpty) {
newEndDate = state.endDateTime ?? DateTime.now();
} else {
newEndDate = _utcToLocalAndAddCurrentTime(endDate);
}
final DateTime? newEndDate = endTimeStr != null && endTimeStr.isNotEmpty
? state.endDateTime ?? DateTime.now()
: _utcToLocalAndAddCurrentTime(endDate);
final result = await _dateCellBackendService.update(
date: newDate,
@ -214,15 +253,14 @@ class DateCellEditorBloc
endTime: newEndTime,
includeTime: includeTime ?? state.includeTime,
isRange: isRange ?? state.isRange,
reminderId: reminderId ?? state.reminderId,
);
result.fold(
(_) {
if (!isClosed &&
(state.parseEndTimeError != null || state.parseTimeError != null)) {
add(
const DateCellEditorEvent.didReceiveTimeFormatError(null, null),
);
add(const DateCellEditorEvent.didReceiveTimeFormatError(null, null));
}
},
(err) {
@ -231,10 +269,12 @@ class DateCellEditorBloc
if (isClosed) {
return;
}
// to determine which textfield should show error
final (startError, endError) = newDate != null
? (timeFormatPrompt(err), null)
: (null, timeFormatPrompt(err));
add(
DateCellEditorEvent.didReceiveTimeFormatError(
startError,
@ -253,13 +293,9 @@ class DateCellEditorBloc
final result = await _dateCellBackendService.clear();
result.fold(
(_) {
if (isClosed) {
return;
if (!isClosed) {
add(const DateCellEditorEvent.didReceiveTimeFormatError(null, null));
}
add(
const DateCellEditorEvent.didReceiveTimeFormatError(null, null),
);
},
(err) => Log.error(err),
);
@ -304,11 +340,11 @@ class DateCellEditorBloc
void _startListening() {
_onCellChangedFn = cellController.startListening(
onCellChanged: ((cell) {
onCellChanged: (cell) {
if (!isClosed) {
add(DateCellEditorEvent.didReceiveCellUpdate(cell));
}
}),
},
);
}
@ -335,7 +371,7 @@ class DateCellEditorBloc
);
result.fold(
(l) => emit(
(_) => emit(
state.copyWith(
dateTypeOptionPB: newDateTypeOption,
timeHintText: _timeHintText(newDateTypeOption),
@ -355,6 +391,7 @@ class DateCellEditorEvent with _$DateCellEditorEvent {
const factory DateCellEditorEvent.didReceiveCellUpdate(
DateCellDataPB? data,
) = _DidReceiveCellUpdate;
const factory DateCellEditorEvent.didReceiveTimeFormatError(
String? parseTimeError,
String? parseEndTimeError,
@ -362,27 +399,41 @@ class DateCellEditorEvent with _$DateCellEditorEvent {
// date cell data is modified
const factory DateCellEditorEvent.selectDay(DateTime day) = _SelectDay;
const factory DateCellEditorEvent.selectDateRange(
DateTime? start,
DateTime? end,
) = _SelectDateRange;
const factory DateCellEditorEvent.setStartDay(
DateTime startDay,
) = _SetStartDay;
const factory DateCellEditorEvent.setEndDay(
DateTime endDay,
) = _SetEndDay;
const factory DateCellEditorEvent.setTime(String time) = _Time;
const factory DateCellEditorEvent.setEndTime(String endTime) = _EndTime;
const factory DateCellEditorEvent.setTime(String time) = _SetTime;
const factory DateCellEditorEvent.setEndTime(String endTime) = _SetEndTime;
const factory DateCellEditorEvent.setIncludeTime(bool includeTime) =
_IncludeTime;
const factory DateCellEditorEvent.setIsRange(bool isRange) = _IsRange;
const factory DateCellEditorEvent.setIsRange(bool isRange) = _SetIsRange;
const factory DateCellEditorEvent.setReminderOption({
required ReminderOption option,
}) = _SetReminderOption;
const factory DateCellEditorEvent.removeReminder() = _RemoveReminder;
// date field type options are modified
const factory DateCellEditorEvent.setTimeFormat(TimeFormatPB timeFormat) =
_TimeFormat;
_SetTimeFormat;
const factory DateCellEditorEvent.setDateFormat(DateFormatPB dateFormat) =
_DateFormat;
_SetDateFormat;
const factory DateCellEditorEvent.clearDate() = _ClearDate;
}
@ -406,17 +457,36 @@ class DateCellEditorState with _$DateCellEditorState {
required bool isRange,
required String? dateStr,
required String? endDateStr,
required String? reminderId,
// error and hint text
required String? parseTimeError,
required String? parseEndTimeError,
required String timeHintText,
@Default(ReminderOption.none) ReminderOption reminderOption,
}) = _DateCellEditorState;
factory DateCellEditorState.initial(DateCellController controller) {
factory DateCellEditorState.initial(
DateCellController controller,
ReminderBloc reminderBloc,
) {
final typeOption = controller.getTypeOption(DateTypeOptionDataParser());
final cellData = controller.getCellData();
final dateCellData = _dateDataFromCellData(cellData);
ReminderOption reminderOption = ReminderOption.none;
if ((dateCellData.reminderId?.isNotEmpty ?? false) &&
dateCellData.dateTime != null) {
final reminder = reminderBloc.state.reminders
.firstWhereOrNull((r) => r.id == dateCellData.reminderId);
if (reminder != null) {
reminderOption = ReminderOption.fromDateDifference(
dateCellData.dateTime!,
reminder.scheduledAt.toDateTime(),
);
}
}
return DateCellEditorState(
dateTypeOptionPB: typeOption,
startDay: dateCellData.isRange ? dateCellData.dateTime : null,
@ -432,6 +502,8 @@ class DateCellEditorState with _$DateCellEditorState {
parseTimeError: null,
parseEndTimeError: null,
timeHintText: _timeHintText(typeOption),
reminderId: dateCellData.reminderId,
reminderOption: reminderOption,
);
}
}
@ -462,6 +534,7 @@ _DateCellData _dateDataFromCellData(
isRange: false,
dateStr: null,
endDateStr: null,
reminderId: null,
);
}
@ -481,12 +554,14 @@ _DateCellData _dateDataFromCellData(
endTimeStr = cellData.endTime;
}
}
final bool includeTime = cellData.includeTime;
final bool isRange = cellData.isRange;
if (cellData.isRange) {
endDateStr = cellData.endDate;
}
final String dateStr = cellData.date;
return _DateCellData(
@ -498,6 +573,7 @@ _DateCellData _dateDataFromCellData(
isRange: isRange,
dateStr: dateStr,
endDateStr: endDateStr,
reminderId: cellData.reminderId,
);
}
@ -510,6 +586,7 @@ class _DateCellData {
final bool isRange;
final String? dateStr;
final String? endDateStr;
final String? reminderId;
_DateCellData({
required this.dateTime,
@ -520,5 +597,6 @@ class _DateCellData {
required this.isRange,
required this.dateStr,
required this.endDateStr,
required this.reminderId,
});
}

View File

@ -1,7 +1,12 @@
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/appflowy_date_picker.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/appflowy_date_picker.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/clear_date_button.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_type_option_button.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'date_cell_editor_bloc.dart';
@ -31,20 +36,28 @@ class _DateCellEditor extends State<DateCellEditor> {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => DateCellEditorBloc(
cellController: widget.cellController,
)..add(const DateCellEditorEvent.initial()),
return MultiBlocProvider(
providers: [
BlocProvider<DateCellEditorBloc>(
create: (context) => DateCellEditorBloc(
reminderBloc: getIt<ReminderBloc>(),
cellController: widget.cellController,
)..add(const DateCellEditorEvent.initial()),
),
],
child: BlocBuilder<DateCellEditorBloc, DateCellEditorState>(
builder: (context, state) {
final bloc = context.read<DateCellEditorBloc>();
final dateCellBloc = context.read<DateCellEditorBloc>();
return AppFlowyDatePicker(
includeTime: state.includeTime,
rebuildOnDaySelected: false,
onIncludeTimeChanged: (value) =>
bloc.add(DateCellEditorEvent.setIncludeTime(!value)),
dateCellBloc.add(DateCellEditorEvent.setIncludeTime(!value)),
isRange: state.isRange,
startDay: state.isRange ? state.startDay : null,
endDay: state.isRange ? state.endDay : null,
onIsRangeChanged: (value) =>
bloc.add(DateCellEditorEvent.setIsRange(!value)),
dateCellBloc.add(DateCellEditorEvent.setIsRange(!value)),
dateFormat: state.dateTypeOptionPB.dateFormat,
timeFormat: state.dateTypeOptionPB.timeFormat,
selectedDay: state.dateTime,
@ -54,28 +67,36 @@ class _DateCellEditor extends State<DateCellEditor> {
parseEndTimeError: state.parseEndTimeError,
parseTimeError: state.parseTimeError,
popoverMutex: popoverMutex,
onStartTimeSubmitted: (timeStr) {
bloc.add(DateCellEditorEvent.setTime(timeStr));
},
onEndTimeSubmitted: (timeStr) {
bloc.add(DateCellEditorEvent.setEndTime(timeStr));
},
onDaySelected: (selectedDay, _) {
bloc.add(DateCellEditorEvent.selectDay(selectedDay));
},
onRangeSelected: (start, end, _) {
bloc.add(DateCellEditorEvent.selectDateRange(start, end));
},
allowFormatChanges: true,
onDateFormatChanged: (format) {
bloc.add(DateCellEditorEvent.setDateFormat(format));
},
onTimeFormatChanged: (format) {
bloc.add(DateCellEditorEvent.setTimeFormat(format));
},
onClearDate: () {
bloc.add(const DateCellEditorEvent.clearDate());
},
onReminderSelected: (option) => dateCellBloc
.add(DateCellEditorEvent.setReminderOption(option: option)),
selectedReminderOption: state.reminderOption,
options: [
OptionGroup(
options: [
DateTypeOptionButton(
popoverMutex: popoverMutex,
dateFormat: state.dateTypeOptionPB.dateFormat,
timeFormat: state.dateTypeOptionPB.timeFormat,
onDateFormatChanged: (format) => dateCellBloc
.add(DateCellEditorEvent.setDateFormat(format)),
onTimeFormatChanged: (format) => dateCellBloc
.add(DateCellEditorEvent.setTimeFormat(format)),
),
ClearDateButton(
onClearDate: () =>
dateCellBloc.add(const DateCellEditorEvent.clearDate()),
),
],
),
],
onStartTimeSubmitted: (timeStr) =>
dateCellBloc.add(DateCellEditorEvent.setTime(timeStr)),
onEndTimeSubmitted: (timeStr) =>
dateCellBloc.add(DateCellEditorEvent.setEndTime(timeStr)),
onDaySelected: (selectedDay, _) =>
dateCellBloc.add(DateCellEditorEvent.selectDay(selectedDay)),
onRangeSelected: (start, end, _) => dateCellBloc
.add(DateCellEditorEvent.selectDateRange(start, end)),
);
},
),

View File

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

View File

@ -1,5 +1,7 @@
library document_plugin;
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/document_page.dart';
@ -12,9 +14,9 @@ import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart';
import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class DocumentPluginBuilder extends PluginBuilder {
@ -22,9 +24,9 @@ class DocumentPluginBuilder extends PluginBuilder {
Plugin build(dynamic data) {
if (data is ViewPB) {
return DocumentPlugin(pluginType: pluginType, view: data);
} else {
throw FlowyPluginException.invalidData;
}
throw FlowyPluginException.invalidData;
}
@override
@ -41,26 +43,28 @@ class DocumentPluginBuilder extends PluginBuilder {
}
class DocumentPlugin extends Plugin<int> {
DocumentPlugin({
Key? key,
required ViewPB view,
required PluginType pluginType,
bool listenOnViewChanged = false,
this.initialSelection,
}) : notifier = ViewPluginNotifier(view: view) {
_pluginType = pluginType;
}
late PluginType _pluginType;
@override
final ViewPluginNotifier notifier;
DocumentPlugin({
required PluginType pluginType,
required ViewPB view,
bool listenOnViewChanged = false,
Key? key,
}) : notifier = ViewPluginNotifier(view: view) {
_pluginType = pluginType;
}
final Selection? initialSelection;
@override
PluginWidgetBuilder get widgetBuilder {
return DocumentPluginWidgetBuilder(
notifier: notifier,
);
}
PluginWidgetBuilder get widgetBuilder => DocumentPluginWidgetBuilder(
notifier: notifier,
initialSelection: initialSelection,
);
@override
PluginType get pluginType => _pluginType;
@ -71,14 +75,16 @@ class DocumentPlugin extends Plugin<int> {
class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
with NavigationItem {
DocumentPluginWidgetBuilder({
Key? key,
required this.notifier,
this.initialSelection,
});
final ViewPluginNotifier notifier;
ViewPB get view => notifier.view;
int? deletedViewIndex;
DocumentPluginWidgetBuilder({
required this.notifier,
Key? key,
});
final Selection? initialSelection;
@override
EdgeInsets get contentPadding => EdgeInsets.zero;
@ -86,21 +92,23 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
@override
Widget buildWidget({PluginContext? context, required bool shrinkWrap}) {
notifier.isDeleted.addListener(() {
notifier.isDeleted.value.fold(() => null, (deletedView) {
if (deletedView.hasIndex()) {
deletedViewIndex = deletedView.index;
}
});
notifier.isDeleted.value.fold(
() => null,
(deletedView) {
if (deletedView.hasIndex()) {
deletedViewIndex = deletedView.index;
}
},
);
});
return BlocBuilder<DocumentAppearanceCubit, DocumentAppearance>(
builder: (_, state) {
return DocumentPage(
view: view,
onDeleted: () => context?.onDeleted(view, deletedViewIndex),
key: ValueKey(view.id),
);
},
builder: (_, state) => DocumentPage(
key: ValueKey(view.id),
view: view,
onDeleted: () => context?.onDeleted(view, deletedViewIndex),
initialSelection: initialSelection,
),
);
}
@ -114,10 +122,7 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
Widget? get rightBarItem {
return Row(
children: [
DocumentShareButton(
key: ValueKey(view.id),
view: view,
),
DocumentShareButton(key: ValueKey(view.id), view: view),
const HSpace(4),
const DocumentMoreButton(),
],

View File

@ -1,3 +1,5 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/doc_bloc.dart';
import 'package:appflowy/plugins/document/presentation/banner.dart';
@ -13,7 +15,6 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/error_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
enum EditorNotificationType {
@ -35,12 +36,14 @@ class EditorNotification extends Notification {
class DocumentPage extends StatefulWidget {
const DocumentPage({
super.key,
required this.onDeleted,
required this.view,
required this.onDeleted,
this.initialSelection,
});
final VoidCallback onDeleted;
final ViewPB view;
final VoidCallback onDeleted;
final Selection? initialSelection;
@override
State<DocumentPage> createState() => _DocumentPageState();
@ -88,10 +91,8 @@ class _DocumentPageState extends State<DocumentPage> {
return BlocListener<NotificationActionBloc, NotificationActionState>(
listener: _onNotificationAction,
child: _buildEditorPage(
context,
state,
),
listenWhen: (_, curr) => curr.action != null,
child: _buildEditorPage(context, state),
);
},
),
@ -107,6 +108,7 @@ class _DocumentPageState extends State<DocumentPage> {
padding: EditorStyleCustomizer.documentPadding,
),
header: _buildCoverAndIcon(context, state.editorState!),
initialSelection: widget.initialSelection,
);
return Column(
@ -167,14 +169,12 @@ class _DocumentPageState extends State<DocumentPage> {
NotificationActionState state,
) async {
if (state.action != null && state.action!.type == ActionType.jumpToBlock) {
final path = state.action?.arguments?[ActionArgumentKeys.nodePath.name];
final path = state.action?.arguments?[ActionArgumentKeys.nodePath];
final editorState = context.read<DocumentBloc>().state.editorState;
if (editorState != null && widget.view.id == state.action?.objectId) {
editorState.updateSelectionWithReason(
Selection.collapsed(
Position(path: [path]),
),
Selection.collapsed(Position(path: [path])),
reason: SelectionUpdateReason.transaction,
);
}

View File

@ -1,3 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/plugins/document/application/doc_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_configuration.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/align_toolbar_item/custom_text_align_command.dart';
@ -12,9 +15,6 @@ import 'package:appflowy/plugins/inline_actions/handlers/inline_page_reference.d
import 'package:appflowy/plugins/inline_actions/handlers/reminder_reference.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_command.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/notifications/notification_action.dart';
import 'package:appflowy/workspace/application/notifications/notification_action_bloc.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart';
@ -22,8 +22,6 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:collection/collection.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
final List<CommandShortcutEvent> commandShortcutEvents = [
@ -52,6 +50,7 @@ class AppFlowyEditorPage extends StatefulWidget {
required this.styleCustomizer,
this.showParagraphPlaceholder,
this.placeholderText,
this.initialSelection,
});
final Widget? header;
@ -63,6 +62,10 @@ class AppFlowyEditorPage extends StatefulWidget {
final ShowPlaceholder? showParagraphPlaceholder;
final String Function(Node)? placeholderText;
/// Used to provide an initial selection on Page-load
///
final Selection? initialSelection;
@override
State<AppFlowyEditorPage> createState() => _AppFlowyEditorPageState();
}
@ -97,13 +100,8 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
smartEditItem..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable,
paragraphItem..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable,
...headingItems
..forEach(
(e) => e.isActive = onlyShowInSingleSelectionAndTextType,
),
...markdownFormatItems
..forEach(
(e) => e.isActive = showInAnyTextType,
),
..forEach((e) => e.isActive = onlyShowInSingleSelectionAndTextType),
...markdownFormatItems..forEach((e) => e.isActive = showInAnyTextType),
quoteItem..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable,
bulletedListItem
..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable,
@ -177,14 +175,11 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
late final EditorScrollController editorScrollController;
Future<bool> showSlashMenu(editorState) async {
final result = await customSlashCommand(
slashMenuItems,
shouldInsertSlash: false,
style: styleCustomizer.selectionMenuStyleBuilder(),
).handler(editorState);
return result;
}
Future<bool> showSlashMenu(editorState) async => await customSlashCommand(
slashMenuItems,
shouldInsertSlash: false,
style: styleCustomizer.selectionMenuStyleBuilder(),
).handler(editorState);
@override
void initState() {
@ -216,6 +211,15 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
// customize the dynamic theme color
_customizeBlockComponentBackgroundColorDecorator();
if (widget.initialSelection != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.editorState.updateSelectionWithReason(
widget.initialSelection,
reason: SelectionUpdateReason.transaction,
);
});
}
}
@override
@ -275,7 +279,6 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
);
final editorState = widget.editorState;
_setInitialSelection(editorScrollController);
if (PlatformExtension.isMobile) {
return AppFlowyMobileToolbar(
@ -337,21 +340,6 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
);
}
void _setInitialSelection(EditorScrollController scrollController) {
final action = getIt<NotificationActionBloc>().state.action;
final viewId = action?.objectId;
final nodePath =
action?.arguments?[ActionArgumentKeys.nodePath.name] as int?;
if (viewId != null && viewId == documentBloc.view.id && nodePath != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
scrollController.itemScrollController.jumpTo(index: nodePath);
widget.editorState.selection =
Selection.collapsed(Position(path: [nodePath]));
});
}
}
List<SelectionMenuItem> _customSlashMenuItems() {
final items = [...standardSelectionMenuItems];
final imageItem = items.firstWhereOrNull(
@ -387,9 +375,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
if (widget.editorState.document.isEmpty) {
return (
true,
Selection.collapsed(
Position(path: [0], offset: 0),
),
Selection.collapsed(Position(path: [0], offset: 0)),
);
}
final nodes = widget.editorState.document.root.children
@ -399,9 +385,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
if (isAllEmpty) {
return (
true,
Selection.collapsed(
Position(path: nodes.first.path, offset: 0),
)
Selection.collapsed(Position(path: nodes.first.path, offset: 0))
);
}
return const (false, null);
@ -421,9 +405,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
void _setRTLToolbarItems(bool isRTL) {
final textDirectionItemIds = textDirectionItems.map((e) => e.id);
// clear all the text direction items
toolbarItems.removeWhere(
(item) => textDirectionItemIds.contains(item.id),
);
toolbarItems.removeWhere((item) => textDirectionItemIds.contains(item.id));
// only show the rtl item when the layout direction is ltr.
if (isRTL) {
toolbarItems.addAll(textDirectionItems);
@ -441,20 +423,19 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
style,
showReplaceMenu,
onDismiss,
) {
return Material(
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(4),
),
child: FindAndReplaceMenuWidget(
editorState: editorState,
onDismiss: onDismiss,
),
) =>
Material(
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(4),
),
);
},
child: FindAndReplaceMenuWidget(
editorState: editorState,
onDismiss: onDismiss,
),
),
),
),
);
}
@ -468,6 +449,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
if (tintColor != null) {
return tintColor.color(context);
}
final themeColor = themeBackgroundColors[colorString];
if (themeColor != null) {
return themeColor.color(context);
@ -488,9 +470,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
};
}
void _initEditorL10n() {
AppFlowyEditorL10n.current = EditorI18n();
}
void _initEditorL10n() => AppFlowyEditorL10n.current = EditorI18n();
Future<void> _focusOnLastEmptyParagraph() async {
final editorState = widget.editorState;
@ -518,6 +498,7 @@ bool showInAnyTextType(EditorState editorState) {
if (selection == null) {
return false;
}
final nodes = editorState.getNodesInSelection(selection);
return nodes.any(
(node) => toolbarItemWhiteList.contains(node.type),

View File

@ -1,39 +1,56 @@
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:provider/provider.dart';
enum MentionType {
page,
date,
reminder;
reminder,
date;
static MentionType fromString(String value) {
switch (value) {
case 'page':
return page;
case 'date':
return date;
case 'reminder':
return reminder;
default:
throw UnimplementedError();
}
}
static MentionType fromString(String value) => switch (value) {
'page' => page,
'date' => date,
// Backwards compatibility
'reminder' => date,
_ => throw UnimplementedError(),
};
}
Node dateMentionNode() {
return paragraphNode(
delta: Delta(
operations: [
TextInsert(
'\$',
attributes: {
MentionBlockKeys.mention: {
MentionBlockKeys.type: MentionType.date.name,
MentionBlockKeys.date: DateTime.now().toIso8601String(),
},
},
),
],
),
);
}
class MentionBlockKeys {
const MentionBlockKeys._();
static const uid = 'uid'; // UniqueID
static const reminderId = 'reminder_id'; // ReminderID
static const mention = 'mention';
static const type = 'type'; // MentionType, String
static const pageId = 'page_id';
// Related to Reminder and Date blocks
static const date = 'date';
static const date = 'date'; // Start Date
static const includeTime = 'include_time';
static const reminderOption = 'reminder_option';
}
class MentionBlock extends StatelessWidget {
@ -62,21 +79,21 @@ class MentionBlock extends StatelessWidget {
pageId: pageId,
textStyle: textStyle,
);
case MentionType.reminder:
case MentionType.date:
final String date = mention[MentionBlockKeys.date];
final BuildContext editorContext =
context.read<EditorState>().document.root.context!;
final editorState = context.read<EditorState>();
final reminderOption = ReminderOption.values.firstWhereOrNull(
(o) => o.name == mention[MentionBlockKeys.reminderOption],
);
return MentionDateBlock(
key: ValueKey(date),
editorContext: editorContext,
editorState: editorState,
date: date,
node: node,
index: index,
isReminder: type == MentionType.reminder,
reminderId: type == MentionType.reminder
? mention[MentionBlockKeys.uid]
: null,
reminderId: mention[MentionBlockKeys.reminderId],
reminderOption: reminderOption,
includeTime: mention[MentionBlockKeys.includeTime] ?? false,
);
default:

View File

@ -1,45 +1,57 @@
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_picker_dialog.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:calendar_view/calendar_view.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart';
import 'package:appflowy/plugins/base/drag_handler.dart';
import 'package:appflowy/plugins/document/application/doc_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy/user/application/reminder/reminder_extension.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/mobile_appflowy_date_picker.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_picker_dialog.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/mobile_date_header.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:calendar_view/calendar_view.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:nanoid/non_secure.dart';
class MentionDateBlock extends StatefulWidget {
const MentionDateBlock({
super.key,
required this.editorContext,
required this.editorState,
required this.date,
required this.index,
required this.node,
this.isReminder = false,
this.reminderId,
this.reminderOption,
this.includeTime = false,
});
final BuildContext editorContext;
final EditorState editorState;
final String date;
final int index;
final Node node;
final bool isReminder;
/// If [isReminder] is true, then this must not be
/// null or empty
final String? reminderId;
final ReminderOption? reminderOption;
final bool includeTime;
@override
@ -47,14 +59,13 @@ class MentionDateBlock extends StatefulWidget {
}
class _MentionDateBlockState extends State<MentionDateBlock> {
late bool includeTime = widget.includeTime;
final PopoverMutex mutex = PopoverMutex();
late bool _includeTime = widget.includeTime;
late DateTime? parsedDate = DateTime.tryParse(widget.date);
@override
Widget build(BuildContext context) {
final editorState = context.read<EditorState>();
DateTime? parsedDate = DateTime.tryParse(widget.date);
if (parsedDate == null) {
return const SizedBox.shrink();
}
@ -77,10 +88,9 @@ class _MentionDateBlockState extends State<MentionDateBlock> {
builder: (context, state) {
final reminder = state.reminders
.firstWhereOrNull((r) => r.id == widget.reminderId);
final noReminder = reminder == null && widget.isReminder;
final formattedDate = appearance.dateFormat
.formatDate(parsedDate!, includeTime, appearance.timeFormat);
.formatDate(parsedDate!, _includeTime, appearance.timeFormat);
final timeStr = parsedDate != null
? _timeFromDate(parsedDate!, appearance.timeFormat)
@ -90,28 +100,25 @@ class _MentionDateBlockState extends State<MentionDateBlock> {
focusedDay: parsedDate,
popoverMutex: mutex,
selectedDay: parsedDate,
firstDay: widget.isReminder
? noReminder
? parsedDate
: DateTime.now()
: null,
lastDay: noReminder ? parsedDate : null,
timeStr: timeStr,
includeTime: includeTime,
enableRanges: false,
includeTime: _includeTime,
dateFormat: appearance.dateFormat,
timeFormat: appearance.timeFormat,
enableRanges: true,
selectedReminderOption: widget.reminderOption,
onIncludeTimeChanged: (includeTime) {
this.includeTime = includeTime;
_updateBlock(parsedDate!.withoutTime, includeTime);
_includeTime = includeTime;
// We can remove time from the date/reminder
// block when toggled off.
if (widget.isReminder) {
_updateScheduledAt(
reminderId: widget.reminderId!,
selectedDay:
includeTime ? parsedDate! : parsedDate!.withoutTime,
if (![null, ReminderOption.none]
.contains(widget.reminderOption)) {
_updateReminder(
widget.reminderOption!,
reminder,
includeTime,
);
} else {
_updateBlock(
parsedDate!.withoutTime,
includeTime: includeTime,
);
}
@ -121,37 +128,100 @@ class _MentionDateBlockState extends State<MentionDateBlock> {
parsedDate = parsedDate!.withoutTime
.add(Duration(hours: parsed.hour, minutes: parsed.minute));
_updateBlock(parsedDate!, includeTime);
if (widget.isReminder &&
widget.date != parsedDate!.toIso8601String()) {
_updateScheduledAt(
reminderId: widget.reminderId!,
selectedDay: parsedDate!,
if (![null, ReminderOption.none]
.contains(widget.reminderOption)) {
_updateReminder(
widget.reminderOption!,
reminder,
_includeTime,
);
} else {
_updateBlock(parsedDate!, includeTime: _includeTime);
}
},
onDaySelected: (selectedDay, focusedDay) {
parsedDate = selectedDay;
_updateBlock(selectedDay, includeTime);
if (widget.isReminder &&
widget.date != selectedDay.toIso8601String()) {
_updateScheduledAt(
reminderId: widget.reminderId!,
selectedDay: selectedDay,
if (![null, ReminderOption.none]
.contains(widget.reminderOption)) {
_updateReminder(
widget.reminderOption!,
reminder,
_includeTime,
);
} else {
_updateBlock(selectedDay, includeTime: _includeTime);
}
},
onReminderSelected: (reminderOption) =>
_updateReminder(reminderOption, reminder),
);
return GestureDetector(
onTapDown: editorState.editable
? (details) => DatePickerMenu(
context: context,
editorState: context.read<EditorState>(),
).show(details.globalPosition, options: options)
: null,
onTapDown: (details) {
if (widget.editorState.editable) {
if (PlatformExtension.isMobile) {
showMobileBottomSheet(
context,
resizeToAvoidBottomInset: false,
builder: (_) => DraggableScrollableSheet(
expand: false,
snap: true,
initialChildSize: 0.7,
minChildSize: 0.4,
snapSizes: const [0.4, 0.7, 1.0],
builder: (_, controller) => Material(
color:
Theme.of(context).colorScheme.secondaryContainer,
child: ListView(
controller: controller,
children: [
ColoredBox(
color: Theme.of(context).colorScheme.surface,
child: const Center(child: DragHandler()),
),
const MobileDateHeader(),
MobileAppFlowyDatePicker(
selectedDay: parsedDate,
timeStr: timeStr,
dateStr: parsedDate != null
? options.dateFormat
.formatDate(parsedDate!, _includeTime)
: null,
includeTime: options.includeTime,
use24hFormat: options.timeFormat ==
UserTimeFormatPB.TwentyFourHour,
rebuildOnDaySelected: true,
rebuildOnTimeChanged: true,
selectedReminderOption: widget.reminderOption,
onDaySelected: options.onDaySelected,
onStartTimeChanged: (time) => options
.onStartTimeChanged
?.call(time ?? ""),
onIncludeTimeChanged:
options.onIncludeTimeChanged,
liveDateFormatter: (selected) =>
appearance.dateFormat.formatDate(
selected,
false,
appearance.timeFormat,
),
onReminderSelected: (option) =>
_updateReminder(option, reminder),
),
],
),
),
),
);
} else {
DatePickerMenu(
context: context,
editorState: widget.editorState,
).show(details.globalPosition, options: options);
}
}
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: MouseRegion(
@ -160,11 +230,11 @@ class _MentionDateBlockState extends State<MentionDateBlock> {
mainAxisSize: MainAxisSize.min,
children: [
FlowySvg(
widget.isReminder
widget.reminderId != null
? FlowySvgs.clock_alarm_s
: FlowySvgs.date_s,
size: const Size.square(18.0),
color: widget.isReminder && reminder?.isAck == true
color: reminder?.isAck == true
? Theme.of(context).colorScheme.error
: null,
),
@ -172,7 +242,7 @@ class _MentionDateBlockState extends State<MentionDateBlock> {
FlowyText(
formattedDate,
fontSize: fontSize,
color: widget.isReminder && reminder?.isAck == true
color: reminder?.isAck == true
? Theme.of(context).colorScheme.error
: null,
),
@ -191,11 +261,16 @@ class _MentionDateBlockState extends State<MentionDateBlock> {
final twelveHourFormat = DateFormat('HH:mm a');
final twentyFourHourFormat = DateFormat('HH:mm');
if (timeFormat == TimeFormatPB.TwelveHour) {
return twelveHourFormat.parse(timeStr);
}
try {
if (timeFormat == TimeFormatPB.TwelveHour) {
return twelveHourFormat.parse(timeStr);
}
return twentyFourHourFormat.parse(timeStr);
return twentyFourHourFormat.parse(timeStr);
} on FormatException {
Log.error("failed to parse time string ($timeStr)");
return DateTime.now();
}
}
String _timeFromDate(DateTime date, UserTimeFormatPB timeFormat) {
@ -210,43 +285,94 @@ class _MentionDateBlockState extends State<MentionDateBlock> {
}
void _updateBlock(
DateTime date, [
DateTime date, {
bool includeTime = false,
]) {
final editorState = widget.editorContext.read<EditorState>();
final transaction = editorState.transaction
String? reminderId,
ReminderOption? reminderOption,
}) {
final rId = reminderId ??
(reminderOption == ReminderOption.none ? null : widget.reminderId);
final transaction = widget.editorState.transaction
..formatText(widget.node, widget.index, 1, {
MentionBlockKeys.mention: {
MentionBlockKeys.type: widget.isReminder
? MentionType.reminder.name
: MentionType.date.name,
MentionBlockKeys.type: MentionType.date.name,
MentionBlockKeys.date: date.toIso8601String(),
MentionBlockKeys.uid: widget.reminderId,
MentionBlockKeys.reminderId: rId,
MentionBlockKeys.includeTime: includeTime,
MentionBlockKeys.reminderOption:
reminderOption?.name ?? widget.reminderOption?.name,
},
});
editorState.apply(transaction, withUpdateSelection: false);
widget.editorState.apply(transaction, withUpdateSelection: false);
// Length of rendered block changes, this synchronizes
// the cursor with the new block render
editorState.updateSelectionWithReason(
editorState.selection,
widget.editorState.updateSelectionWithReason(
widget.editorState.selection,
reason: SelectionUpdateReason.transaction,
);
}
void _updateScheduledAt({
required String reminderId,
required DateTime selectedDay,
bool? includeTime,
}) {
widget.editorContext.read<ReminderBloc>().add(
ReminderEvent.update(
ReminderUpdate(
void _updateReminder(
ReminderOption reminderOption,
ReminderPB? reminder, [
bool includeTime = false,
]) {
final rootContext = widget.editorState.document.root.context;
if (parsedDate == null || rootContext == null) {
return;
}
if (widget.reminderId != null) {
_updateBlock(
parsedDate!,
includeTime: includeTime,
reminderOption: reminderOption,
);
if (ReminderOption.none == reminderOption && reminder != null) {
// Delete existing reminder
return rootContext
.read<ReminderBloc>()
.add(ReminderEvent.remove(reminderId: reminder.id));
}
// Update existing reminder
return rootContext.read<ReminderBloc>().add(
ReminderEvent.update(
ReminderUpdate(
id: widget.reminderId!,
scheduledAt: parsedDate!.subtract(reminderOption.time),
),
),
);
}
final reminderId = nanoid();
_updateBlock(
parsedDate!,
includeTime: includeTime,
reminderId: reminderId,
reminderOption: reminderOption,
);
// Add new reminder
final viewId = rootContext.read<DocumentBloc>().view.id;
return rootContext.read<ReminderBloc>().add(
ReminderEvent.add(
reminder: ReminderPB(
id: reminderId,
scheduledAt: selectedDay,
includeTime: includeTime,
objectId: viewId,
title: LocaleKeys.reminderNotification_title.tr(),
message: LocaleKeys.reminderNotification_message.tr(),
meta: {
ReminderMetaKeys.includeTime: false.toString(),
ReminderMetaKeys.blockId: widget.node.id,
},
scheduledAt: Int64(parsedDate!.millisecondsSinceEpoch ~/ 1000),
isAck: parsedDate!.isBefore(DateTime.now()),
),
),
);

View File

@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_item/mobile_add_block_toolbar_item.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_toolbar_theme.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
@ -9,7 +12,6 @@ import 'package:appflowy/startup/tasks/app_widget.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
final addBlockToolbarItem = AppFlowyMobileToolbarItem(
@ -213,6 +215,15 @@ class _AddBlockMenu extends StatelessWidget {
});
},
),
// date
_AddBlockMenuItemData(
blockType: ParagraphBlockKeys.type,
backgroundColor: const Color(0xFFF49898),
text: LocaleKeys.editor_date.tr(),
icon: FlowySvgs.date_s,
onTap: () => _insertBlock(dateMentionNode()),
),
];
@override

View File

@ -252,9 +252,7 @@ class EditorStyleCustomizer {
key: ValueKey(
switch (type) {
MentionType.page => mention[MentionBlockKeys.pageId],
MentionType.date ||
MentionType.reminder =>
mention[MentionBlockKeys.date],
MentionType.date => mention[MentionBlockKeys.date],
_ => MentionBlockKeys.mention,
},
),

View File

@ -1,16 +1,18 @@
import 'package:flutter/material.dart';
import 'package:appflowy/date/date_service.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/doc_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart';
import 'package:appflowy/user/application/reminder/reminder_extension.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy/user/application/reminder/reminder_extension.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart';
import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:nanoid/nanoid.dart';
@ -147,9 +149,10 @@ class ReminderReferenceService {
'\$',
attributes: {
MentionBlockKeys.mention: {
MentionBlockKeys.type: MentionType.reminder.name,
MentionBlockKeys.type: MentionType.date.name,
MentionBlockKeys.date: date.toIso8601String(),
MentionBlockKeys.uid: reminder.id,
MentionBlockKeys.reminderId: reminder.id,
MentionBlockKeys.reminderOption: ReminderOption.atTimeOfEvent.name,
},
},
);
@ -213,8 +216,8 @@ class ReminderReferenceService {
title: LocaleKeys.reminderNotification_title.tr(),
message: LocaleKeys.reminderNotification_message.tr(),
meta: {
ReminderMetaKeys.includeTime.name: false.toString(),
ReminderMetaKeys.blockId.name: node.id,
ReminderMetaKeys.includeTime: false.toString(),
ReminderMetaKeys.blockId: node.id,
},
scheduledAt: Int64(date.millisecondsSinceEpoch ~/ 1000),
isAck: date.isBefore(DateTime.now()),

View File

@ -1,18 +1,22 @@
import 'package:flutter/material.dart';
import 'package:appflowy/mobile/application/mobile_router.dart';
import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy/user/application/user_settings_service.dart';
import 'package:appflowy/workspace/application/notifications/notification_action.dart';
import 'package:appflowy/workspace/application/notifications/notification_action_bloc.dart';
import 'package:appflowy/workspace/application/notifications/notification_service.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
import 'package:appflowy/workspace/application/settings/notifications/notification_settings_cubit.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
@ -140,30 +144,33 @@ class _ApplicationWidgetState extends State<ApplicationWidget> {
create: (_) => DocumentAppearanceCubit()..fetch(),
),
BlocProvider.value(value: getIt<NotificationActionBloc>()),
BlocProvider.value(
value: getIt<ReminderBloc>()..add(const ReminderEvent.started()),
),
],
child: BlocListener<NotificationActionBloc, NotificationActionState>(
listenWhen: (_, curr) => curr.action != null,
listener: (context, state) {
if (state.action?.type == ActionType.openView) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final view =
state.action!.arguments?[ActionArgumentKeys.view.name];
final action = state.action;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (action?.type == ActionType.openView &&
PlatformExtension.isDesktop) {
final view = action!.arguments?[ActionArgumentKeys.view];
if (view != null) {
AppGlobals.rootNavKey.currentContext?.pushView(view);
final nodePath = state.action!
.arguments?[ActionArgumentKeys.nodePath.name] as int?;
if (nodePath != null) {
context.read<NotificationActionBloc>().add(
NotificationActionEvent.performAction(
action: state.action!
.copyWith(type: ActionType.jumpToBlock),
),
);
}
}
});
}
} else if (action?.type == ActionType.openRow &&
PlatformExtension.isMobile) {
final view = action!.arguments?[ActionArgumentKeys.view];
if (view != null) {
final view = action.arguments?[ActionArgumentKeys.view];
final rowId = action.arguments?[ActionArgumentKeys.rowId];
AppGlobals.rootNavKey.currentContext?.pushView(view, {
PluginArgumentKeys.rowId: rowId,
});
}
}
});
},
child: BlocBuilder<AppearanceSettingsCubit, AppearanceSettingsState>(
builder: (context, state) => MaterialApp.router(

View File

@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:appflowy/mobile/presentation/database/board/mobile_board_screen.dart';
import 'package:appflowy/mobile/presentation/database/card/card.dart';
import 'package:appflowy/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart';
@ -489,10 +491,13 @@ GoRoute _mobileGridScreenRoute() {
pageBuilder: (context, state) {
final id = state.uri.queryParameters[MobileGridScreen.viewId]!;
final title = state.uri.queryParameters[MobileGridScreen.viewTitle];
final arguments = state.uri.queryParameters[MobileGridScreen.viewArgs];
return MaterialPage(
child: MobileGridScreen(
id: id,
title: title,
arguments: arguments != null ? jsonDecode(arguments) : null,
),
);
},

View File

@ -5,6 +5,7 @@ import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/reminder/reminder_extension.dart';
import 'package:appflowy/user/application/reminder/reminder_service.dart';
import 'package:appflowy/user/application/user_settings_service.dart';
import 'package:appflowy/util/int64_extension.dart';
import 'package:appflowy/workspace/application/notifications/notification_action.dart';
import 'package:appflowy/workspace/application/notifications/notification_action_bloc.dart';
import 'package:appflowy/workspace/application/notifications/notification_service.dart';
@ -15,19 +16,18 @@ import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'reminder_bloc.freezed.dart';
class ReminderBloc extends Bloc<ReminderEvent, ReminderState> {
late final NotificationActionBloc actionBloc;
late final ReminderService reminderService;
late final NotificationActionBloc _actionBloc;
late final ReminderService _reminderService;
late final Timer timer;
ReminderBloc() : super(ReminderState()) {
actionBloc = getIt<NotificationActionBloc>();
reminderService = const ReminderService();
_actionBloc = getIt<NotificationActionBloc>();
_reminderService = const ReminderService();
timer = _periodicCheck();
on<ReminderEvent>((event, emit) async {
@ -42,7 +42,7 @@ class ReminderBloc extends Bloc<ReminderEvent, ReminderState> {
reminders.remove(reminder);
reminder.isRead = true;
await reminderService.updateReminder(reminder: reminder);
await _reminderService.updateReminder(reminder: reminder);
updatedReminders.add(reminder);
}
@ -51,29 +51,29 @@ class ReminderBloc extends Bloc<ReminderEvent, ReminderState> {
emit(state.copyWith(reminders: reminders));
},
started: () async {
final remindersOrFailure = await reminderService.fetchReminders();
final remindersOrFailure = await _reminderService.fetchReminders();
remindersOrFailure.fold(
(error) => Log.error(error),
(reminders) => emit(state.copyWith(reminders: reminders)),
);
},
remove: (reminder) async {
remove: (reminderId) async {
final unitOrFailure =
await reminderService.removeReminder(reminderId: reminder.id);
await _reminderService.removeReminder(reminderId: reminderId);
unitOrFailure.fold(
(error) => Log.error(error),
(_) {
final reminders = [...state.reminders];
reminders.removeWhere((e) => e.id == reminder.id);
reminders.removeWhere((e) => e.id == reminderId);
emit(state.copyWith(reminders: reminders));
},
);
},
add: (reminder) async {
final unitOrFailure =
await reminderService.addReminder(reminder: reminder);
await _reminderService.addReminder(reminder: reminder);
return unitOrFailure.fold(
(error) => Log.error(error),
@ -83,6 +83,19 @@ class ReminderBloc extends Bloc<ReminderEvent, ReminderState> {
},
);
},
addById: (reminderId, objectId, scheduledAt, meta) async => add(
ReminderEvent.add(
reminder: ReminderPB(
id: reminderId,
objectId: objectId,
title: LocaleKeys.reminderNotification_title.tr(),
message: LocaleKeys.reminderNotification_message.tr(),
scheduledAt: scheduledAt,
isAck: scheduledAt.toDateTime().isBefore(DateTime.now()),
meta: meta,
),
),
),
update: (updateObject) async {
final reminder =
state.reminders.firstWhereOrNull((r) => r.id == updateObject.id);
@ -92,7 +105,7 @@ class ReminderBloc extends Bloc<ReminderEvent, ReminderState> {
}
final newReminder = updateObject.merge(a: reminder);
final failureOrUnit = await reminderService.updateReminder(
final failureOrUnit = await _reminderService.updateReminder(
reminder: updateObject.merge(a: reminder),
);
@ -124,17 +137,34 @@ class ReminderBloc extends Bloc<ReminderEvent, ReminderState> {
),
);
actionBloc.add(
NotificationActionEvent.performAction(
action: NotificationAction(
objectId: reminder.objectId,
arguments: {
ActionArgumentKeys.nodePath.name: path,
ActionArgumentKeys.view.name: view,
},
),
),
String? rowId;
if (view?.layout != ViewLayoutPB.Document) {
rowId = reminder.meta[ReminderMetaKeys.rowId];
}
final action = NotificationAction(
objectId: reminder.objectId,
arguments: {
ActionArgumentKeys.view: view,
ActionArgumentKeys.nodePath: path,
ActionArgumentKeys.rowId: rowId,
},
);
if (!isClosed) {
_actionBloc.add(
NotificationActionEvent.performAction(
action: action,
nextActions: [
action.copyWith(
type: rowId != null
? ActionType.openRow
: ActionType.jumpToBlock,
),
],
),
);
}
},
);
});
@ -151,9 +181,7 @@ class ReminderBloc extends Bloc<ReminderEvent, ReminderState> {
continue;
}
final scheduledAt = DateTime.fromMillisecondsSinceEpoch(
reminder.scheduledAt.toInt() * 1000,
);
final scheduledAt = reminder.scheduledAt.toDateTime();
if (scheduledAt.isBefore(now)) {
final notificationSettings =
@ -163,7 +191,7 @@ class ReminderBloc extends Bloc<ReminderEvent, ReminderState> {
identifier: reminder.id,
title: LocaleKeys.reminderNotification_title.tr(),
body: LocaleKeys.reminderNotification_message.tr(),
onClick: () => actionBloc.add(
onClick: () => _actionBloc.add(
NotificationActionEvent.performAction(
action: NotificationAction(objectId: reminder.objectId),
),
@ -189,11 +217,19 @@ class ReminderEvent with _$ReminderEvent {
const factory ReminderEvent.started() = _Started;
// Remove a reminder
const factory ReminderEvent.remove({required ReminderPB reminder}) = _Remove;
const factory ReminderEvent.remove({required String reminderId}) = _Remove;
// Add a reminder
const factory ReminderEvent.add({required ReminderPB reminder}) = _Add;
// Add a reminder
const factory ReminderEvent.addById({
required String reminderId,
required String objectId,
required Int64 scheduledAt,
@Default(null) Map<String, String>? meta,
}) = _AddById;
// Update a reminder (eg. isAck, isRead, etc.)
const factory ReminderEvent.update(ReminderUpdate update) = _Update;
@ -232,7 +268,7 @@ class ReminderUpdate {
final meta = a.meta;
if (includeTime != a.includeTime) {
meta[ReminderMetaKeys.includeTime.name] = includeTime.toString();
meta[ReminderMetaKeys.includeTime] = includeTime.toString();
}
return ReminderPB(

View File

@ -1,17 +1,14 @@
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
enum ReminderMetaKeys {
includeTime("include_time"),
blockId("block_id");
const ReminderMetaKeys(this.name);
final String name;
class ReminderMetaKeys {
static String includeTime = "include_time";
static String blockId = "block_id";
static String rowId = "row_id";
}
extension ReminderExtension on ReminderPB {
bool? get includeTime {
final String? includeTimeStr = meta[ReminderMetaKeys.includeTime.name];
final String? includeTimeStr = meta[ReminderMetaKeys.includeTime];
return includeTimeStr != null ? includeTimeStr == true.toString() : null;
}

View File

@ -0,0 +1,5 @@
import 'package:fixnum/fixnum.dart';
extension DateConversion on Int64 {
DateTime toDateTime() => DateTime.fromMillisecondsSinceEpoch(toInt() * 1000);
}

View File

@ -1,6 +1,13 @@
enum ActionType {
openView,
jumpToBlock,
openRow,
}
class ActionArgumentKeys {
static String view = "view";
static String nodePath = "node_path";
static String rowId = "row_id";
}
/// A [NotificationAction] is used to communicate with the
@ -31,12 +38,3 @@ class NotificationAction {
arguments: arguments ?? this.arguments,
);
}
enum ActionArgumentKeys {
view('view'),
nodePath('node_path');
final String name;
const ActionArgumentKeys(this.name);
}

View File

@ -9,8 +9,20 @@ class NotificationActionBloc
NotificationActionBloc() : super(const NotificationActionState.initial()) {
on<NotificationActionEvent>((event, emit) async {
event.when(
performAction: (action) {
emit(state.copyWith(action: action));
performAction: (action, nextActions) {
emit(state.copyWith(action: action, nextActions: nextActions));
if (nextActions.isNotEmpty) {
final newActions = [...nextActions];
final next = newActions.removeAt(0);
add(
NotificationActionEvent.performAction(
action: next,
nextActions: newActions,
),
);
}
},
);
});
@ -21,18 +33,29 @@ class NotificationActionBloc
class NotificationActionEvent with _$NotificationActionEvent {
const factory NotificationActionEvent.performAction({
required NotificationAction action,
@Default([]) List<NotificationAction> nextActions,
}) = _PerformAction;
}
class NotificationActionState {
const NotificationActionState({required this.action});
const NotificationActionState({
required this.action,
this.nextActions = const [],
});
final NotificationAction? action;
final List<NotificationAction> nextActions;
const NotificationActionState.initial() : action = null;
const NotificationActionState.initial()
: action = null,
nextActions = const [];
NotificationActionState copyWith({
NotificationAction? action,
List<NotificationAction>? nextActions,
}) =>
NotificationActionState(action: action ?? this.action);
NotificationActionState(
action: action ?? this.action,
nextActions: nextActions ?? this.nextActions,
);
}

View File

@ -1,3 +1,5 @@
import 'package:flutter/foundation.dart';
import 'package:appflowy/plugins/util.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/startup/startup.dart';
@ -6,7 +8,6 @@ import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:bloc/bloc.dart';
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'tabs_event.dart';
@ -67,6 +68,14 @@ class TabsBloc extends Bloc<TabsEvent, TabsState> {
add(TabsEvent.openTab(plugin: view.plugin(), view: view));
/// Adds a [TabsEvent.openPlugin] event for the provided [ViewPB]
void openPlugin(ViewPB view) =>
add(TabsEvent.openPlugin(plugin: view.plugin(), view: view));
void openPlugin(
ViewPB view, {
Map<String, dynamic> arguments = const {},
}) =>
add(
TabsEvent.openPlugin(
plugin: view.plugin(arguments: arguments),
view: view,
),
);
}

View File

@ -1,3 +1,5 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/database/board/presentation/board_page.dart';
import 'package:appflowy/plugins/database/calendar/presentation/calendar_page.dart';
@ -9,14 +11,19 @@ import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:dartz/dartz.dart' hide id;
import 'package:flutter/material.dart';
enum FlowyPlugin {
editor,
kanban,
}
class PluginArgumentKeys {
static String selection = "selection";
static String rowId = "row_id";
}
extension ViewExtension on ViewPB {
Widget defaultIcon() => FlowySvg(
switch (layout) {
@ -36,17 +43,30 @@ extension ViewExtension on ViewPB {
_ => throw UnimplementedError(),
};
Plugin plugin({bool listenOnViewChanged = false}) {
Plugin plugin({
bool listenOnViewChanged = false,
Map<String, dynamic> arguments = const {},
}) {
switch (layout) {
case ViewLayoutPB.Board:
case ViewLayoutPB.Calendar:
case ViewLayoutPB.Grid:
return DatabaseTabBarViewPlugin(view: this, pluginType: pluginType);
final String? rowId = arguments[PluginArgumentKeys.rowId];
return DatabaseTabBarViewPlugin(
view: this,
pluginType: pluginType,
initialRowId: rowId,
);
case ViewLayoutPB.Document:
final Selection? initialSelection =
arguments[PluginArgumentKeys.selection];
return DocumentPlugin(
view: this,
pluginType: pluginType,
listenOnViewChanged: listenOnViewChanged,
initialSelection: initialSelection,
);
}
throw UnimplementedError;

View File

@ -1,3 +1,5 @@
import 'package:flutter/material.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/menu/menu_bloc.dart';
@ -14,8 +16,8 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'
show UserProfilePB;
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
/// Home Sidebar is the left side bar of the home page.
@ -63,6 +65,7 @@ class HomeSideBar extends StatelessWidget {
),
),
BlocListener<NotificationActionBloc, NotificationActionState>(
listenWhen: (_, curr) => curr.action != null,
listener: _onNotificationAction,
),
],
@ -147,17 +150,21 @@ class HomeSideBar extends StatelessWidget {
context.read<MenuBloc>().state.views.findView(action.objectId);
if (view != null) {
context.read<TabsBloc>().openPlugin(view);
final Map<String, dynamic> arguments = {};
final nodePath =
action.arguments?[ActionArgumentKeys.nodePath.name] as int?;
final nodePath = action.arguments?[ActionArgumentKeys.nodePath];
if (nodePath != null) {
context.read<NotificationActionBloc>().add(
NotificationActionEvent.performAction(
action: action.copyWith(type: ActionType.jumpToBlock),
),
);
arguments[PluginArgumentKeys.selection] = Selection.collapsed(
Position(path: [nodePath]),
);
}
final rowId = action.arguments?[ActionArgumentKeys.rowId];
if (rowId != null) {
arguments[PluginArgumentKeys.rowId] = rowId;
}
context.read<TabsBloc>().openPlugin(view, arguments: arguments);
}
}
}

View File

@ -117,7 +117,7 @@ class _NotificationDialogState extends State<NotificationDialog>
}
void _onDelete(ReminderPB reminder) {
_reminderBloc.add(ReminderEvent.remove(reminder: reminder));
_reminderBloc.add(ReminderEvent.remove(reminderId: reminder.id));
}
void _onReadChanged(ReminderPB reminder, bool isRead) {

View File

@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/user/application/reminder/reminder_extension.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy/user/application/reminder/reminder_extension.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/notifications/widgets/notification_item.dart';
import 'package:appflowy/workspace/presentation/notifications/widgets/notifications_hub_empty.dart';
import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart';
@ -9,9 +12,7 @@ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:collection/collection.dart';
import 'package:dartz/dartz.dart';
import 'package:flutter/material.dart';
/// Displays a Lsit of Notifications, currently used primarily to
/// display Reminders.
@ -62,8 +63,7 @@ class NotificationsView extends StatelessWidget {
children: [
...shownReminders.map(
(ReminderPB reminder) {
final blockId =
reminder.meta[ReminderMetaKeys.blockId.name];
final blockId = reminder.meta[ReminderMetaKeys.blockId];
final documentService = DocumentService();
final documentFuture = documentService.openDocument(
@ -76,9 +76,7 @@ class NotificationsView extends StatelessWidget {
_getNodeFromDocument(documentFuture, blockId);
}
final view = views
.firstWhereOrNull((v) => v.id == reminder.objectId);
final view = views.findView(reminder.objectId);
return NotificationItem(
reminderId: reminder.id,
key: ValueKey(reminder.id),

View File

@ -1,16 +1,22 @@
import 'package:appflowy/plugins/database/grid/presentation/widgets/header/type_option/date/date_time_format.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/clear_date_button.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/header/type_option/date/date_time_format.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_picker.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_type_option_button.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/end_text_field.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/end_time_button.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/start_text_field.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pbenum.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
class OptionGroup {
OptionGroup({required this.options});
final List<Widget> options;
}
typedef DaySelectedCallback = Function(DateTime, DateTime);
typedef RangeSelectedCallback = Function(DateTime?, DateTime?, DateTime);
@ -32,20 +38,27 @@ class AppFlowyDatePicker extends StatefulWidget {
this.focusedDay,
this.firstDay,
this.lastDay,
this.startDay,
this.endDay,
this.timeStr,
this.endTimeStr,
this.timeHintText,
this.parseEndTimeError,
this.parseTimeError,
this.popoverMutex,
this.selectedReminderOption = ReminderOption.none,
this.onStartTimeSubmitted,
this.onEndTimeSubmitted,
this.onDaySelected,
this.onRangeSelected,
this.onReminderSelected,
this.options,
this.allowFormatChanges = false,
this.onDateFormatChanged,
this.onTimeFormatChanged,
this.onClearDate,
this.onCalendarCreated,
this.onPageChanged,
});
final bool includeTime;
@ -64,17 +77,33 @@ class AppFlowyDatePicker extends StatefulWidget {
final DateTime? focusedDay;
final DateTime? firstDay;
final DateTime? lastDay;
/// Start date in selected range
final DateTime? startDay;
/// End date in selected range
final DateTime? endDay;
final String? timeStr;
final String? endTimeStr;
final String? timeHintText;
final String? parseEndTimeError;
final String? parseTimeError;
final PopoverMutex? popoverMutex;
final ReminderOption selectedReminderOption;
final TimeChangedCallback? onStartTimeSubmitted;
final TimeChangedCallback? onEndTimeSubmitted;
final DaySelectedCallback? onDaySelected;
final RangeSelectedCallback? onRangeSelected;
final OnReminderSelected? onReminderSelected;
/// A list of [OptionGroup] that will be rendered with proper
/// separators, each group can contain multiple options.
///
/// __Supported on Desktop & Web__
///
final List<OptionGroup>? options;
/// If this value is true, then [onTimeFormatChanged] and [onDateFormatChanged]
/// cannot be null
@ -94,21 +123,45 @@ class AppFlowyDatePicker extends StatefulWidget {
///
final VoidCallback? onClearDate;
final void Function(PageController pageController)? onCalendarCreated;
final void Function(DateTime focusedDay)? onPageChanged;
@override
State<AppFlowyDatePicker> createState() => _AppFlowyDatePickerState();
}
class _AppFlowyDatePickerState extends State<AppFlowyDatePicker> {
late DateTime? _selectedDay = widget.selectedDay;
late ReminderOption _selectedReminderOption = widget.selectedReminderOption;
@override
void didChangeDependencies() {
_selectedDay = widget.selectedDay;
super.didChangeDependencies();
Widget build(BuildContext context) =>
PlatformExtension.isMobile ? buildMobilePicker() : buildDesktopPicker();
Widget buildMobilePicker() {
return DatePicker(
isRange: widget.isRange,
onDaySelected: (selectedDay, focusedDay) {
widget.onDaySelected?.call(selectedDay, focusedDay);
if (widget.rebuildOnDaySelected) {
setState(() => _selectedDay = selectedDay);
}
},
onRangeSelected: widget.onRangeSelected,
selectedDay:
widget.rebuildOnDaySelected ? _selectedDay : widget.selectedDay,
firstDay: widget.firstDay,
lastDay: widget.lastDay,
startDay: widget.startDay,
endDay: widget.endDay,
onCalendarCreated: widget.onCalendarCreated,
onPageChanged: widget.onPageChanged,
);
}
@override
Widget build(BuildContext context) {
Widget buildDesktopPicker() {
return Padding(
padding: const EdgeInsets.only(top: 18.0, bottom: 12.0),
child: Column(
@ -142,9 +195,14 @@ class _AppFlowyDatePickerState extends State<AppFlowyDatePicker> {
}
},
onRangeSelected: widget.onRangeSelected,
selectedDay: _selectedDay,
selectedDay:
widget.rebuildOnDaySelected ? _selectedDay : widget.selectedDay,
firstDay: widget.firstDay,
lastDay: widget.lastDay,
startDay: widget.startDay,
endDay: widget.endDay,
onCalendarCreated: widget.onCalendarCreated,
onPageChanged: widget.onPageChanged,
),
const TypeOptionSeparator(spacing: 12.0),
if (widget.enableRanges && widget.onIsRangeChanged != null) ...[
@ -161,28 +219,44 @@ class _AppFlowyDatePickerState extends State<AppFlowyDatePicker> {
onChanged: widget.onIncludeTimeChanged,
),
),
if (widget.onClearDate != null ||
(widget.allowFormatChanges &&
widget.onDateFormatChanged != null &&
widget.onTimeFormatChanged != null))
// Only show if either of the options are below it
const TypeOptionSeparator(spacing: 8.0),
if (widget.allowFormatChanges &&
widget.onDateFormatChanged != null &&
widget.onTimeFormatChanged != null)
DateTypeOptionButton(
popoverMutex: widget.popoverMutex,
dateFormat: widget.dateFormat,
timeFormat: widget.timeFormat,
onDateFormatChanged: widget.onDateFormatChanged!,
onTimeFormatChanged: widget.onTimeFormatChanged!,
const _GroupSeparator(),
ReminderSelector(
mutex: widget.popoverMutex,
selectedOption: _selectedReminderOption,
onOptionSelected: (option) {
setState(() => _selectedReminderOption = option);
widget.onReminderSelected?.call(option);
},
),
if (widget.options?.isNotEmpty ?? false) ...[
const _GroupSeparator(),
ListView.separated(
shrinkWrap: true,
itemCount: widget.options!.length,
separatorBuilder: (_, __) => const _GroupSeparator(),
itemBuilder: (_, index) =>
_renderGroupOptions(widget.options![index].options),
),
if (widget.onClearDate != null) ...[
const VSpace(4.0),
ClearDateButton(onClearDate: widget.onClearDate!),
],
],
),
);
}
Widget _renderGroupOptions(List<Widget> options) => ListView.separated(
shrinkWrap: true,
itemCount: options.length,
separatorBuilder: (_, __) => const VSpace(4),
itemBuilder: (_, index) => options[index],
);
}
class _GroupSeparator extends StatelessWidget {
const _GroupSeparator();
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Container(color: Theme.of(context).dividerColor, height: 1.0),
);
}

View File

@ -0,0 +1,468 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/app_bar_actions.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_option_decorate_box.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/appflowy_date_picker.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/mobile_date_editor.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:go_router/go_router.dart';
class MobileAppFlowyDatePicker extends StatefulWidget {
const MobileAppFlowyDatePicker({
super.key,
this.selectedDay,
this.startDay,
this.endDay,
this.dateStr,
this.endDateStr,
this.timeStr,
this.endTimeStr,
this.enableRanges = false,
this.isRange = false,
this.rebuildOnDaySelected = false,
this.rebuildOnTimeChanged = false,
required this.includeTime,
required this.use24hFormat,
this.selectedReminderOption,
required this.onStartTimeChanged,
this.onEndTimeChanged,
required this.onIncludeTimeChanged,
this.onRangeChanged,
this.onDaySelected,
this.onRangeSelected,
this.onClearDate,
this.liveDateFormatter,
this.onReminderSelected,
});
final DateTime? selectedDay;
final DateTime? startDay;
final DateTime? endDay;
final String? dateStr;
final String? endDateStr;
final String? timeStr;
final String? endTimeStr;
final bool enableRanges;
final bool isRange;
final bool includeTime;
final bool rebuildOnDaySelected;
final bool rebuildOnTimeChanged;
final bool use24hFormat;
final ReminderOption? selectedReminderOption;
final Function(String? time) onStartTimeChanged;
final Function(String? time)? onEndTimeChanged;
final Function(bool) onIncludeTimeChanged;
final Function(bool)? onRangeChanged;
final DaySelectedCallback? onDaySelected;
final RangeSelectedCallback? onRangeSelected;
final VoidCallback? onClearDate;
final OnReminderSelected? onReminderSelected;
final String Function(DateTime)? liveDateFormatter;
@override
State<MobileAppFlowyDatePicker> createState() =>
_MobileAppFlowyDatePickerState();
}
class _MobileAppFlowyDatePickerState extends State<MobileAppFlowyDatePicker> {
late bool _includeTime = widget.includeTime;
late String? _dateStr = widget.dateStr;
late ReminderOption _reminderOption =
widget.selectedReminderOption ?? ReminderOption.none;
@override
Widget build(BuildContext context) {
return Column(
children: [
FlowyOptionDecorateBox(
showTopBorder: false,
child: _IncludeTimePicker(
dateStr:
widget.liveDateFormatter != null ? _dateStr : widget.dateStr,
endDateStr: widget.endDateStr,
timeStr: widget.timeStr,
endTimeStr: widget.endTimeStr,
includeTime: _includeTime,
use24hFormat: widget.use24hFormat,
onStartTimeChanged: widget.onStartTimeChanged,
onEndTimeChanged: widget.onEndTimeChanged,
rebuildOnTimeChanged: widget.rebuildOnTimeChanged,
),
),
const _Divider(),
FlowyOptionDecorateBox(
child: MobileDatePicker(
isRange: widget.isRange,
selectedDay: widget.selectedDay,
startDay: widget.startDay,
endDay: widget.endDay,
onDaySelected: (selected, focused) {
widget.onDaySelected?.call(selected, focused);
if (widget.liveDateFormatter != null) {
setState(() => _dateStr = widget.liveDateFormatter!(selected));
}
},
onRangeSelected: widget.onRangeSelected,
rebuildOnDaySelected: widget.rebuildOnDaySelected,
),
),
const _Divider(),
if (widget.enableRanges && widget.onRangeChanged != null)
_EndDateSwitch(
isRange: widget.isRange,
onRangeChanged: widget.onRangeChanged!,
),
_IncludeTimeSwitch(
showTopBorder: !widget.enableRanges || widget.onRangeChanged == null,
includeTime: _includeTime,
onIncludeTimeChanged: (includeTime) {
widget.onIncludeTimeChanged(includeTime);
setState(() => _includeTime = includeTime);
},
),
if (widget.onReminderSelected != null) ...[
const _Divider(),
_ReminderSelector(
selectedReminderOption: _reminderOption,
onReminderSelected: (option) {
widget.onReminderSelected!.call(option);
setState(() => _reminderOption = option);
},
),
],
if (widget.onClearDate != null) ...[
const _Divider(),
_ClearDateButton(onClearDate: widget.onClearDate!),
],
const _Divider(),
],
);
}
}
class _Divider extends StatelessWidget {
const _Divider();
@override
Widget build(BuildContext context) => const VSpace(20.0);
}
class _ReminderSelector extends StatelessWidget {
const _ReminderSelector({
this.selectedReminderOption,
required this.onReminderSelected,
});
final ReminderOption? selectedReminderOption;
final OnReminderSelected onReminderSelected;
@override
Widget build(BuildContext context) {
final option = selectedReminderOption ?? ReminderOption.none;
final availableOptions = [...ReminderOption.values];
if (option != ReminderOption.custom) {
availableOptions.remove(ReminderOption.custom);
}
return FlowyOptionTile.text(
text: 'Reminder',
trailing: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const HSpace(6.0),
FlowyText(
option.label,
color: Theme.of(context).hintColor,
),
const HSpace(4.0),
FlowySvg(
FlowySvgs.arrow_right_s,
color: Theme.of(context).hintColor,
size: const Size.square(18.0),
),
],
),
onTap: () => showMobileBottomSheet(
context,
padding: EdgeInsets.zero,
builder: (context) {
return DraggableScrollableSheet(
expand: false,
snap: true,
initialChildSize: 0.7,
minChildSize: 0.7,
builder: (context, controller) => Column(
children: [
const _ReminderSelectHeader(),
const VSpace(12.0),
Flexible(
child: SingleChildScrollView(
controller: controller,
child: Column(
children: availableOptions
.map(
(o) => FlowyOptionTile.text(
text: o.label,
showTopBorder: o == ReminderOption.none,
onTap: () {
onReminderSelected(o);
context.pop();
},
),
)
.toList(),
),
),
),
],
),
);
},
),
);
}
}
class _ReminderSelectHeader extends StatelessWidget {
const _ReminderSelectHeader();
@override
Widget build(BuildContext context) {
return SizedBox(
height: 56,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
SizedBox(
width: 120,
child: AppBarCancelButton(onTap: context.pop),
),
const FlowyText.medium(
'Select reminder',
fontSize: 17.0,
),
const HSpace(120),
],
),
);
}
}
class _IncludeTimePicker extends StatefulWidget {
const _IncludeTimePicker({
required this.includeTime,
this.dateStr,
this.endDateStr,
this.timeStr,
this.endTimeStr,
this.rebuildOnTimeChanged = false,
required this.use24hFormat,
required this.onStartTimeChanged,
required this.onEndTimeChanged,
});
final bool includeTime;
final String? dateStr;
final String? endDateStr;
final String? timeStr;
final String? endTimeStr;
final bool rebuildOnTimeChanged;
final bool use24hFormat;
final Function(String? time) onStartTimeChanged;
final Function(String? time)? onEndTimeChanged;
@override
State<_IncludeTimePicker> createState() => _IncludeTimePickerState();
}
class _IncludeTimePickerState extends State<_IncludeTimePicker> {
late String? _timeStr = widget.timeStr;
late String? _endTimeStr = widget.endTimeStr;
@override
Widget build(BuildContext context) {
if (widget.dateStr == null || widget.dateStr!.isEmpty) {
return const Divider(height: 1);
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTime(
context,
widget.includeTime,
widget.use24hFormat,
true,
widget.dateStr,
widget.rebuildOnTimeChanged ? _timeStr : widget.timeStr,
),
VSpace(8.0, color: Theme.of(context).colorScheme.surface),
_buildTime(
context,
widget.includeTime,
widget.use24hFormat,
false,
widget.endDateStr,
widget.rebuildOnTimeChanged ? _endTimeStr : widget.endTimeStr,
),
],
),
);
}
Widget _buildTime(
BuildContext context,
bool isIncludeTime,
bool use24hFormat,
bool isStartDay,
String? dateStr,
String? timeStr,
) {
if (dateStr == null) {
return const SizedBox.shrink();
}
final List<Widget> children = [];
if (!isIncludeTime) {
children.addAll([
const HSpace(12.0),
FlowyText(dateStr),
]);
} else {
children.addAll([
Expanded(child: FlowyText(dateStr, textAlign: TextAlign.center)),
Container(width: 1, height: 16, color: Colors.grey),
Expanded(child: FlowyText(timeStr ?? '', textAlign: TextAlign.center)),
]);
}
return GestureDetector(
onTap: !isIncludeTime
? null
: () async {
await showMobileBottomSheet(
context,
builder: (context) => ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 300),
child: CupertinoDatePicker(
showDayOfWeek: false,
mode: CupertinoDatePickerMode.time,
use24hFormat: use24hFormat,
onDateTimeChanged: (dateTime) {
final selectedTime = use24hFormat
? DateFormat('HH:mm').format(dateTime)
: DateFormat('hh:mm a').format(dateTime);
if (isStartDay) {
widget.onStartTimeChanged(selectedTime);
if (widget.rebuildOnTimeChanged && mounted) {
setState(() => _timeStr = selectedTime);
}
} else {
widget.onEndTimeChanged?.call(selectedTime);
if (widget.rebuildOnTimeChanged && mounted) {
setState(() => _endTimeStr = selectedTime);
}
}
},
),
),
);
},
child: Container(
constraints: const BoxConstraints(minHeight: 36),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: Theme.of(context).colorScheme.secondaryContainer,
border: Border.all(
color: Theme.of(context).colorScheme.outline,
width: 1,
),
),
child: Row(children: children),
),
);
}
}
class _EndDateSwitch extends StatelessWidget {
const _EndDateSwitch({
required this.isRange,
required this.onRangeChanged,
});
final bool isRange;
final Function(bool) onRangeChanged;
@override
Widget build(BuildContext context) {
return FlowyOptionTile.toggle(
text: LocaleKeys.grid_field_isRange.tr(),
isSelected: isRange,
onValueChanged: onRangeChanged,
);
}
}
class _IncludeTimeSwitch extends StatelessWidget {
const _IncludeTimeSwitch({
this.showTopBorder = true,
required this.includeTime,
required this.onIncludeTimeChanged,
});
final bool showTopBorder;
final bool includeTime;
final Function(bool) onIncludeTimeChanged;
@override
Widget build(BuildContext context) {
return FlowyOptionTile.toggle(
showTopBorder: showTopBorder,
text: LocaleKeys.grid_field_includeTime.tr(),
isSelected: includeTime,
onValueChanged: onIncludeTimeChanged,
);
}
}
class _ClearDateButton extends StatelessWidget {
const _ClearDateButton({required this.onClearDate});
final VoidCallback onClearDate;
@override
Widget build(BuildContext context) {
return FlowyOptionTile.text(
text: LocaleKeys.grid_field_clearDate.tr(),
onTap: onClearDate,
);
}
}

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:table_calendar/table_calendar.dart';
@ -20,6 +21,8 @@ class DatePicker extends StatefulWidget {
this.lastDay,
this.onDaySelected,
this.onRangeSelected,
this.onCalendarCreated,
this.onPageChanged,
});
final bool isRange;
@ -48,12 +51,16 @@ class DatePicker extends StatefulWidget {
DateTime focusedDay,
)? onRangeSelected;
final void Function(PageController pageController)? onCalendarCreated;
final void Function(DateTime focusedDay)? onPageChanged;
@override
State<DatePicker> createState() => _DatePickerState();
}
class _DatePickerState extends State<DatePicker> {
DateTime _focusedDay = DateTime.now();
late DateTime _focusedDay = widget.selectedDay ?? DateTime.now();
late CalendarFormat _calendarFormat = widget.calendarFormat;
@override
@ -64,57 +71,56 @@ class _DatePickerState extends State<DatePicker> {
shape: BoxShape.circle,
);
final calendarStyle = PlatformExtension.isMobile
? _CalendarStyle.mobile(
dowTextStyle: textStyle.copyWith(
color: Theme.of(context).hintColor,
fontSize: 14.0,
),
)
: _CalendarStyle.desktop(
textStyle: textStyle,
iconColor: Theme.of(context).iconTheme.color,
dowTextStyle: AFThemeExtension.of(context).caption,
selectedColor: Theme.of(context).colorScheme.primary,
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: TableCalendar(
firstDay: widget.firstDay ?? kFirstDay,
lastDay: widget.lastDay ?? kLastDay,
focusedDay: _focusedDay,
rowHeight: 26.0 + 7.0,
rowHeight: calendarStyle.rowHeight,
calendarFormat: _calendarFormat,
availableCalendarFormats: const {CalendarFormat.month: 'Month'},
daysOfWeekHeight: 17.0 + 8.0,
daysOfWeekHeight: calendarStyle.dowHeight,
rangeSelectionMode: widget.isRange
? RangeSelectionMode.enforced
: RangeSelectionMode.disabled,
rangeStartDay: widget.isRange ? widget.startDay : null,
rangeEndDay: widget.isRange ? widget.endDay : null,
headerStyle: HeaderStyle(
formatButtonVisible: false,
titleCentered: true,
titleTextStyle: textStyle,
leftChevronMargin: EdgeInsets.zero,
leftChevronPadding: EdgeInsets.zero,
leftChevronIcon: FlowySvg(
FlowySvgs.arrow_left_s,
color: Theme.of(context).iconTheme.color,
),
rightChevronPadding: EdgeInsets.zero,
rightChevronMargin: EdgeInsets.zero,
rightChevronIcon: FlowySvg(
FlowySvgs.arrow_right_s,
color: Theme.of(context).iconTheme.color,
),
headerMargin: EdgeInsets.zero,
headerPadding: const EdgeInsets.only(bottom: 8.0),
),
availableGestures: calendarStyle.availableGestures,
availableCalendarFormats: const {CalendarFormat.month: 'Month'},
onCalendarCreated: widget.onCalendarCreated,
headerVisible: calendarStyle.headerVisible,
headerStyle: calendarStyle.headerStyle,
calendarStyle: CalendarStyle(
cellMargin: const EdgeInsets.all(3.5),
defaultDecoration: boxDecoration,
selectedDecoration: boxDecoration.copyWith(
color: Theme.of(context).colorScheme.primary,
color: calendarStyle.selectedColor,
),
todayDecoration: boxDecoration.copyWith(
color: Colors.transparent,
border: Border.all(color: Theme.of(context).colorScheme.primary),
border: Border.all(color: calendarStyle.selectedColor),
),
weekendDecoration: boxDecoration,
outsideDecoration: boxDecoration,
rangeStartDecoration: boxDecoration.copyWith(
color: Theme.of(context).colorScheme.primary,
color: calendarStyle.selectedColor,
),
rangeEndDecoration: boxDecoration.copyWith(
color: Theme.of(context).colorScheme.primary,
color: calendarStyle.selectedColor,
),
defaultTextStyle: textStyle,
weekendTextStyle: textStyle,
@ -140,10 +146,7 @@ class _DatePickerState extends State<DatePicker> {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Center(
child: Text(
label,
style: AFThemeExtension.of(context).caption,
),
child: Text(label, style: calendarStyle.dowTextStyle),
),
);
},
@ -152,10 +155,71 @@ class _DatePickerState extends State<DatePicker> {
widget.isRange ? false : isSameDay(widget.selectedDay, day),
onFormatChanged: (calendarFormat) =>
setState(() => _calendarFormat = calendarFormat),
onPageChanged: (focusedDay) => setState(() => _focusedDay = focusedDay),
onPageChanged: (focusedDay) {
widget.onPageChanged?.call(focusedDay);
setState(() => _focusedDay = focusedDay);
},
onDaySelected: widget.onDaySelected,
onRangeSelected: widget.onRangeSelected,
),
);
}
}
class _CalendarStyle {
_CalendarStyle({
required this.rowHeight,
required this.dowHeight,
required this.headerVisible,
required this.headerStyle,
required this.dowTextStyle,
required this.selectedColor,
required this.availableGestures,
});
final double rowHeight;
final double dowHeight;
final bool headerVisible;
final HeaderStyle headerStyle;
final TextStyle dowTextStyle;
final Color selectedColor;
final AvailableGestures availableGestures;
_CalendarStyle.mobile({
required this.dowTextStyle,
}) : rowHeight = 48,
dowHeight = 48,
headerVisible = false,
headerStyle = const HeaderStyle(),
selectedColor = const Color(0xFF00BCF0),
availableGestures = AvailableGestures.horizontalSwipe;
_CalendarStyle.desktop({
required TextStyle textStyle,
required this.selectedColor,
required this.dowTextStyle,
Color? iconColor,
}) : rowHeight = 33,
dowHeight = 35,
headerVisible = true,
headerStyle = HeaderStyle(
formatButtonVisible: false,
titleCentered: true,
titleTextStyle: textStyle,
leftChevronMargin: EdgeInsets.zero,
leftChevronPadding: EdgeInsets.zero,
leftChevronIcon: FlowySvg(
FlowySvgs.arrow_left_s,
color: iconColor,
),
rightChevronPadding: EdgeInsets.zero,
rightChevronMargin: EdgeInsets.zero,
rightChevronIcon: FlowySvg(
FlowySvgs.arrow_right_s,
color: iconColor,
),
headerMargin: EdgeInsets.zero,
headerPadding: const EdgeInsets.only(bottom: 8.0),
),
availableGestures = AvailableGestures.all;
}

View File

@ -1,13 +1,13 @@
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/appflowy_date_picker.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/utils/date_time_format_ext.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/utils/user_time_format_ext.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pbenum.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart';
import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/style_widget/decoration.dart';
/// Provides arguemnts for [AppFlowyDatePicker] when showing
@ -21,15 +21,20 @@ class DatePickerOptions {
this.firstDay,
this.lastDay,
this.timeStr,
this.endTimeStr,
this.includeTime = false,
this.isRange = false,
this.enableRanges = true,
this.dateFormat = UserDateFormatPB.Friendly,
this.timeFormat = UserTimeFormatPB.TwentyFourHour,
this.selectedReminderOption,
this.onDaySelected,
this.onIncludeTimeChanged,
required this.onIncludeTimeChanged,
this.onStartTimeChanged,
this.onEndTimeChanged,
this.onRangeSelected,
this.onIsRangeChanged,
this.onReminderSelected,
}) : focusedDay = focusedDay ?? DateTime.now();
final DateTime focusedDay;
@ -38,33 +43,35 @@ class DatePickerOptions {
final DateTime? firstDay;
final DateTime? lastDay;
final String? timeStr;
final String? endTimeStr;
final bool includeTime;
final bool isRange;
final bool enableRanges;
final UserDateFormatPB dateFormat;
final UserTimeFormatPB timeFormat;
final ReminderOption? selectedReminderOption;
final DaySelectedCallback? onDaySelected;
final IncludeTimeChangedCallback? onIncludeTimeChanged;
final IncludeTimeChangedCallback onIncludeTimeChanged;
final TimeChangedCallback? onStartTimeChanged;
final TimeChangedCallback? onEndTimeChanged;
final RangeSelectedCallback? onRangeSelected;
final Function(bool)? onIsRangeChanged;
final OnReminderSelected? onReminderSelected;
}
abstract class DatePickerService {
void show(Offset offset);
void show(Offset offset, {required DatePickerOptions options});
void dismiss();
}
const double _datePickerWidth = 260;
const double _datePickerHeight = 355;
const double _includeTimeHeight = 40;
const double _datePickerHeight = 370;
const double _includeTimeHeight = 32;
const double _ySpacing = 15;
class DatePickerMenu extends DatePickerService {
DatePickerMenu({
required this.context,
required this.editorState,
});
DatePickerMenu({required this.context, required this.editorState});
final BuildContext context;
final EditorState editorState;
@ -78,16 +85,10 @@ class DatePickerMenu extends DatePickerService {
}
@override
void show(
Offset offset, {
DatePickerOptions? options,
}) =>
void show(Offset offset, {required DatePickerOptions options}) =>
_show(offset, options: options);
void _show(
Offset offset, {
DatePickerOptions? options,
}) {
void _show(Offset offset, {required DatePickerOptions options}) {
dismiss();
final editorSize = editorState.renderBox!.size;
@ -112,37 +113,35 @@ class DatePickerMenu extends DatePickerService {
}
_menuEntry = OverlayEntry(
builder: (context) {
return Material(
type: MaterialType.transparency,
child: SizedBox(
height: editorSize.height,
width: editorSize.width,
child: RawKeyboardListener(
focusNode: FocusNode()..requestFocus(),
onKey: (event) {
if (event is RawKeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.escape) {
dismiss();
}
},
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: dismiss,
child: Stack(
children: [
_AnimatedDatePicker(
offset: Offset(offsetX, offsetY),
showBelow: showBelow,
options: options,
),
],
),
builder: (_) => Material(
type: MaterialType.transparency,
child: SizedBox(
height: editorSize.height,
width: editorSize.width,
child: RawKeyboardListener(
focusNode: FocusNode()..requestFocus(),
onKey: (event) {
if (event is RawKeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.escape) {
dismiss();
}
},
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: dismiss,
child: Stack(
children: [
_AnimatedDatePicker(
offset: Offset(offsetX, offsetY),
showBelow: showBelow,
options: options,
),
],
),
),
),
);
},
),
),
);
Overlay.of(context).insert(_menuEntry!);
@ -153,28 +152,28 @@ class _AnimatedDatePicker extends StatefulWidget {
const _AnimatedDatePicker({
required this.offset,
required this.showBelow,
this.options,
required this.options,
});
final Offset offset;
final bool showBelow;
final DatePickerOptions? options;
final DatePickerOptions options;
@override
State<_AnimatedDatePicker> createState() => _AnimatedDatePickerState();
}
class _AnimatedDatePickerState extends State<_AnimatedDatePicker> {
late bool _includeTime = widget.options?.includeTime ?? false;
late bool _includeTime = widget.options.includeTime;
@override
Widget build(BuildContext context) {
double dy = widget.offset.dy;
if (!widget.showBelow && _includeTime) {
dy = dy - _includeTimeHeight;
dy -= _includeTimeHeight;
}
dy = dy + (widget.showBelow ? _ySpacing : -_ySpacing);
dy += (widget.showBelow ? _ySpacing : -_ySpacing);
return AnimatedPositioned(
duration: const Duration(milliseconds: 200),
@ -185,30 +184,31 @@ class _AnimatedDatePickerState extends State<_AnimatedDatePicker> {
Theme.of(context).cardColor,
Theme.of(context).colorScheme.shadow,
),
constraints: BoxConstraints.loose(
const Size(_datePickerWidth, 465),
),
constraints: BoxConstraints.loose(const Size(_datePickerWidth, 465)),
child: AppFlowyDatePicker(
popoverMutex: widget.options?.popoverMutex,
includeTime: _includeTime,
enableRanges: widget.options?.enableRanges ?? false,
isRange: widget.options?.isRange ?? false,
onIsRangeChanged: (_) {},
timeStr: widget.options?.timeStr,
dateFormat:
widget.options?.dateFormat.simplified ?? DateFormatPB.Friendly,
timeFormat: widget.options?.timeFormat.simplified ??
TimeFormatPB.TwentyFourHour,
selectedDay: widget.options?.selectedDay,
onIncludeTimeChanged: (includeTime) {
widget.options?.onIncludeTimeChanged?.call(!includeTime);
widget.options.onIncludeTimeChanged.call(!includeTime);
setState(() => _includeTime = !includeTime);
},
onStartTimeSubmitted: widget.options?.onStartTimeChanged,
onDaySelected: widget.options?.onDaySelected,
focusedDay: widget.options?.focusedDay ?? DateTime.now(),
firstDay: widget.options?.firstDay,
lastDay: widget.options?.lastDay,
enableRanges: widget.options.enableRanges,
isRange: widget.options.isRange,
onIsRangeChanged: widget.options.onIsRangeChanged,
dateFormat: widget.options.dateFormat.simplified,
timeFormat: widget.options.timeFormat.simplified,
selectedDay: widget.options.selectedDay,
focusedDay: widget.options.focusedDay,
firstDay: widget.options.firstDay,
lastDay: widget.options.lastDay,
timeStr: widget.options.timeStr,
endTimeStr: widget.options.endTimeStr,
popoverMutex: widget.options.popoverMutex,
selectedReminderOption:
widget.options.selectedReminderOption ?? ReminderOption.none,
onStartTimeSubmitted: widget.options.onStartTimeChanged,
onDaySelected: widget.options.onDaySelected,
onRangeSelected: widget.options.onRangeSelected,
onReminderSelected: widget.options.onReminderSelected,
),
),
);

View File

@ -1,10 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/header/type_option/date/date_time_format.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/utils/layout.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pbenum.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/widgets.dart';
import '../utils/layout.dart';
class DateTimeSetting extends StatefulWidget {
const DateTimeSetting({
@ -35,7 +36,7 @@ class _DateTimeSettingState extends State<DateTimeSetting> {
mutex: timeSettingPopoverMutex,
triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click,
offset: const Offset(8, 0),
popupBuilder: (BuildContext context) => DateFormatList(
popupBuilder: (_) => DateFormatList(
selectedFormat: widget.dateFormat,
onSelected: _onDateFormatChanged,
),
@ -48,7 +49,7 @@ class _DateTimeSettingState extends State<DateTimeSetting> {
mutex: timeSettingPopoverMutex,
triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click,
offset: const Offset(8, 0),
popupBuilder: (BuildContext context) => TimeFormatList(
popupBuilder: (_) => TimeFormatList(
selectedFormat: widget.timeFormat,
onSelected: _onTimeFormatChanged,
),

View File

@ -32,7 +32,7 @@ class EndTextField extends StatelessWidget {
child: TimeTextField(
isEndTime: true,
timeFormat: timeFormat,
timeStr: endTimeStr,
endTimeStr: endTimeStr,
popoverMutex: popoverMutex,
onSubmitted: onSubmitted,
),

View File

@ -0,0 +1,119 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/appflowy_date_picker.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_picker.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
class MobileDatePicker extends StatefulWidget {
const MobileDatePicker({
super.key,
this.selectedDay,
required this.isRange,
this.onDaySelected,
this.rebuildOnDaySelected = false,
this.onRangeSelected,
this.firstDay,
this.lastDay,
this.startDay,
this.endDay,
});
final DateTime? selectedDay;
final bool isRange;
final DaySelectedCallback? onDaySelected;
final bool rebuildOnDaySelected;
final RangeSelectedCallback? onRangeSelected;
final DateTime? firstDay;
final DateTime? lastDay;
final DateTime? startDay;
final DateTime? endDay;
@override
State<MobileDatePicker> createState() => _MobileDatePickerState();
}
class _MobileDatePickerState extends State<MobileDatePicker> {
PageController? _pageController;
late DateTime _focusedDay = widget.selectedDay ?? DateTime.now();
late DateTime? _selectedDay = widget.selectedDay;
@override
Widget build(BuildContext context) {
return Column(
children: [
const VSpace(8.0),
_buildHeader(context),
const VSpace(8.0),
_buildCalendar(context),
const VSpace(16.0),
],
);
}
Widget _buildCalendar(BuildContext context) {
return DatePicker(
isRange: widget.isRange,
onDaySelected: (selectedDay, focusedDay) {
widget.onDaySelected?.call(selectedDay, focusedDay);
if (widget.rebuildOnDaySelected) {
setState(() => _selectedDay = selectedDay);
}
},
onRangeSelected: widget.onRangeSelected,
selectedDay:
widget.rebuildOnDaySelected ? _selectedDay : widget.selectedDay,
firstDay: widget.firstDay,
lastDay: widget.lastDay,
startDay: widget.startDay,
endDay: widget.endDay,
onCalendarCreated: (pageController) => _pageController = pageController,
onPageChanged: (focusedDay) => setState(() => _focusedDay = focusedDay),
);
}
Widget _buildHeader(BuildContext context) {
return Row(
children: [
const HSpace(16.0),
FlowyText(
DateFormat.yMMMM().format(_focusedDay),
),
const Spacer(),
FlowyButton(
useIntrinsicWidth: true,
text: FlowySvg(
FlowySvgs.arrow_left_s,
color: Theme.of(context).iconTheme.color,
size: const Size.square(24.0),
),
onTap: () => _pageController?.previousPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
),
),
const HSpace(24.0),
FlowyButton(
useIntrinsicWidth: true,
text: FlowySvg(
FlowySvgs.arrow_right_s,
color: Theme.of(context).iconTheme.color,
size: const Size.square(24.0),
),
onTap: () => _pageController?.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
),
),
const HSpace(8.0),
],
);
}
}

View File

@ -0,0 +1,45 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
const _iconWidth = 30.0;
const _height = 44.0;
class MobileDateHeader extends StatelessWidget {
const MobileDateHeader({super.key});
@override
Widget build(BuildContext context) {
return Container(
color: Theme.of(context).colorScheme.surface,
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Stack(
children: [
Align(
alignment: Alignment.centerLeft,
child: FlowyIconButton(
icon: const FlowySvg(
FlowySvgs.close_s,
size: Size.square(_height),
),
width: _iconWidth,
iconPadding: EdgeInsets.zero,
onPressed: () => context.pop(),
),
),
Align(
alignment: Alignment.center,
child: FlowyText.medium(
LocaleKeys.grid_field_dateFieldName.tr(),
fontSize: 16,
),
),
].map((e) => SizedBox(height: _height, child: e)).toList(),
),
);
}
}

View File

@ -0,0 +1,143 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/utils/layout.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
typedef OnReminderSelected = void Function(ReminderOption option);
class ReminderSelector extends StatelessWidget {
const ReminderSelector({
super.key,
required this.mutex,
required this.selectedOption,
required this.onOptionSelected,
});
final PopoverMutex? mutex;
final ReminderOption selectedOption;
final OnReminderSelected? onOptionSelected;
@override
Widget build(BuildContext context) {
final options = ReminderOption.values.toList();
if (selectedOption != ReminderOption.custom) {
options.remove(ReminderOption.custom);
}
final optionWidgets = options
.map(
(o) => SizedBox(
height: DatePickerSize.itemHeight,
child: FlowyButton(
text: FlowyText.medium(o.label),
rightIcon: o == selectedOption
? const FlowySvg(FlowySvgs.check_s)
: null,
onTap: () {
if (o != selectedOption) {
onOptionSelected?.call(o);
mutex?.close();
}
},
),
),
)
.toList();
return AppFlowyPopover(
mutex: mutex,
triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click,
offset: const Offset(8, -155),
margin: EdgeInsets.zero,
constraints: BoxConstraints.loose(const Size(150, 310)),
popupBuilder: (_) => Padding(
padding: const EdgeInsets.all(6.0),
child: ListView.separated(
itemCount: options.length,
separatorBuilder: (_, __) => VSpace(DatePickerSize.seperatorHeight),
itemBuilder: (_, index) => optionWidgets[index],
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: SizedBox(
height: DatePickerSize.itemHeight,
child: FlowyButton(
text: FlowyText.medium(LocaleKeys.datePicker_reminderLabel.tr()),
rightIcon: Row(
children: [
FlowyText.regular(selectedOption.label),
const FlowySvg(FlowySvgs.more_s),
],
),
),
),
),
);
}
}
enum ReminderOption {
none(time: Duration()),
atTimeOfEvent(time: Duration()),
fiveMinsBefore(time: Duration(minutes: 5)),
tenMinsBefore(time: Duration(minutes: 10)),
fifteenMinsBefore(time: Duration(minutes: 15)),
thirtyMinsBefore(time: Duration(minutes: 30)),
oneHourBefore(time: Duration(hours: 1)),
twoHoursBefore(time: Duration(hours: 2)),
oneDayBefore(time: Duration(days: 1)),
twoDaysBefore(time: Duration(days: 2)),
custom(time: Duration());
const ReminderOption({required this.time});
final Duration time;
String get label => switch (this) {
ReminderOption.none => LocaleKeys.datePicker_reminderOptions_none.tr(),
ReminderOption.atTimeOfEvent =>
LocaleKeys.datePicker_reminderOptions_atTimeOfEvent.tr(),
ReminderOption.fiveMinsBefore =>
LocaleKeys.datePicker_reminderOptions_fiveMinsBefore.tr(),
ReminderOption.tenMinsBefore =>
LocaleKeys.datePicker_reminderOptions_tenMinsBefore.tr(),
ReminderOption.fifteenMinsBefore =>
LocaleKeys.datePicker_reminderOptions_fifteenMinsBefore.tr(),
ReminderOption.thirtyMinsBefore =>
LocaleKeys.datePicker_reminderOptions_thirtyMinsBefore.tr(),
ReminderOption.oneHourBefore =>
LocaleKeys.datePicker_reminderOptions_oneHourBefore.tr(),
ReminderOption.twoHoursBefore =>
LocaleKeys.datePicker_reminderOptions_twoHoursBefore.tr(),
ReminderOption.oneDayBefore =>
LocaleKeys.datePicker_reminderOptions_oneDayBefore.tr(),
ReminderOption.twoDaysBefore =>
LocaleKeys.datePicker_reminderOptions_twoDaysBefore.tr(),
ReminderOption.custom =>
LocaleKeys.datePicker_reminderOptions_custom.tr(),
};
static ReminderOption fromDateDifference(
DateTime eventDate,
DateTime reminderDate,
) =>
fromMinutes(eventDate.difference(reminderDate).inMinutes);
static ReminderOption fromMinutes(int minutes) => switch (minutes) {
0 => ReminderOption.atTimeOfEvent,
5 => ReminderOption.fiveMinsBefore,
10 => ReminderOption.tenMinsBefore,
15 => ReminderOption.fifteenMinsBefore,
30 => ReminderOption.thirtyMinsBefore,
60 => ReminderOption.oneHourBefore,
120 => ReminderOption.twoHoursBefore,
1440 => ReminderOption.oneDayBefore,
2880 => ReminderOption.twoDaysBefore,
_ => ReminderOption.custom,
};
}

View File

@ -45,11 +45,8 @@ class _TimeTextFieldState extends State<TimeTextField> {
void initState() {
super.initState();
if (widget.isEndTime) {
_textController.text = widget.endTimeStr ?? "";
} else {
_textController.text = widget.timeStr ?? "";
}
_textController.text =
(widget.isEndTime ? widget.endTimeStr : widget.timeStr) ?? "";
if (!widget.isEndTime && widget.timeStr != null) {
text = widget.timeStr!;
@ -89,6 +86,7 @@ class _TimeTextFieldState extends State<TimeTextField> {
child: FlowyTextField(
text: text,
focusNode: _focusNode,
autoFocus: false,
controller: _textController,
submitOnLeave: true,
hintText: widget.timeHintText,

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/style_widget/decoration.dart';
import 'package:flutter/material.dart';
class AppFlowyPopover extends StatelessWidget {
final Widget child;
@ -58,12 +59,11 @@ class AppFlowyPopover extends StatelessWidget {
offset: offset,
clickHandler: clickHandler,
popupBuilder: (context) {
final child = popupBuilder(context);
return _PopoverContainer(
constraints: constraints,
margin: margin,
decoration: decoration,
child: child,
child: popupBuilder(context),
);
},
child: child,
@ -72,18 +72,17 @@ class AppFlowyPopover extends StatelessWidget {
}
class _PopoverContainer extends StatelessWidget {
final Widget child;
final BoxConstraints constraints;
final EdgeInsets margin;
final Decoration? decoration;
const _PopoverContainer({
required this.child,
required this.margin,
required this.constraints,
required this.decoration,
Key? key,
}) : super(key: key);
});
final Widget child;
final BoxConstraints constraints;
final EdgeInsets margin;
final Decoration? decoration;
@override
Widget build(BuildContext context) {

View File

@ -581,7 +581,8 @@
"newProperty": "New property",
"deleteFieldPromptMessage": "Are you sure? This property will be deleted",
"newColumn": "New Column",
"format": "Format"
"format": "Format",
"reminderOnDateTooltip": "This cell has a scheduled reminder"
},
"rowPage": {
"newField": "Add a new field",
@ -1025,7 +1026,21 @@
"includeTime": "Include time",
"isRange": "End date",
"timeFormat": "Time format",
"clearDate": "Clear date"
"clearDate": "Clear date",
"reminderLabel": "Reminder",
"reminderOptions": {
"none": "None",
"atTimeOfEvent": "Time of event",
"fiveMinsBefore": "5 mins before",
"tenMinsBefore": "10 mins before",
"fifteenMinsBefore": "15 mins before",
"thirtyMinsBefore": "30 mins before",
"oneHourBefore": "1 hour before",
"twoHoursBefore": "2 hours before",
"oneDayBefore": "1 day before",
"twoDaysBefore": "2 days before",
"custom": "Custom"
}
},
"relativeDates": {
"yesterday": "Yesterday",
@ -1089,6 +1104,7 @@
"highlight": "Highlight",
"color": "Color",
"image": "Image",
"date": "Date",
"italic": "Italic",
"link": "Link",
"numberedList": "Numbered List",

View File

@ -34,7 +34,7 @@ resolver = "2"
[workspace.dependencies]
lib-dispatch = { workspace = true, path = "lib-dispatch" }
lib-log = { workspace = true, path = "lib-log" }
lib-infra= { workspace = true, path = "lib-infra" }
lib-infra = { workspace = true, path = "lib-infra" }
flowy-ast = { workspace = true, path = "build-tool/flowy-ast" }
flowy-codegen = { workspace = true, path = "build-tool/flowy-codegen" }
flowy-derive = { workspace = true, path = "build-tool/flowy-derive" }
@ -73,7 +73,7 @@ futures = "0.3.29"
tokio = "1.34.0"
tokio-stream = "0.1.14"
async-trait = "0.1.74"
chrono = { version = "0.4.31", default-features = false, features = ["clock"] }
chrono = { version = "0.4.31", default-features = false, features = ["clock"] }
lru = "0.12.0"
[profile.dev]

View File

@ -32,6 +32,9 @@ pub struct DateCellDataPB {
#[pb(index = 8)]
pub is_range: bool,
#[pb(index = 9)]
pub reminder_id: String,
}
#[derive(Clone, Debug, Default, ProtoBuf)]
@ -59,6 +62,9 @@ pub struct DateChangesetPB {
#[pb(index = 8, one_of)]
pub clear_flag: Option<bool>,
#[pb(index = 9, one_of)]
pub reminder_id: Option<String>,
}
// Date
@ -94,7 +100,7 @@ impl From<DateTypeOptionPB> for DateTypeOption {
}
}
#[derive(Clone, Debug, Copy, EnumIter, ProtoBuf_Enum, Default)]
#[derive(Clone, Debug, Copy, ProtoBuf_Enum, Default)]
pub enum DateFormatPB {
Local = 0,
US = 1,

View File

@ -586,7 +586,9 @@ pub(crate) async fn update_date_cell_handler(
include_time: data.include_time,
is_range: data.is_range,
clear_flag: data.clear_flag,
reminder_id: data.reminder_id,
};
let database_editor = manager.get_database_with_view_id(&cell_id.view_id).await?;
database_editor
.update_cell_with_changeset(

View File

@ -79,6 +79,8 @@ impl TypeOptionCellDataSerde for DateTypeOption {
let end_timestamp = cell_data.end_timestamp;
let (end_date, end_time) = self.formatted_date_time_from_timestamp(&end_timestamp);
let reminder_id = cell_data.reminder_id;
DateCellDataPB {
date,
time,
@ -88,6 +90,7 @@ impl TypeOptionCellDataSerde for DateTypeOption {
end_timestamp: end_timestamp.unwrap_or_default(),
include_time,
is_range,
reminder_id,
}
}
@ -257,18 +260,20 @@ impl CellDataChangeset for DateTypeOption {
cell: Option<Cell>,
) -> FlowyResult<(Cell, <Self as TypeOption>::CellData)> {
// old date cell data
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.end_timestamp,
cell_data.include_time,
cell_data.is_range,
)
},
None => (None, None, false, false),
};
let (previous_timestamp, previous_end_timestamp, include_time, is_range, reminder_id) =
match cell {
Some(cell) => {
let cell_data = DateCellData::from(&cell);
(
cell_data.timestamp,
cell_data.end_timestamp,
cell_data.include_time,
cell_data.is_range,
cell_data.reminder_id,
)
},
None => (None, None, false, false, String::new()),
};
if changeset.clear_flag == Some(true) {
let cell_data = DateCellData {
@ -276,6 +281,7 @@ impl CellDataChangeset for DateTypeOption {
end_timestamp: None,
include_time,
is_range,
reminder_id: String::new(),
};
return Ok((Cell::from(&cell_data), cell_data));
@ -284,6 +290,7 @@ impl CellDataChangeset for DateTypeOption {
// 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);
let reminder_id = changeset.reminder_id.unwrap_or(reminder_id);
// Calculate the timestamp in the time zone specified in type option. If
// a new timestamp is included in the changeset without an accompanying
@ -323,6 +330,7 @@ impl CellDataChangeset for DateTypeOption {
end_timestamp,
include_time,
is_range,
reminder_id,
};
Ok((Cell::from(&cell_data), cell_data))

View File

@ -25,6 +25,7 @@ pub struct DateCellChangeset {
pub include_time: Option<bool>,
pub is_range: Option<bool>,
pub clear_flag: Option<bool>,
pub reminder_id: Option<String>,
}
impl FromCellChangeset for DateCellChangeset {
@ -50,15 +51,17 @@ pub struct DateCellData {
pub include_time: bool,
#[serde(default)]
pub is_range: bool,
pub reminder_id: String,
}
impl DateCellData {
pub fn new(timestamp: i64, include_time: bool, is_range: bool) -> Self {
pub fn new(timestamp: i64, include_time: bool, is_range: bool, reminder_id: String) -> Self {
Self {
timestamp: Some(timestamp),
end_timestamp: None,
include_time,
is_range,
reminder_id,
}
}
}
@ -79,11 +82,14 @@ impl From<&Cell> for DateCellData {
.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();
let reminder_id = cell.get_str_value("reminder_id").unwrap_or_default();
Self {
timestamp,
end_timestamp,
include_time,
is_range,
reminder_id,
}
}
}
@ -95,6 +101,7 @@ impl From<&DateCellDataPB> for DateCellData {
end_timestamp: Some(data.end_timestamp),
include_time: data.include_time,
is_range: data.is_range,
reminder_id: data.reminder_id.to_owned(),
}
}
}
@ -116,6 +123,7 @@ impl From<&DateCellData> for Cell {
.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)
.insert_str_value("reminder_id", cell_data.reminder_id.to_owned())
.build()
}
}
@ -145,6 +153,7 @@ impl<'de> serde::Deserialize<'de> for DateCellData {
end_timestamp: None,
include_time: false,
is_range: false,
reminder_id: String::new(),
})
}
@ -163,6 +172,7 @@ impl<'de> serde::Deserialize<'de> for DateCellData {
let mut end_timestamp: Option<i64> = None;
let mut include_time: Option<bool> = None;
let mut is_range: Option<bool> = None;
let mut reminder_id: Option<String> = None;
while let Some(key) = map.next_key()? {
match key {
@ -178,18 +188,23 @@ impl<'de> serde::Deserialize<'de> for DateCellData {
"is_range" => {
is_range = map.next_value()?;
},
"reminder_id" => {
reminder_id = map.next_value()?;
},
_ => {},
}
}
let include_time = include_time.unwrap_or_default();
let is_range = is_range.unwrap_or_default();
let reminder_id = reminder_id.unwrap_or_default();
Ok(DateCellData {
timestamp,
end_timestamp,
include_time,
is_range,
reminder_id,
})
}
}

View File

@ -29,6 +29,7 @@ mod tests {
end_timestamp: None,
include_time: true,
is_range: false,
reminder_id: String::new(),
};
assert_eq!(
@ -41,6 +42,7 @@ mod tests {
end_timestamp: Some(1648533809),
include_time: true,
is_range: false,
reminder_id: String::new(),
};
assert_eq!(
@ -53,6 +55,7 @@ mod tests {
end_timestamp: Some(1648533809),
include_time: true,
is_range: true,
reminder_id: String::new(),
};
assert_eq!(

View File

@ -115,6 +115,7 @@ pub fn make_date_cell_string(timestamp: i64) -> String {
include_time: Some(false),
is_range: Some(false),
clear_flag: None,
reminder_id: Some(String::new()),
})
.unwrap()
}