feat: mobile card detail screen (#3935)

* feat: add CardDetailScreen and CardPropertyEditScreen

- add basic UI layout for these two screens
- add MobileTextCell as the GridCellWidget adapts to mobile

* feat: add MobileNumberCell and MobileTimestampCell

* feat: Add MobileDateCell and MobileCheckboxCell

- Add MobileDateCellEditScreen
- Add dateStr and endDateStr in DateCellCalendarState

* feat:  add MobileFieldTypeOptionEditor

- Add placeholder for different TypeOptionMobileWidgetBuilders
- Add _MobileSwitchFieldButton

* feat: add property delete feature in CardPropertyEditScreen

* fix: fix VisibilitySwitch didn't update

* feat: add MobileCreateRowFieldScreen

- Refactor MobileFieldEditor to used in CardPropertyEditScreen and MobileCreateRowFieldScreen
- Add MobileCreateRowFieldScreen

* chore: localization and improve spacing

* feat: add TimestampTypeOptionMobileWidget

- Refactor  TimeFormatListTile to be used in TimestampTypeOptionMobileWidget and _DateCellEditBody
- Add IncludeTimeSwitch to be used in TimestampTypeOptionMobileWidget and _DateCellEditBody

* feat: add checkbox and DateTypeOptionMobileWidget

* chore: improve UI

* chore: improve spacing

* fix: fix end time shown issue

* fix: minor issues

* fix: flutter analyze

* chore: delete unused TextEditingController

* fix: prevent group field from deleting

* feat: add NumberTypeOptionMobileWidget

* chore: rename and clean code

* chore: clean code

* chore: extract class

* chore: refactor reorder cells

* chore: improve super.key

* chore: refactor MobileFieldTypeList

* chore: reorginize code

* chore: remove unused import file

* chore: clean code

* chore: add commas due to flutter upgrade

* feat: add MobileURLCell

* fix: close keyboard when user tap outside of textfield

* chore: update go_router version

* fix: add missing GridCellStyle

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
This commit is contained in:
Yijing Huang 2023-11-20 21:56:21 -08:00 committed by GitHub
parent b9ecc7ceb6
commit acc951c5eb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
49 changed files with 3213 additions and 66 deletions

View File

@ -0,0 +1 @@
export 'card_detail/mobile_card_detail_screen.dart';

View File

@ -0,0 +1,186 @@
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/bottom_sheet/bottom_sheet_action_widget.dart';
import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_bottom_sheet.dart';
import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart';
import 'package:appflowy/plugins/database_view/application/field/field_info.dart';
import 'package:appflowy/plugins/database_view/application/row/row_banner_bloc.dart';
import 'package:appflowy/plugins/database_view/application/row/row_controller.dart';
import 'package:appflowy/plugins/database_view/grid/application/row/row_action_sheet_bloc.dart';
import 'package:appflowy/plugins/database_view/grid/application/row/row_detail_bloc.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cell_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/cells.dart';
import 'package:appflowy/mobile/presentation/database/card/card_detail/widgets/mobile_row_property_list.dart';
import 'package:appflowy/plugins/database_view/widgets/row/row_document.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:go_router/go_router.dart';
class MobileCardDetailScreen extends StatefulWidget {
const MobileCardDetailScreen({
super.key,
required this.rowController,
});
static const routeName = '/MobileCardDetailScreen';
static const argRowController = 'rowController';
static const argCellBuilder = 'cellBuilder';
final RowController rowController;
@override
State<MobileCardDetailScreen> createState() => _MobileCardDetailScreenState();
}
class _MobileCardDetailScreenState extends State<MobileCardDetailScreen> {
late final ScrollController _scrollController;
late final GridCellBuilder _cellBuilder;
@override
void initState() {
super.initState();
_scrollController = ScrollController();
_cellBuilder = GridCellBuilder(
cellCache: widget.rowController.cellCache,
);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// TODO(yijing): fix context issue when navigating in bottom navigation bar
return BlocProvider(
create: (context) => RowDetailBloc(rowController: widget.rowController)
..add(const RowDetailEvent.initial()),
child: Scaffold(
// appbar with duplicate and delete card features
appBar: AppBar(
title: Text(LocaleKeys.board_cardDetail.tr()),
actions: [
BlocProvider<RowActionSheetBloc>(
create: (context) => RowActionSheetBloc(
viewId: widget.rowController.viewId,
rowId: widget.rowController.rowId,
groupId: widget.rowController.groupId,
),
child: Builder(
builder: (context) {
return IconButton(
onPressed: () {
showFlowyMobileBottomSheet(
context,
title: LocaleKeys.board_cardActions.tr(),
builder: (_) => Row(
children: [
Expanded(
child: BottomSheetActionWidget(
svg: FlowySvgs.copy_s,
text: LocaleKeys.button_duplicate.tr(),
onTap: () {
context.read<RowActionSheetBloc>().add(
const RowActionSheetEvent
.duplicateRow(),
);
context
..pop()
..pop();
Fluttertoast.showToast(
msg: LocaleKeys.board_cardDuplicated.tr(),
gravity: ToastGravity.CENTER,
);
},
),
),
const HSpace(8),
Expanded(
child: BottomSheetActionWidget(
svg: FlowySvgs.m_delete_m,
text: LocaleKeys.button_delete.tr(),
onTap: () {
context.read<RowActionSheetBloc>().add(
const RowActionSheetEvent.deleteRow(),
);
context
..pop()
..pop();
Fluttertoast.showToast(
msg: LocaleKeys.board_cardDeleted.tr(),
gravity: ToastGravity.CENTER,
);
},
),
),
],
),
);
},
icon: const Icon(Icons.more_horiz),
);
},
),
),
],
),
body: Padding(
padding: const EdgeInsets.all(16),
child: ListView(
controller: _scrollController,
children: [
BlocProvider<RowBannerBloc>(
create: (context) => RowBannerBloc(
viewId: widget.rowController.viewId,
rowMeta: widget.rowController.rowMeta,
)..add(const RowBannerEvent.initial()),
child: BlocBuilder<RowBannerBloc, RowBannerState>(
builder: (context, state) {
// primaryField is the property cannot be deleted like card title
if (state.primaryField != null) {
final mobileStyle = GridTextCellStyle(
placeholder: LocaleKeys.grid_row_titlePlaceholder.tr(),
textStyle: Theme.of(context).textTheme.titleLarge,
);
// get the cell context for the card title
final cellContext = DatabaseCellContext(
viewId: widget.rowController.viewId,
rowMeta: widget.rowController.rowMeta,
fieldInfo: FieldInfo.initial(state.primaryField!),
);
return _cellBuilder.build(
cellContext,
style: mobileStyle,
);
}
return const SizedBox.shrink();
},
),
),
const VSpace(8),
// Card Properties
MobileRowPropertyList(
cellBuilder: _cellBuilder,
viewId: widget.rowController.viewId,
),
const Divider(),
const VSpace(16),
RowDocument(
viewId: widget.rowController.viewId,
rowId: widget.rowController.rowId,
scrollController: _scrollController,
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,59 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/database/card/card_property_edit/mobile_field_editor.dart';
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class MobileCreateRowFieldScreen extends StatefulWidget {
static const routeName = '/MobileCreateRowFieldScreen';
static const argViewId = 'viewId';
static const argTypeOption = 'typeOption';
const MobileCreateRowFieldScreen({
required this.viewId,
required this.typeOption,
super.key,
});
final String viewId;
final TypeOptionPB typeOption;
@override
State<MobileCreateRowFieldScreen> createState() =>
_MobileCreateRowFieldScreenState();
}
class _MobileCreateRowFieldScreenState
extends State<MobileCreateRowFieldScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(LocaleKeys.grid_field_newProperty.tr()),
actions: [
Padding(
padding: const EdgeInsets.only(right: 16),
child: TextButton(
onPressed: () => context.pop(),
child: Text(
LocaleKeys.button_done.tr(),
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onBackground,
),
),
),
),
],
),
body: MobileFieldEditor(
viewId: widget.viewId,
typeOptionLoader: FieldTypeOptionLoader(
viewId: widget.viewId,
field: widget.typeOption.field_2,
),
),
);
}
}

View File

@ -0,0 +1,47 @@
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_create_row_field_screen.dart';
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class MobileCreateRowFieldButton extends StatelessWidget {
const MobileCreateRowFieldButton({super.key, required this.viewId});
final String viewId;
@override
Widget build(BuildContext context) {
return TextButton.icon(
label: Text(
LocaleKeys.grid_field_newProperty.tr(),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).hintColor,
),
),
onPressed: () async {
final result = await TypeOptionBackendService.createFieldTypeOption(
viewId: viewId,
);
result.fold(
(typeOption) {
context.push(
MobileCreateRowFieldScreen.routeName,
extra: {
MobileCreateRowFieldScreen.argViewId: viewId,
MobileCreateRowFieldScreen.argTypeOption: typeOption,
},
);
},
(r) => Log.error("Failed to create field type option: $r"),
);
},
icon: FlowySvg(
FlowySvgs.add_m,
color: Theme.of(context).hintColor,
),
);
}
}

View File

@ -0,0 +1,45 @@
import 'package:appflowy/plugins/database_view/application/field/field_editor_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class MobileFieldNameTextField extends StatefulWidget {
const MobileFieldNameTextField({
super.key,
this.text,
});
final String? text;
@override
State<MobileFieldNameTextField> createState() =>
_MobileFieldNameTextFieldState();
}
class _MobileFieldNameTextFieldState extends State<MobileFieldNameTextField> {
final controller = TextEditingController();
@override
void initState() {
super.initState();
if (widget.text != null) {
controller.text = widget.text!;
}
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
onChanged: (newName) {
context
.read<FieldEditorBloc>()
.add(FieldEditorEvent.updateName(newName));
},
);
}
}

View File

@ -0,0 +1,221 @@
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/widgets/mobile_create_row_field_button.dart';
import 'package:appflowy/mobile/presentation/database/card/card_property_edit/card_property_edit_screen.dart';
import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart';
import 'package:appflowy/plugins/database_view/grid/application/row/row_detail_bloc.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_type_extension.dart';
import 'package:appflowy/plugins/database_view/widgets/row/accessory/cell_accessory.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cell_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/cells.dart';
import 'package:appflowy/plugins/database_view/widgets/row/row_property.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
/// Display the row properties in a list. Only use this widget in the
/// [MobileCardDetailScreen].
class MobileRowPropertyList extends StatelessWidget {
const MobileRowPropertyList({
super.key,
required this.viewId,
required this.cellBuilder,
});
final String viewId;
final GridCellBuilder cellBuilder;
@override
Widget build(BuildContext context) {
return BlocBuilder<RowDetailBloc, RowDetailState>(
builder: (context, state) {
final List<DatabaseCellContext> visibleCells = state.visibleCells
.where((element) => !element.fieldInfo.field.isPrimary)
.toList();
return ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: visibleCells.length,
itemBuilder: (context, index) => _PropertyCell(
key: ValueKey('row_detail_${visibleCells[index].fieldId}'),
cellContext: visibleCells[index],
cellBuilder: cellBuilder,
index: index,
),
onReorder: (oldIndex, newIndex) {
// when reorderiing downwards, need to update index
if (oldIndex < newIndex) {
newIndex--;
}
final reorderedFieldId = visibleCells[oldIndex].fieldId;
final targetFieldId = visibleCells[newIndex].fieldId;
context.read<RowDetailBloc>().add(
RowDetailEvent.reorderField(
reorderedFieldId,
targetFieldId,
oldIndex,
newIndex,
),
);
},
footer: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (context.read<RowDetailBloc>().state.numHiddenFields != 0)
const ToggleHiddenFieldsVisibilityButton(),
const VSpace(8),
// add new field
MobileCreateRowFieldButton(
viewId: viewId,
),
],
),
),
);
},
);
}
}
// TODO(yijing): temperary locate here
// It may need to be share with other widgets
const cellHeight = 32.0;
class _PropertyCell extends StatefulWidget {
const _PropertyCell({
super.key,
required this.cellContext,
required this.cellBuilder,
required this.index,
});
final DatabaseCellContext cellContext;
final GridCellBuilder cellBuilder;
final int index;
@override
State<StatefulWidget> createState() => _PropertyCellState();
}
class _PropertyCellState extends State<_PropertyCell> {
@override
Widget build(BuildContext context) {
final style = _customCellStyle(widget.cellContext.fieldType);
final cell = widget.cellBuilder.build(widget.cellContext, style: style);
return ListTile(
contentPadding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
horizontalTitleGap: 4,
// FieldCellButton in Desktop
// TODO(yijing): adjust width with sreen size
leading: SizedBox(
width: 150,
height: cellHeight,
child: TextButton.icon(
icon: FlowySvg(
widget.cellContext.fieldInfo.field.fieldType.icon(),
color: Theme.of(context).colorScheme.onSurface,
),
label: Text(
widget.cellContext.fieldInfo.field.name,
// TODO(yijing): update text style
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
),
overflow: TextOverflow.ellipsis,
),
style: TextButton.styleFrom(
alignment: Alignment.centerLeft,
padding: EdgeInsets.zero,
),
// naivgator to field editor
onPressed: () => context.push(
CardPropertyEditScreen.routeName,
extra: {
CardPropertyEditScreen.argCellContext: widget.cellContext,
CardPropertyEditScreen.argRowDetailBloc:
context.read<RowDetailBloc>(),
},
),
),
),
title: SizedBox(
width: double.infinity,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => cell.requestFocus.notify(),
//property values
child: AccessoryHover(
fieldType: widget.cellContext.fieldType,
child: cell,
),
),
),
);
}
}
GridCellStyle? _customCellStyle(FieldType fieldType) {
switch (fieldType) {
case FieldType.Checkbox:
return GridCheckboxCellStyle(
cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
);
case FieldType.DateTime:
return DateCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
alignment: Alignment.centerLeft,
cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
);
case FieldType.LastEditedTime:
case FieldType.CreatedTime:
return TimestampCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
alignment: Alignment.centerLeft,
cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
);
case FieldType.MultiSelect:
return SelectOptionCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
);
case FieldType.Checklist:
return ChecklistCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
cellPadding: EdgeInsets.zero,
showTasksInline: true,
);
case FieldType.Number:
return GridNumberCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
);
case FieldType.RichText:
return GridTextCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
);
case FieldType.SingleSelect:
return SelectOptionCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
);
case FieldType.URL:
return GridURLCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
accessoryTypes: [
GridURLCellAccessoryType.copyURL,
GridURLCellAccessoryType.visitURL,
],
);
}
throw UnimplementedError;
}

View File

@ -0,0 +1,3 @@
export 'mobile_field_name_text_field.dart';
export 'mobile_create_row_field_button.dart';
export 'mobile_row_property_list.dart';

View File

@ -0,0 +1,64 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/database/card/card_property_edit/mobile_field_editor.dart';
import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart';
import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart';
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart';
import 'package:appflowy/plugins/database_view/grid/application/row/row_detail_bloc.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
class CardPropertyEditScreen extends StatelessWidget {
const CardPropertyEditScreen({
super.key,
required this.cellContext,
});
static const routeName = '/CardPropertyEditScreen';
static const argCellContext = 'cellContext';
static const argRowDetailBloc = 'rowDetailBloc';
final DatabaseCellContext cellContext;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(LocaleKeys.grid_field_editProperty.tr()),
actions: [
// show delete button when this field is not used to group cards
if (!cellContext.fieldInfo.isGroupField)
IconButton(
onPressed: () {
showFlowyMobileConfirmDialog(
context,
title: LocaleKeys.grid_field_deleteFieldPromptMessage.tr(),
actionButtonTitle: LocaleKeys.button_delete.tr(),
actionButtonColor: Theme.of(context).colorScheme.error,
onActionButtonPressed: () {
context.read<RowDetailBloc>().add(
RowDetailEvent.deleteField(
cellContext.fieldInfo.field.id,
),
);
context.pop();
},
);
},
icon: const FlowySvg(FlowySvgs.m_delete_m),
),
],
),
body: MobileFieldEditor(
viewId: cellContext.viewId,
typeOptionLoader: FieldTypeOptionLoader(
viewId: cellContext.viewId,
field: cellContext.fieldInfo.field,
),
fieldInfo: cellContext.fieldInfo,
),
);
}
}

View File

@ -0,0 +1,130 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/database/card/card_detail/widgets/widgets.dart';
import 'package:appflowy/mobile/presentation/database/card/card_property_edit/mobile_field_type_option_editor.dart';
import 'package:appflowy/mobile/presentation/database/card/card_property_edit/widgets/property_title.dart';
import 'package:appflowy/plugins/database_view/application/field/field_editor_bloc.dart';
import 'package:appflowy/plugins/database_view/application/field/field_info.dart';
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart';
import 'package:appflowy/plugins/database_view/grid/application/row/row_detail_bloc.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_settings_entities.pbenum.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
// Used in [CardPropertyEditScreen] and [MobileCreateRowFieldScreen]
class MobileFieldEditor extends StatelessWidget {
const MobileFieldEditor({
super.key,
required this.viewId,
required this.typeOptionLoader,
this.isGroupingField = false,
this.fieldInfo,
});
final String viewId;
final bool isGroupingField;
final FieldTypeOptionLoader typeOptionLoader;
final FieldInfo? fieldInfo;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) {
return FieldEditorBloc(
// group field is the field to be used to group cards in database view, it can not be deleted
isGroupField: isGroupingField,
loader: typeOptionLoader,
field: typeOptionLoader.field,
)..add(const FieldEditorEvent.initial());
},
child: BlocBuilder<FieldEditorBloc, FieldEditorState>(
builder: (context, state) {
// for field type edit option
final dataController = context.read<FieldEditorBloc>().dataController;
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// property name
// TODO(yijing): improve hint text
PropertyTitle(LocaleKeys.settings_user_name.tr()),
BlocSelector<FieldEditorBloc, FieldEditorState, String>(
selector: (state) {
return state.name;
},
builder: (context, propertyName) {
return MobileFieldNameTextField(
text: propertyName,
);
},
),
Row(
children: [
PropertyTitle(LocaleKeys.grid_field_visibility.tr()),
const Spacer(),
VisibilitySwitch(
isFieldHidden:
fieldInfo?.visibility == FieldVisibility.AlwaysHidden,
onChanged: () {
state.field.fold(
() => Log.error('Can not hidden the field'),
(field) => context.read<RowDetailBloc>().add(
RowDetailEvent.toggleFieldVisibility(
field.id,
),
),
);
},
),
],
),
const VSpace(8),
// edit property type and settings
if (!typeOptionLoader.field.isPrimary)
MobileFieldTypeOptionEditor(
dataController: dataController,
),
],
),
);
},
),
);
}
}
class VisibilitySwitch extends StatefulWidget {
const VisibilitySwitch({
super.key,
required this.isFieldHidden,
this.onChanged,
});
final bool isFieldHidden;
final Function? onChanged;
@override
State<VisibilitySwitch> createState() => _VisibilitySwitchState();
}
class _VisibilitySwitchState extends State<VisibilitySwitch> {
late bool _isFieldHidden = widget.isFieldHidden;
@override
Widget build(BuildContext context) {
return Switch.adaptive(
activeColor: Theme.of(context).colorScheme.primary,
value: !_isFieldHidden,
onChanged: (bool value) {
setState(() {
_isFieldHidden = !_isFieldHidden;
widget.onChanged?.call();
});
},
);
}
}

View File

@ -0,0 +1,82 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/database_view/application/field/field_type_option_edit_bloc.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_type_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
typedef SelectFieldCallback = void Function(FieldType);
class MobileFieldTypeList extends StatelessWidget {
const MobileFieldTypeList({
super.key,
required this.onSelectField,
required this.bloc,
});
final FieldTypeOptionEditBloc bloc;
final SelectFieldCallback onSelectField;
@override
Widget build(BuildContext context) {
const allFieldTypes = FieldType.values;
return BlocProvider.value(
value: bloc,
child: ListView.builder(
shrinkWrap: true,
itemCount: allFieldTypes.length,
itemBuilder: (_, index) {
return MobileFieldTypeCell(
fieldType: allFieldTypes[index],
onSelectField: onSelectField,
);
},
),
);
}
}
class MobileFieldTypeCell extends StatelessWidget {
const MobileFieldTypeCell({
super.key,
required this.fieldType,
required this.onSelectField,
});
final FieldType fieldType;
final SelectFieldCallback onSelectField;
@override
Widget build(BuildContext context) {
return RadioListTile<FieldType>(
dense: true,
controlAffinity: ListTileControlAffinity.trailing,
contentPadding: EdgeInsets.zero,
value: fieldType,
groupValue: context.select(
(FieldTypeOptionEditBloc bloc) => bloc.state.field.fieldType,
),
onChanged: (value) {
if (value != null) {
onSelectField(value);
}
},
title: Row(
children: [
FlowySvg(
fieldType.icon(),
),
const HSpace(8),
Text(
fieldType.title(),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onBackground,
),
),
],
),
);
}
}

View File

@ -0,0 +1,191 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/database/card/card_property_edit/type_option_widget_builder/type_option_widget_builder.dart';
import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_bottom_sheet.dart';
import 'package:appflowy/plugins/database_view/application/field/field_type_option_edit_bloc.dart';
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_data_controller.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_type_extension.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/type_option/builder.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'mobile_field_type_list.dart';
class MobileFieldTypeOptionEditor extends StatelessWidget {
const MobileFieldTypeOptionEditor({
super.key,
required TypeOptionController dataController,
}) : _dataController = dataController;
final TypeOptionController _dataController;
@override
Widget build(BuildContext context) {
return BlocProvider<FieldTypeOptionEditBloc>(
create: (_) => FieldTypeOptionEditBloc(_dataController)
..add(const FieldTypeOptionEditEvent.initial()),
child: BlocBuilder<FieldTypeOptionEditBloc, FieldTypeOptionEditState>(
builder: (context, state) {
final typeOptionWidget = _makeTypeOptionMobileWidget(
context: context,
dataController: _dataController,
);
return Column(
children: [
const _MobileSwitchFieldButton(),
if (typeOptionWidget != null) ...[
const VSpace(8),
typeOptionWidget,
],
],
);
},
),
);
}
}
class _MobileSwitchFieldButton extends StatelessWidget {
const _MobileSwitchFieldButton();
@override
Widget build(BuildContext context) {
final fieldType = context.select(
(FieldTypeOptionEditBloc bloc) => bloc.state.field.fieldType,
);
return GestureDetector(
child: Row(
children: [
Text(
LocaleKeys.grid_field_propertyType.tr(),
style: Theme.of(context).textTheme.titleMedium,
),
const Spacer(),
FlowySvg(fieldType.icon()),
const HSpace(4),
Text(
fieldType.title(),
style: Theme.of(context).textTheme.titleMedium,
),
Icon(
Icons.arrow_forward_ios_sharp,
color: Theme.of(context).hintColor,
),
],
),
onTap: () => showFlowyMobileBottomSheet(
context,
isScrollControlled: true,
title: LocaleKeys.grid_field_propertyType.tr(),
builder: (_) => MobileFieldTypeList(
bloc: context.read<FieldTypeOptionEditBloc>(),
onSelectField: (newFieldType) {
context.read<FieldTypeOptionEditBloc>().add(
FieldTypeOptionEditEvent.switchToField(newFieldType),
);
context.pop();
},
),
),
);
}
}
Widget? _makeTypeOptionMobileWidget({
required BuildContext context,
required TypeOptionController dataController,
}) =>
_makeTypeOptionMobileWidgetBuilder(
dataController: dataController,
).build(context);
TypeOptionWidgetBuilder _makeTypeOptionMobileWidgetBuilder({
required TypeOptionController dataController,
}) {
final viewId = dataController.loader.viewId;
final fieldType = dataController.field.fieldType;
switch (dataController.field.fieldType) {
case FieldType.Checkbox:
return CheckboxTypeOptionMobileWidgetBuilder(
makeTypeOptionContextWithDataController<CheckboxTypeOptionPB>(
viewId: viewId,
fieldType: fieldType,
dataController: dataController,
),
);
case FieldType.DateTime:
return DateTypeOptionMobileWidgetBuilder(
makeTypeOptionContextWithDataController<DateTypeOptionPB>(
viewId: viewId,
fieldType: fieldType,
dataController: dataController,
),
);
case FieldType.LastEditedTime:
case FieldType.CreatedTime:
return TimestampTypeOptionMobileWidgetBuilder(
makeTypeOptionContextWithDataController<TimestampTypeOptionPB>(
viewId: viewId,
fieldType: fieldType,
dataController: dataController,
),
);
case FieldType.SingleSelect:
return SingleSelectTypeOptionMobileWidgetBuilder(
makeTypeOptionContextWithDataController<SingleSelectTypeOptionPB>(
viewId: viewId,
fieldType: fieldType,
dataController: dataController,
),
);
case FieldType.MultiSelect:
return MultiSelectTypeOptionMobileWidgetBuilder(
makeTypeOptionContextWithDataController<MultiSelectTypeOptionPB>(
viewId: viewId,
fieldType: fieldType,
dataController: dataController,
),
);
case FieldType.Number:
return NumberTypeOptionMobileWidgetBuilder(
makeTypeOptionContextWithDataController<NumberTypeOptionPB>(
viewId: viewId,
fieldType: fieldType,
dataController: dataController,
),
);
case FieldType.RichText:
return RichTextTypeOptionMobileWidgetBuilder(
makeTypeOptionContextWithDataController<RichTextTypeOptionPB>(
viewId: viewId,
fieldType: fieldType,
dataController: dataController,
),
);
case FieldType.URL:
return URLTypeOptionMobileWidgetBuilder(
makeTypeOptionContextWithDataController<URLTypeOptionPB>(
viewId: viewId,
fieldType: fieldType,
dataController: dataController,
),
);
case FieldType.Checklist:
return ChecklistTypeOptionMobileWidgetBuilder(
makeTypeOptionContextWithDataController<ChecklistTypeOptionPB>(
viewId: viewId,
fieldType: fieldType,
dataController: dataController,
),
);
}
throw UnimplementedError;
}

View File

@ -0,0 +1,12 @@
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/type_option/builder.dart';
import 'package:flutter/material.dart';
class CheckboxTypeOptionMobileWidgetBuilder extends TypeOptionWidgetBuilder {
CheckboxTypeOptionMobileWidgetBuilder(
CheckboxTypeOptionContext typeOptionContext,
);
@override
Widget? build(BuildContext context) => null;
}

View File

@ -0,0 +1,12 @@
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/type_option/builder.dart';
import 'package:flutter/material.dart';
class ChecklistTypeOptionMobileWidgetBuilder extends TypeOptionWidgetBuilder {
ChecklistTypeOptionMobileWidgetBuilder(
ChecklistTypeOptionContext typeOptionContext,
);
@override
Widget? build(BuildContext context) => const Text('Under Construction');
}

View File

@ -0,0 +1,88 @@
import 'package:appflowy/mobile/presentation/database/card/row/cells/date_cell/widgets/widgets.dart';
import 'package:appflowy/plugins/database_view/application/field/type_option/date_bloc.dart';
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_type_option_editor.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/type_option/builder.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/type_option/date.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class DateTypeOptionMobileWidgetBuilder extends TypeOptionWidgetBuilder {
final DateTypeOptionMobileWidget _widget;
DateTypeOptionMobileWidgetBuilder(
DateTypeOptionContext typeOptionContext,
) : _widget = DateTypeOptionMobileWidget(
typeOptionContext: typeOptionContext,
);
@override
Widget? build(BuildContext context) => _widget;
}
class DateTypeOptionMobileWidget extends TypeOptionWidget {
const DateTypeOptionMobileWidget({
super.key,
required this.typeOptionContext,
});
final DateTypeOptionContext typeOptionContext;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) =>
DateTypeOptionBloc(typeOptionContext: typeOptionContext),
child: BlocConsumer<DateTypeOptionBloc, DateTypeOptionState>(
listener: (context, state) =>
typeOptionContext.typeOption = state.typeOption,
builder: (context, state) {
final List<Widget> children = [
DateFormatListTile(
currentFormatStr: state.typeOption.dateFormat.title(),
groupValue: context
.watch<DateTypeOptionBloc>()
.state
.typeOption
.dateFormat,
onChanged: (newFormat) {
if (newFormat != null) {
context.read<DateTypeOptionBloc>().add(
DateTypeOptionEvent.didSelectDateFormat(
newFormat,
),
);
}
},
),
TimeFormatListTile(
currentFormatStr: state.typeOption.timeFormat.title(),
groupValue: context
.watch<DateTypeOptionBloc>()
.state
.typeOption
.timeFormat,
onChanged: (newFormat) {
if (newFormat != null) {
context.read<DateTypeOptionBloc>().add(
DateTypeOptionEvent.didSelectTimeFormat(
newFormat,
),
);
}
},
),
];
return ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
separatorBuilder: (_, index) => const VSpace(8),
itemCount: children.length,
itemBuilder: (_, index) => children[index],
);
},
),
);
}
}

View File

@ -0,0 +1,12 @@
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/type_option/builder.dart';
import 'package:flutter/material.dart';
class MultiSelectTypeOptionMobileWidgetBuilder extends TypeOptionWidgetBuilder {
MultiSelectTypeOptionMobileWidgetBuilder(
MultiSelectTypeOptionContext typeOptionContext,
);
@override
Widget? build(BuildContext context) => const Text('Under Construction');
}

View File

@ -0,0 +1,154 @@
import 'package:appflowy/mobile/presentation/database/card/card_property_edit/widgets/property_title.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/plugins/database_view/application/field/type_option/number_bloc.dart';
import 'package:appflowy/plugins/database_view/application/field/type_option/number_format_bloc.dart';
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_type_option_editor.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/type_option/builder.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/number_entities.pbenum.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:go_router/go_router.dart';
class NumberTypeOptionMobileWidgetBuilder extends TypeOptionWidgetBuilder {
final NumberTypeOptionMobileWidget _widget;
NumberTypeOptionMobileWidgetBuilder(
NumberTypeOptionContext typeOptionContext,
) : _widget = NumberTypeOptionMobileWidget(
typeOptionContext: typeOptionContext,
);
@override
Widget? build(BuildContext context) {
return _widget;
}
}
class NumberTypeOptionMobileWidget extends TypeOptionWidget {
final NumberTypeOptionContext typeOptionContext;
const NumberTypeOptionMobileWidget({
super.key,
required this.typeOptionContext,
});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) =>
NumberTypeOptionBloc(typeOptionContext: typeOptionContext),
child: BlocConsumer<NumberTypeOptionBloc, NumberTypeOptionState>(
listener: (context, state) =>
typeOptionContext.typeOption = state.typeOption,
builder: (context, state) {
return GestureDetector(
child: Row(
children: [
PropertyTitle(LocaleKeys.grid_field_numberFormat.tr()),
const Spacer(),
const HSpace(4),
Text(
state.typeOption.format.title(),
style: Theme.of(context).textTheme.titleMedium,
),
Icon(
Icons.arrow_forward_ios,
color: Theme.of(context).hintColor,
),
],
),
onTap: () => showFlowyMobileBottomSheet(
context,
isScrollControlled: true,
title: LocaleKeys.grid_field_numberFormat.tr(),
builder: (bottomsheetContext) => GestureDetector(
child: NumberFormatList(
onSelected: (format) {
context
.read<NumberTypeOptionBloc>()
.add(NumberTypeOptionEvent.didSelectFormat(format));
bottomsheetContext.pop();
},
selectedFormat: state.typeOption.format,
),
),
),
);
},
),
);
}
}
typedef SelectNumberFormatCallback = Function(NumberFormatPB format);
class NumberFormatList extends StatelessWidget {
const NumberFormatList({
super.key,
required this.selectedFormat,
required this.onSelected,
});
final SelectNumberFormatCallback onSelected;
final NumberFormatPB selectedFormat;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => NumberFormatBloc(),
child: Column(
children: [
const _FilterTextField(),
const VSpace(16),
SizedBox(
height: 300,
child: BlocBuilder<NumberFormatBloc, NumberFormatState>(
builder: (context, state) {
final List<NumberFormatPB> formatList = state.formats;
return ListView.builder(
itemCount: formatList.length,
itemBuilder: (BuildContext context, int index) {
final format = formatList[index];
return RadioListTile<NumberFormatPB>(
controlAffinity: ListTileControlAffinity.trailing,
visualDensity: VisualDensity.compact,
value: format,
groupValue: selectedFormat,
onChanged: (format) => onSelected(format!),
title: Text(format.title()),
);
},
);
},
),
),
],
),
);
}
}
class _FilterTextField extends StatelessWidget {
const _FilterTextField();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: TextField(
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
onChanged: (text) => context
.read<NumberFormatBloc>()
.add(NumberFormatEvent.setFilter(text)),
),
);
}
}

View File

@ -0,0 +1,12 @@
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/type_option/builder.dart';
import 'package:flutter/material.dart';
class RichTextTypeOptionMobileWidgetBuilder extends TypeOptionWidgetBuilder {
RichTextTypeOptionMobileWidgetBuilder(
RichTextTypeOptionContext typeOptionContext,
);
@override
Widget? build(BuildContext context) => null;
}

View File

@ -0,0 +1,13 @@
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/type_option/builder.dart';
import 'package:flutter/material.dart';
class SingleSelectTypeOptionMobileWidgetBuilder
extends TypeOptionWidgetBuilder {
SingleSelectTypeOptionMobileWidgetBuilder(
SingleSelectTypeOptionContext typeOptionContext,
);
@override
Widget? build(BuildContext context) => const Text('Under Construction');
}

View File

@ -0,0 +1,98 @@
import 'package:appflowy/mobile/presentation/database/card/row/cells/date_cell/widgets/widgets.dart';
import 'package:appflowy/plugins/database_view/application/field/type_option/timestamp_bloc.dart';
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_type_option_editor.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/type_option/builder.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/type_option/date.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class TimestampTypeOptionMobileWidgetBuilder extends TypeOptionWidgetBuilder {
final TimestampTypeOptionMobileWidget _widget;
TimestampTypeOptionMobileWidgetBuilder(
TimestampTypeOptionContext typeOptionContext,
) : _widget = TimestampTypeOptionMobileWidget(
typeOptionContext: typeOptionContext,
);
@override
Widget? build(BuildContext context) => _widget;
}
class TimestampTypeOptionMobileWidget extends TypeOptionWidget {
const TimestampTypeOptionMobileWidget({
super.key,
required this.typeOptionContext,
});
final TimestampTypeOptionContext typeOptionContext;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) =>
TimestampTypeOptionBloc(typeOptionContext: typeOptionContext),
child: BlocConsumer<TimestampTypeOptionBloc, TimestampTypeOptionState>(
listener: (context, state) =>
typeOptionContext.typeOption = state.typeOption,
builder: (context, state) {
final List<Widget> children = [
// used to add a separator padding at the top
const SizedBox.shrink(),
DateFormatListTile(
currentFormatStr: state.typeOption.dateFormat.title(),
groupValue: context
.watch<TimestampTypeOptionBloc>()
.state
.typeOption
.dateFormat,
onChanged: (newFormat) {
if (newFormat != null) {
context.read<TimestampTypeOptionBloc>().add(
TimestampTypeOptionEvent.didSelectDateFormat(
newFormat,
),
);
}
},
),
IncludeTimeSwitch(
switchValue: state.typeOption.includeTime,
onChanged: (value) => context
.read<TimestampTypeOptionBloc>()
.add(TimestampTypeOptionEvent.includeTime(value)),
),
if (state.typeOption.includeTime)
TimeFormatListTile(
currentFormatStr: state.typeOption.timeFormat.title(),
groupValue: context
.watch<TimestampTypeOptionBloc>()
.state
.typeOption
.timeFormat,
onChanged: (newFormat) {
if (newFormat != null) {
context.read<TimestampTypeOptionBloc>().add(
TimestampTypeOptionEvent.didSelectTimeFormat(
newFormat,
),
);
}
},
),
];
return ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
separatorBuilder: (context, index) => const VSpace(8),
itemCount: children.length,
itemBuilder: (BuildContext context, int index) => children[index],
);
},
),
);
}
}

View File

@ -0,0 +1,9 @@
export 'checklist.dart';
export 'checkbox.dart';
export 'date.dart';
export 'multi_select.dart';
export 'number.dart';
export 'rich_text.dart';
export 'single_select.dart';
export 'timestamp.dart';
export 'url.dart';

View File

@ -0,0 +1,12 @@
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/type_option/builder.dart';
import 'package:flutter/material.dart';
class URLTypeOptionMobileWidgetBuilder extends TypeOptionWidgetBuilder {
URLTypeOptionMobileWidgetBuilder(
URLTypeOptionContext typeOptionContext,
);
@override
Widget? build(BuildContext context) => null;
}

View File

@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
class PropertyTitle extends StatelessWidget {
const PropertyTitle(this.name, {super.key});
final String name;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
name,
style: Theme.of(context).textTheme.titleMedium,
),
);
}
}

View File

@ -0,0 +1,7 @@
export 'mobile_text_cell.dart';
export 'mobile_number_cell.dart';
export 'mobile_timestamp_cell.dart';
export 'mobile_checkbox_cell.dart';
export 'mobile_url_cell.dart';
export 'date_cell/mobile_date_cell.dart';
export 'date_cell/mobile_date_cell_edit_screen.dart';

View File

@ -0,0 +1,103 @@
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cell_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/date_cell/date_cell_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'mobile_date_cell_edit_screen.dart';
class MobileDateCell extends GridCellWidget {
MobileDateCell({
super.key,
required this.cellControllerBuilder,
required this.hintText,
});
final CellControllerBuilder cellControllerBuilder;
final String? hintText;
@override
GridCellState<MobileDateCell> createState() => _DateCellState();
}
class _DateCellState extends GridCellState<MobileDateCell> {
late final DateCellBloc _cellBloc;
@override
void initState() {
final cellController =
widget.cellControllerBuilder.build() as DateCellController;
_cellBloc = DateCellBloc(cellController: cellController)
..add(const DateCellEvent.initial());
super.initState();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cellBloc,
child: BlocBuilder<DateCellBloc, DateCellState>(
builder: (context, state) {
// full screen show the date edit screen
return GestureDetector(
onTap: () => context.push(
MobileDateCellEditScreen.routeName,
extra: {
MobileDateCellEditScreen.argCellController:
widget.cellControllerBuilder.build() as DateCellController,
},
),
child: SizedBox(
width: double.infinity,
child: MobileDateCellText(
dateStr: state.dateStr,
placeholder: widget.hintText ?? "",
),
),
);
},
),
);
}
@override
Future<void> dispose() async {
_cellBloc.close();
super.dispose();
}
@override
void requestBeginFocus() {}
@override
String? onCopy() => _cellBloc.state.dateStr;
}
class MobileDateCellText extends StatelessWidget {
const MobileDateCellText({
super.key,
required this.dateStr,
required this.placeholder,
});
final String dateStr;
final String placeholder;
@override
Widget build(BuildContext context) {
final isPlaceholder = dateStr.isEmpty;
final text = isPlaceholder ? placeholder : dateStr;
return Align(
alignment: Alignment.centerLeft,
child: Text(
text,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: isPlaceholder
? Theme.of(context).hintColor
: Theme.of(context).colorScheme.onBackground,
),
),
);
}
}

View File

@ -0,0 +1,372 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/type_option/date.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/date_cell/date_cal_bloc.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/date_cell/date_editor.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.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 'widgets/widgets.dart';
class MobileDateCellEditScreen extends StatefulWidget {
static const routeName = '/MobileDateCellEditScreen';
static const argCellController = 'cellController';
const MobileDateCellEditScreen(
this.cellController, {
super.key,
});
final DateCellController cellController;
@override
State<MobileDateCellEditScreen> createState() =>
_MobileDateCellEditScreenState();
}
class _MobileDateCellEditScreenState extends State<MobileDateCellEditScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(LocaleKeys.button_edit.tr()),
),
body: FutureBuilder<Either<dynamic, FlowyError>>(
future: widget.cellController.getTypeOption(
DateTypeOptionDataParser(),
),
builder: (BuildContext context, snapshot) {
if (snapshot.hasData) {
return snapshot.data!.fold(
(dateTypeOptionPB) {
return _DateCellEditBody(
dateCellController: widget.cellController,
dateTypeOptionPB: dateTypeOptionPB,
);
},
(err) {
Log.error(err);
return FlowyMobileStateContainer.error(
title: LocaleKeys.grid_field_failedToLoadDate.tr(),
errorMsg: err.toString(),
);
},
);
}
return const Center(child: CircularProgressIndicator.adaptive());
},
),
);
}
}
class _DateCellEditBody extends StatefulWidget {
const _DateCellEditBody({
required this.dateCellController,
required this.dateTypeOptionPB,
});
final DateCellController dateCellController;
final DateTypeOptionPB dateTypeOptionPB;
@override
State<_DateCellEditBody> createState() => _DateCellEditBodyState();
}
class _DateCellEditBodyState extends State<_DateCellEditBody> {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => DateCellCalendarBloc(
dateTypeOptionPB: widget.dateTypeOptionPB,
cellData: widget.dateCellController.getCellData(),
cellController: widget.dateCellController,
)..add(const DateCellCalendarEvent.initial()),
child: BlocBuilder<DateCellCalendarBloc, DateCellCalendarState>(
builder: (context, state) {
final widgetsList = [
DateAndTimeDisplay(state),
const DatePicker(),
const _EndDateSwitch(),
const _IncludeTimeSwitch(),
const _StartDayTime(),
const _EndDayTime(),
const Divider(),
const _DateFormatOption(),
const _TimeFormatOption(),
const Divider(),
const _ClearDateButton(),
];
return Padding(
padding: const EdgeInsets.all(16),
child: ListView.separated(
itemBuilder: (context, index) => widgetsList[index],
separatorBuilder: (_, __) => const VSpace(8),
itemCount: widgetsList.length,
),
);
},
),
);
}
}
class _EndDateSwitch extends StatelessWidget {
const _EndDateSwitch();
@override
Widget build(BuildContext context) {
return BlocSelector<DateCellCalendarBloc, DateCellCalendarState, bool>(
selector: (state) => state.isRange,
builder: (context, isRange) {
return Row(
children: [
Text(
LocaleKeys.grid_field_isRange.tr(),
style: Theme.of(context).textTheme.titleMedium,
),
const Spacer(),
Switch.adaptive(
value: isRange,
activeColor: Theme.of(context).colorScheme.primary,
onChanged: (value) {
context
.read<DateCellCalendarBloc>()
.add(DateCellCalendarEvent.setIsRange(value));
},
),
],
);
},
);
}
}
class _IncludeTimeSwitch extends StatelessWidget {
const _IncludeTimeSwitch();
@override
Widget build(BuildContext context) {
return BlocSelector<DateCellCalendarBloc, DateCellCalendarState, bool>(
selector: (state) => state.includeTime,
builder: (context, includeTime) {
return IncludeTimeSwitch(
switchValue: includeTime,
onChanged: (value) {
context
.read<DateCellCalendarBloc>()
.add(DateCellCalendarEvent.setIncludeTime(value));
},
);
},
);
}
}
class _StartDayTime extends StatelessWidget {
const _StartDayTime();
@override
Widget build(BuildContext context) {
return BlocBuilder<DateCellCalendarBloc, DateCellCalendarState>(
builder: (context, state) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: state.includeTime
? Row(
children: [
Text(
state.isRange
? LocaleKeys.grid_field_startDateTime.tr()
: LocaleKeys.grid_field_dateTime.tr(),
style: Theme.of(context).textTheme.titleMedium,
),
const Spacer(),
// TODO(yijing): improve width
SizedBox(
width: 180,
child: _TimeTextField(
timeStr: state.timeStr,
isEndTime: false,
),
),
],
)
: const SizedBox.shrink(),
);
},
);
}
}
class _EndDayTime extends StatelessWidget {
const _EndDayTime();
@override
Widget build(BuildContext context) {
return BlocBuilder<DateCellCalendarBloc, DateCellCalendarState>(
builder: (context, state) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: state.includeTime && state.endTimeStr != null
? Row(
children: [
Text(
LocaleKeys.grid_field_endDateTime.tr(),
style: Theme.of(context).textTheme.titleMedium,
),
const Spacer(),
// TODO(yijing): improve width
SizedBox(
width: 180,
child: _TimeTextField(
timeStr: state.timeStr,
isEndTime: true,
),
),
],
)
: const SizedBox.shrink(),
);
},
);
}
}
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<DateCellCalendarBloc, DateCellCalendarState>(
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<DateCellCalendarBloc>().add(
widget.isEndTime
? DateCellCalendarEvent.setEndTime(timeStr)
: DateCellCalendarEvent.setTime(timeStr),
);
},
);
},
);
}
@override
void dispose() {
_textController.dispose();
super.dispose();
}
}
class _ClearDateButton extends StatelessWidget {
const _ClearDateButton();
@override
Widget build(BuildContext context) {
return GestureDetector(
child: Align(
alignment: Alignment.centerLeft,
child: Text(
LocaleKeys.grid_field_clearDate.tr(),
style: Theme.of(context).textTheme.titleMedium,
),
),
onTap: () => context
.read<DateCellCalendarBloc>()
.add(const DateCellCalendarEvent.clearDate()),
);
}
}
class _TimeFormatOption extends StatelessWidget {
const _TimeFormatOption();
@override
Widget build(BuildContext context) {
return BlocSelector<DateCellCalendarBloc, DateCellCalendarState,
TimeFormatPB>(
selector: (state) => state.dateTypeOptionPB.timeFormat,
builder: (context, state) {
return TimeFormatListTile(
currentFormatStr: state.title(),
groupValue: context
.watch<DateCellCalendarBloc>()
.state
.dateTypeOptionPB
.timeFormat,
onChanged: (newFormat) {
if (newFormat == null) return;
context
.read<DateCellCalendarBloc>()
.add(DateCellCalendarEvent.setTimeFormat(newFormat));
},
);
},
);
}
}
class _DateFormatOption extends StatelessWidget {
const _DateFormatOption();
@override
Widget build(BuildContext context) {
return BlocSelector<DateCellCalendarBloc, DateCellCalendarState,
DateFormatPB>(
selector: (state) => state.dateTypeOptionPB.dateFormat,
builder: (context, state) {
return DateFormatListTile(
currentFormatStr: state.title(),
groupValue: context
.watch<DateCellCalendarBloc>()
.state
.dateTypeOptionPB
.dateFormat,
onChanged: (newFormat) {
if (newFormat == null) return;
context
.read<DateCellCalendarBloc>()
.add(DateCellCalendarEvent.setDateFormat(newFormat));
},
);
},
);
}
}

View File

@ -0,0 +1,142 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/date_cell/date_cal_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pbenum.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class DateAndTimeDisplay extends StatelessWidget {
const DateAndTimeDisplay(this.state, {super.key});
final DateCellCalendarState state;
@override
Widget build(BuildContext context) {
return Column(
children: [
// date/start date and time
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_DateEditButton(
dateStr: state.isRange
? _formatDateByDateFormatPB(
state.startDay,
state.dateTypeOptionPB.dateFormat,
)
: state.dateStr,
initialDate: state.isRange ? state.startDay : state.dateTime,
onDaySelected: (newDate) {
context.read<DateCellCalendarBloc>().add(
state.isRange
? DateCellCalendarEvent.setStartDay(newDate)
: DateCellCalendarEvent.selectDay(newDate),
);
},
),
const HSpace(8),
if (state.includeTime)
Expanded(child: _TimeEditButton(state.timeStr)),
],
),
const VSpace(8),
// end date and time
if (state.isRange) ...[
_DateEditButton(
dateStr: state.endDay != null ? state.endDateStr : null,
initialDate: state.endDay,
onDaySelected: (newDate) {
context.read<DateCellCalendarBloc>().add(
DateCellCalendarEvent.setEndDay(newDate),
);
},
),
const HSpace(8),
if (state.includeTime)
Expanded(child: _TimeEditButton(state.endTimeStr)),
],
],
);
}
}
class _DateEditButton extends StatelessWidget {
const _DateEditButton({
required this.dateStr,
required this.initialDate,
required this.onDaySelected,
});
final String? dateStr;
/// initial date for date picker, if null, use DateTime.now()
final DateTime? initialDate;
final void Function(DateTime)? onDaySelected;
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
return SizedBox(
// share space with TimeEditButton
width: (size.width - 8) / 2,
child: OutlinedButton(
onPressed: () async {
final DateTime? newDate = await showDatePicker(
context: context,
initialDate: initialDate ?? DateTime.now(),
firstDate: DateTime(1900),
lastDate: DateTime(2100),
);
if (newDate != null) {
onDaySelected?.call(newDate);
}
},
child: Text(
dateStr ?? LocaleKeys.grid_field_selectDate.tr(),
style: Theme.of(context).textTheme.labelMedium,
),
),
);
}
}
class _TimeEditButton extends StatelessWidget {
const _TimeEditButton(
this.timeStr,
);
final String? timeStr;
@override
Widget build(BuildContext context) {
return OutlinedButton(
// TODO(yijing): implement time picker
onPressed: null,
child: Text(
timeStr ?? LocaleKeys.grid_field_selectTime.tr(),
style: Theme.of(context).textTheme.labelMedium,
),
);
}
}
String? _formatDateByDateFormatPB(DateTime? date, DateFormatPB dateFormatPB) {
if (date == null) {
return null;
}
switch (dateFormatPB) {
case DateFormatPB.Local:
return DateFormat('MM/dd/yyyy').format(date);
case DateFormatPB.US:
return DateFormat('yyyy/MM/dd').format(date);
case DateFormatPB.ISO:
return DateFormat('yyyy-MM-dd').format(date);
case DateFormatPB.Friendly:
return DateFormat('MMM dd, yyyy').format(date);
case DateFormatPB.DayMonthYear:
return DateFormat('dd/MM/yyyy').format(date);
default:
return 'Unavailable date format';
}
}

View File

@ -0,0 +1,141 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class DateFormatListTile extends StatelessWidget {
const DateFormatListTile({
super.key,
required this.currentFormatStr,
this.groupValue,
this.onChanged,
});
final String currentFormatStr;
/// The group value for the radio list tile.
final DateFormatPB? groupValue;
/// The Function for the radio list tile.
final void Function(DateFormatPB?)? onChanged;
@override
Widget build(BuildContext context) {
final style = Theme.of(context);
return Row(
children: [
Text(
LocaleKeys.grid_field_dateFormat.tr(),
style: style.textTheme.titleMedium,
),
const Spacer(),
GestureDetector(
child: Row(
children: [
Text(
currentFormatStr,
style: style.textTheme.titleMedium,
),
const HSpace(4),
Icon(
Icons.arrow_forward_ios_sharp,
color: style.hintColor,
),
],
),
onTap: () => showFlowyMobileBottomSheet(
context,
title: LocaleKeys.grid_field_dateFormat.tr(),
builder: (_) {
return Column(
children: [
_DateFormatRadioListTile(
title: LocaleKeys.grid_field_dateFormatLocal.tr(),
dateFormatPB: DateFormatPB.Local,
groupValue: groupValue,
onChanged: (newFormat) {
onChanged?.call(newFormat);
context.pop();
},
),
_DateFormatRadioListTile(
title: LocaleKeys.grid_field_dateFormatUS.tr(),
dateFormatPB: DateFormatPB.US,
groupValue: groupValue,
onChanged: (newFormat) {
onChanged?.call(newFormat);
context.pop();
},
),
_DateFormatRadioListTile(
title: LocaleKeys.grid_field_dateFormatISO.tr(),
dateFormatPB: DateFormatPB.ISO,
groupValue: groupValue,
onChanged: (newFormat) {
onChanged?.call(newFormat);
context.pop();
},
),
_DateFormatRadioListTile(
title: LocaleKeys.grid_field_dateFormatFriendly.tr(),
dateFormatPB: DateFormatPB.Friendly,
groupValue: groupValue,
onChanged: (newFormat) {
onChanged?.call(newFormat);
context.pop();
},
),
_DateFormatRadioListTile(
title: LocaleKeys.grid_field_dateFormatDayMonthYear.tr(),
dateFormatPB: DateFormatPB.DayMonthYear,
groupValue: groupValue,
onChanged: (newFormat) {
onChanged?.call(newFormat);
context.pop();
},
),
],
);
},
),
),
],
);
}
}
class _DateFormatRadioListTile extends StatelessWidget {
const _DateFormatRadioListTile({
required this.title,
required this.dateFormatPB,
required this.groupValue,
required this.onChanged,
});
final String title;
final DateFormatPB dateFormatPB;
final DateFormatPB? groupValue;
final void Function(DateFormatPB?)? onChanged;
@override
Widget build(BuildContext context) {
final style = Theme.of(context);
return RadioListTile<DateFormatPB>(
dense: true,
contentPadding: const EdgeInsets.fromLTRB(0, 0, 4, 0),
controlAffinity: ListTileControlAffinity.trailing,
title: Text(
title,
style: style.textTheme.bodyMedium?.copyWith(
color: style.colorScheme.onSurface,
),
),
groupValue: groupValue,
value: dateFormatPB,
onChanged: onChanged,
);
}
}

View File

@ -0,0 +1,32 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
class IncludeTimeSwitch extends StatelessWidget {
const IncludeTimeSwitch({
super.key,
required this.switchValue,
required this.onChanged,
});
final bool switchValue;
final void Function(bool)? onChanged;
@override
Widget build(BuildContext context) {
return Row(
children: [
Text(
LocaleKeys.grid_field_includeTime.tr(),
style: Theme.of(context).textTheme.titleMedium,
),
const Spacer(),
Switch.adaptive(
value: switchValue,
activeColor: Theme.of(context).colorScheme.primary,
onChanged: onChanged,
),
],
);
}
}

View File

@ -0,0 +1,115 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pbenum.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class TimeFormatListTile extends StatelessWidget {
const TimeFormatListTile({
super.key,
required this.currentFormatStr,
required this.groupValue,
required this.onChanged,
});
final String currentFormatStr;
/// The group value for the radio list tile.
final TimeFormatPB? groupValue;
/// The Function for the radio list tile.
final void Function(TimeFormatPB?)? onChanged;
@override
Widget build(BuildContext context) {
final style = Theme.of(context);
return Row(
children: [
Text(
LocaleKeys.grid_field_timeFormat.tr(),
style: style.textTheme.titleMedium,
),
const Spacer(),
GestureDetector(
child: Row(
children: [
Text(
currentFormatStr,
style: style.textTheme.titleMedium,
),
const HSpace(4),
Icon(
Icons.arrow_forward_ios_sharp,
color: style.hintColor,
),
],
),
onTap: () => showFlowyMobileBottomSheet(
context,
title: LocaleKeys.grid_field_timeFormat.tr(),
builder: (context) {
return Column(
children: [
_TimeFormatRadioListTile(
title: LocaleKeys.grid_field_timeFormatTwelveHour.tr(),
timeFormatPB: TimeFormatPB.TwelveHour,
groupValue: groupValue,
onChanged: (newFormat) {
onChanged?.call(newFormat);
context.pop();
},
),
_TimeFormatRadioListTile(
title: LocaleKeys.grid_field_timeFormatTwentyFourHour.tr(),
timeFormatPB: TimeFormatPB.TwentyFourHour,
groupValue: groupValue,
onChanged: (newFormat) {
onChanged?.call(newFormat);
context.pop();
},
),
],
);
},
),
),
],
);
}
}
class _TimeFormatRadioListTile extends StatelessWidget {
const _TimeFormatRadioListTile({
required this.title,
required this.timeFormatPB,
required this.groupValue,
required this.onChanged,
});
final String title;
final TimeFormatPB timeFormatPB;
final TimeFormatPB? groupValue;
final void Function(TimeFormatPB?)? onChanged;
@override
Widget build(BuildContext context) {
final style = Theme.of(context);
return RadioListTile<TimeFormatPB>(
dense: true,
contentPadding: const EdgeInsets.fromLTRB(0, 0, 4, 0),
controlAffinity: ListTileControlAffinity.trailing,
title: Text(
title,
style: style.textTheme.bodyMedium?.copyWith(
color: style.colorScheme.onSurface,
),
),
groupValue: groupValue,
value: timeFormatPB,
onChanged: onChanged,
);
}
}

View File

@ -0,0 +1,4 @@
export 'date_and_time_display.dart';
export 'date_format_list_tile.dart';
export 'time_format_list_tile.dart';
export 'include_time_switch.dart';

View File

@ -0,0 +1,66 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cell_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/checkbox_cell/checkbox_cell_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class MobileCheckboxCell extends GridCellWidget {
MobileCheckboxCell({
super.key,
required this.cellControllerBuilder,
GridCellStyle? style,
});
final CellControllerBuilder cellControllerBuilder;
@override
GridCellState<MobileCheckboxCell> createState() => _CheckboxCellState();
}
class _CheckboxCellState extends GridCellState<MobileCheckboxCell> {
late final CheckboxCellBloc _cellBloc;
@override
void initState() {
super.initState();
final cellController =
widget.cellControllerBuilder.build() as CheckboxCellController;
_cellBloc = CheckboxCellBloc(cellController: cellController)
..add(const CheckboxCellEvent.initial());
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cellBloc,
child: BlocBuilder<CheckboxCellBloc, CheckboxCellState>(
builder: (context, state) {
return Align(
alignment: Alignment.centerLeft,
// TODO(yijing): improve icon here
child: FlowySvg(
state.isSelected ? FlowySvgs.checkbox_s : FlowySvgs.uncheck_s,
color: Theme.of(context).colorScheme.onBackground,
size: const Size.square(24),
),
);
},
),
);
}
@override
Future<void> dispose() async {
_cellBloc.close();
super.dispose();
}
@override
void requestBeginFocus() {
_cellBloc.add(const CheckboxCellEvent.select());
}
@override
String? onCopy() => _cellBloc.state.isSelected ? "Yes" : "No";
}

View File

@ -0,0 +1,85 @@
import 'dart:async';
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cell_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/number_cell/number_cell_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class MobileNumberCell extends GridCellWidget {
MobileNumberCell({
super.key,
required this.cellControllerBuilder,
this.hintText,
});
final CellControllerBuilder cellControllerBuilder;
final String? hintText;
@override
GridEditableTextCell<MobileNumberCell> createState() => _NumberCellState();
}
class _NumberCellState extends GridEditableTextCell<MobileNumberCell> {
late final NumberCellBloc _cellBloc;
late final TextEditingController _controller;
@override
SingleListenerFocusNode focusNode = SingleListenerFocusNode();
@override
void initState() {
final cellController =
widget.cellControllerBuilder.build() as NumberCellController;
_cellBloc = NumberCellBloc(cellController: cellController)
..add(const NumberCellEvent.initial());
_controller = TextEditingController(text: _cellBloc.state.cellContent);
super.initState();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cellBloc,
child: MultiBlocListener(
listeners: [
BlocListener<NumberCellBloc, NumberCellState>(
listenWhen: (p, c) => p.cellContent != c.cellContent,
listener: (context, state) => _controller.text = state.cellContent,
),
],
child: TextField(
controller: _controller,
focusNode: focusNode,
decoration: InputDecoration(
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
hintText: widget.hintText,
contentPadding: EdgeInsets.zero,
isCollapsed: true,
),
// close keyboard when tapping outside of the text field
onTapOutside: (event) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
),
);
}
@override
Future<void> dispose() async {
_cellBloc.close();
super.dispose();
}
@override
Future<void> focusChanged() async {
if (mounted &&
_cellBloc.isClosed == false &&
_controller.text != _cellBloc.state.cellContent) {
_cellBloc.add(NumberCellEvent.updateCell(_controller.text));
}
}
@override
String? onCopy() => _cellBloc.state.cellContent;
}

View File

@ -0,0 +1,84 @@
import 'dart:async';
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cell_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/text_cell/text_cell_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class MobileTextCell extends GridCellWidget {
MobileTextCell({
super.key,
required this.cellControllerBuilder,
this.hintText,
});
final CellControllerBuilder cellControllerBuilder;
final String? hintText;
@override
GridEditableTextCell<MobileTextCell> createState() => _MobileTextCellState();
}
class _MobileTextCellState extends GridEditableTextCell<MobileTextCell> {
late final TextCellBloc _cellBloc;
late final TextEditingController _controller;
@override
SingleListenerFocusNode focusNode = SingleListenerFocusNode();
@override
void initState() {
super.initState();
final cellController =
widget.cellControllerBuilder.build() as TextCellController;
_cellBloc = TextCellBloc(cellController: cellController)
..add(const TextCellEvent.initial());
_controller = TextEditingController(text: _cellBloc.state.content);
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cellBloc,
child: BlocListener<TextCellBloc, TextCellState>(
listener: (context, state) {
if (_controller.text != state.content) {
_controller.text = state.content;
}
},
child: TextField(
controller: _controller,
focusNode: focusNode,
// TODO(yijing): update text style
decoration: InputDecoration(
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
hintText: widget.hintText,
contentPadding: EdgeInsets.zero,
isCollapsed: true,
),
// close keyboard when tapping outside of the text field
onTapOutside: (event) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
),
);
}
@override
Future<void> dispose() async {
_cellBloc.close();
super.dispose();
}
@override
String? onCopy() => _cellBloc.state.content;
@override
Future<void> focusChanged() {
_cellBloc.add(
TextCellEvent.updateText(_controller.text),
);
return super.focusChanged();
}
}

View File

@ -0,0 +1,57 @@
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cell_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/timestamp_cell/timestamp_cell_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class MobileTimestampCell extends GridCellWidget {
MobileTimestampCell({
super.key,
required this.cellControllerBuilder,
});
final CellControllerBuilder cellControllerBuilder;
@override
GridCellState<MobileTimestampCell> createState() => _TimestampCellState();
}
class _TimestampCellState extends GridCellState<MobileTimestampCell> {
late final TimestampCellBloc _cellBloc;
@override
void initState() {
final cellController =
widget.cellControllerBuilder.build() as TimestampCellController;
_cellBloc = TimestampCellBloc(cellController: cellController)
..add(const TimestampCellEvent.initial());
super.initState();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cellBloc,
child: BlocBuilder<TimestampCellBloc, TimestampCellState>(
builder: (context, state) {
return Align(
alignment: Alignment.centerLeft,
child: Text(state.dateStr),
);
},
),
);
}
@override
Future<void> dispose() async {
_cellBloc.close();
super.dispose();
}
@override
String? onCopy() => _cellBloc.state.dateStr;
@override
void requestBeginFocus() {}
}

View File

@ -0,0 +1,119 @@
import 'dart:async';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cell_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/url_cell/url_cell_bloc.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher_string.dart';
class MobileURLCell extends GridCellWidget {
MobileURLCell({
super.key,
required this.cellControllerBuilder,
this.hintText,
});
final CellControllerBuilder cellControllerBuilder;
final String? hintText;
@override
GridCellState<MobileURLCell> createState() => _GridURLCellState();
}
class _GridURLCellState extends GridCellState<MobileURLCell> {
late final URLCellBloc _cellBloc;
@override
void initState() {
super.initState();
final cellController =
widget.cellControllerBuilder.build() as URLCellController;
_cellBloc = URLCellBloc(cellController: cellController)
..add(const URLCellEvent.initial());
}
@override
Future<void> dispose() async {
_cellBloc.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cellBloc,
child: BlocSelector<URLCellBloc, URLCellState, String>(
selector: (state) => state.content,
builder: (context, content) {
if (content.isEmpty) {
return TextField(
keyboardType: TextInputType.url,
decoration: InputDecoration(
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
hintText: widget.hintText,
contentPadding: EdgeInsets.zero,
isCollapsed: true,
),
// close keyboard when tapping outside of the text field
onTapOutside: (event) =>
FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: (value) {
_cellBloc.add(
URLCellEvent.updateURL(value),
);
},
);
}
return Align(
alignment: Alignment.centerLeft,
child: GestureDetector(
onTap: () {
if (content.isEmpty) {
return;
}
final shouldAddScheme = !['http', 'https']
.any((pattern) => content.startsWith(pattern));
final url = shouldAddScheme ? 'http://$content' : content;
canLaunchUrlString(url).then((value) => launchUrlString(url));
},
onLongPress: () => showFlowyMobileBottomSheet(
context,
title: LocaleKeys.board_mobile_editURL.tr(),
builder: (_) {
final controller = TextEditingController(text: content);
return TextField(
controller: controller,
autofocus: true,
keyboardType: TextInputType.url,
onEditingComplete: () {
_cellBloc.add(
URLCellEvent.updateURL(controller.text),
);
context.pop();
},
);
},
),
child: Text(
content,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
decoration: TextDecoration.underline,
color: Theme.of(context).colorScheme.primary,
),
),
),
);
},
),
);
}
@override
void requestBeginFocus() {}
}

View File

@ -39,6 +39,7 @@ class FlowyMobileStateContainer extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SizedBox.expand(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 32),
@ -47,7 +48,10 @@ class FlowyMobileStateContainer extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
emoji ?? '',
emoji ??
(_stateType == _FlowyMobileStateContainerType.error
? '🛸'
: ''),
style: const TextStyle(fontSize: 40),
),
const SizedBox(height: 8),

View File

@ -5,10 +5,11 @@ Future<T?> showFlowyMobileBottomSheet<T>(
BuildContext context, {
required String title,
required Widget Function(BuildContext) builder,
bool isScrollControlled = false,
}) async {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
isScrollControlled: isScrollControlled,
builder: (context) => Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
child: Column(

View File

@ -1,7 +1,7 @@
import 'dart:collection';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/database/card/card.dart';
import 'package:appflowy/plugins/database_view/application/database_controller.dart';
import 'package:appflowy/plugins/database_view/application/field/field_controller.dart';
import 'package:appflowy/plugins/database_view/application/row/row_cache.dart';
@ -10,6 +10,7 @@ import 'package:appflowy/plugins/database_view/board/presentation/widgets/board_
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_type_extension.dart';
import 'package:appflowy/plugins/database_view/tab_bar/tab_bar_view.dart';
import 'package:appflowy/plugins/database_view/widgets/row/row_detail.dart';
import 'package:appflowy/util/platform_extension.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart';
import 'package:appflowy_board/appflowy_board.dart';
@ -21,6 +22,7 @@ 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 'package:go_router/go_router.dart';
import '../../widgets/card/cells/card_cell.dart';
import '../../widgets/card/card_cell_builder.dart';
@ -319,13 +321,23 @@ class _BoardContentState extends State<BoardContent> {
groupId: groupId,
);
FlowyOverlay.show(
context: context,
builder: (_) => RowDetailPage(
cellBuilder: GridCellBuilder(cellCache: dataController.cellCache),
rowController: dataController,
),
);
// navigate to card detail screen when it is in mobile
if (PlatformExtension.isMobile) {
context.push(
MobileCardDetailScreen.routeName,
extra: {
'rowController': dataController,
},
);
} else {
FlowyOverlay.show(
context: context,
builder: (_) => RowDetailPage(
cellBuilder: GridCellBuilder(cellCache: dataController.cellCache),
rowController: dataController,
),
);
}
}
}

View File

@ -123,5 +123,5 @@ class SwitchFieldButton extends StatelessWidget {
}
abstract class TypeOptionWidget extends StatelessWidget {
const TypeOptionWidget({Key? key}) : super(key: key);
const TypeOptionWidget({super.key});
}

View File

@ -4,6 +4,7 @@ import 'package:appflowy/plugins/database_view/application/row/row_controller.da
import 'package:appflowy/plugins/database_view/application/row/row_service.dart';
import 'package:appflowy/plugins/database_view/grid/application/row/row_bloc.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cell_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/row/row_property.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra/theme_extension.dart';
@ -273,7 +274,8 @@ class RowContent extends StatelessWidget {
) {
return cellByFieldId.values.map(
(cellId) {
final GridCellWidget child = builder.build(cellId);
final cellStyle = customCellStyle(cellId.fieldType);
final GridCellWidget child = builder.build(cellId, style: cellStyle);
return CellContainer(
width: cellId.fieldInfo.fieldSettings?.width.toDouble() ?? 140,

View File

@ -2,6 +2,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart';
import 'package:appflowy/plugins/database_view/application/row/row_cache.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/row/action.dart';
import 'package:appflowy/util/platform_extension.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
@ -122,6 +123,27 @@ class _RowCardState<T> extends State<RowCard<T>> {
return !listEquals(previous.cells, current.cells);
},
builder: (context, state) {
// mobile
if (PlatformExtension.isMobile) {
// TODO(yijing): refactor it in mobile to display card in database view
return RowCardContainer(
buildAccessoryWhen: () => state.isEditing == false,
accessoryBuilder: (context) {
return [];
},
openAccessory: (p0) {},
openCard: (context) => widget.openCard(context),
child: _CardContent<T>(
rowNotifier: rowNotifier,
cellBuilder: widget.cellBuilder,
styleConfiguration: widget.styleConfiguration,
cells: state.cells,
renderHook: widget.renderHook,
cardData: widget.cardData,
),
);
}
// desktop
return AppFlowyPopover(
controller: popoverController,
triggerActions: PopoverTriggerFlags.none,

View File

@ -1,4 +1,6 @@
import 'package:appflowy/mobile/presentation/database/card/row/cells/cells.dart';
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
import 'package:appflowy/util/platform_extension.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter/material.dart';
@ -23,7 +25,7 @@ class GridCellBuilder {
GridCellWidget build(
DatabaseCellContext cellContext, {
GridCellStyle? style,
required GridCellStyle? style,
}) {
final cellControllerBuilder = CellControllerBuilder(
cellContext: cellContext,
@ -31,6 +33,30 @@ class GridCellBuilder {
);
final key = cellContext.key();
if (PlatformExtension.isMobile) {
return _getMobileCardCellWidget(
key,
cellContext,
cellControllerBuilder,
style,
);
}
return _getDesktopGridCellWidget(
key,
cellContext,
cellControllerBuilder,
style,
);
}
GridCellWidget _getDesktopGridCellWidget(
ValueKey key,
DatabaseCellContext cellContext,
CellControllerBuilder cellControllerBuilder,
GridCellStyle? style,
) {
switch (cellContext.fieldType) {
case FieldType.Checkbox:
return GridCheckboxCell(
@ -94,6 +120,74 @@ class GridCellBuilder {
}
}
// editable cell/(card's propery value) widget
GridCellWidget _getMobileCardCellWidget(
ValueKey key,
DatabaseCellContext cellContext,
CellControllerBuilder cellControllerBuilder,
GridCellStyle? style,
) {
switch (cellContext.fieldType) {
case FieldType.RichText:
style as GridTextCellStyle;
return MobileTextCell(
cellControllerBuilder: cellControllerBuilder,
hintText: style.placeholder,
);
case FieldType.Number:
style as GridNumberCellStyle;
return MobileNumberCell(
cellControllerBuilder: cellControllerBuilder,
hintText: style.placeholder,
);
case FieldType.LastEditedTime:
case FieldType.CreatedTime:
return MobileTimestampCell(
cellControllerBuilder: cellControllerBuilder,
key: key,
);
case FieldType.Checkbox:
return MobileCheckboxCell(
cellControllerBuilder: cellControllerBuilder,
key: key,
);
case FieldType.DateTime:
style as DateCellStyle;
return MobileDateCell(
cellControllerBuilder: cellControllerBuilder,
hintText: style.placeholder,
key: key,
);
case FieldType.URL:
style as GridURLCellStyle;
return MobileURLCell(
cellControllerBuilder: cellControllerBuilder,
hintText: style.placeholder,
key: key,
);
// TODO(yijing): implement the following mobile select option cell
case FieldType.SingleSelect:
return GridSingleSelectCell(
cellControllerBuilder: cellControllerBuilder,
style: style,
key: key,
);
case FieldType.MultiSelect:
return GridMultiSelectCell(
cellControllerBuilder: cellControllerBuilder,
style: style,
key: key,
);
case FieldType.Checklist:
return GridChecklistCell(
cellControllerBuilder: cellControllerBuilder,
style: style,
key: key,
);
}
throw UnimplementedError;
}
class BlankCell extends StatelessWidget {
const BlankCell({Key? key}) : super(key: key);

View File

@ -38,20 +38,23 @@ class DateCellCalendarBloc
await event.when(
initial: () async => _startListening(),
didReceiveCellUpdate: (DateCellDataPB? cellData) {
final (dateTime, endDateTime, time, endTime, includeTime, isRange) =
_dateDataFromCellData(cellData);
final dateCellData = _dateDataFromCellData(cellData);
final endDay =
isRange == state.isRange && isRange ? endDateTime : null;
dateCellData.isRange == state.isRange && dateCellData.isRange
? dateCellData.endDateTime
: null;
emit(
state.copyWith(
dateTime: dateTime,
time: time,
endDateTime: endDateTime,
endTime: endTime,
includeTime: includeTime,
isRange: isRange,
startDay: isRange ? dateTime : null,
dateTime: dateCellData.dateTime,
timeStr: dateCellData.timeStr,
endDateTime: dateCellData.endDateTime,
endTimeStr: dateCellData.endTimeStr,
includeTime: dateCellData.includeTime,
isRange: dateCellData.isRange,
startDay: dateCellData.isRange ? dateCellData.dateTime : null,
endDay: endDay,
dateStr: dateCellData.dateStr,
endDateStr: dateCellData.endDateStr,
),
);
},
@ -76,21 +79,31 @@ class DateCellCalendarBloc
setIsRange: (isRange) async {
await _updateDateData(isRange: isRange);
},
setTime: (time) async {
await _updateDateData(time: time);
setTime: (timeStr) async {
await _updateDateData(timeStr: timeStr);
},
selectDateRange: (DateTime? start, DateTime? end) async {
if (end == null && state.startDay != null && state.endDay == null) {
final (newStart, newEnd) = state.startDay!.isBefore(start!)
? (state.startDay!, start)
: (start, state.startDay!);
emit(state.copyWith(startDay: null, endDay: null));
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,
@ -98,8 +111,54 @@ class DateCellCalendarBloc
);
}
},
setStartDay: (DateTime startDay) async {
if (state.endDay == null) {
emit(
state.copyWith(
startDay: startDay,
),
);
} else if (startDay.isAfter(state.endDay!)) {
emit(
state.copyWith(
startDay: startDay,
endDay: null,
),
);
} else {
emit(
state.copyWith(
startDay: startDay,
),
);
_updateDateData(date: startDay.date, endDate: state.endDay!.date);
}
},
setEndDay: (DateTime endDay) async {
if (state.startDay == null) {
emit(
state.copyWith(
endDay: endDay,
),
);
} else if (endDay.isBefore(state.startDay!)) {
emit(
state.copyWith(
startDay: null,
endDay: endDay,
),
);
} else {
emit(
state.copyWith(
endDay: endDay,
),
);
_updateDateData(date: state.startDay!.date, endDate: endDay.date);
}
},
setEndTime: (String endTime) async {
await _updateDateData(endTime: endTime);
await _updateDateData(endTimeStr: endTime);
},
setDateFormat: (dateFormat) async {
await _updateTypeOption(emit, dateFormat: dateFormat);
@ -117,30 +176,31 @@ class DateCellCalendarBloc
Future<void> _updateDateData({
DateTime? date,
String? time,
String? timeStr,
DateTime? endDate,
String? endTime,
String? endTimeStr,
bool? includeTime,
bool? isRange,
}) async {
// make sure that not both date and time are updated at the same time
assert(
!(date != null && time != null) || !(endDate != null && endTime != null),
!(date != null && timeStr != null) ||
!(endDate != null && endTimeStr != null),
);
// if not updating the time, use the old time in the state
final String? newTime = time ?? state.time;
final String? newTime = timeStr ?? state.timeStr;
DateTime? newDate;
if (time != null && time.isNotEmpty) {
if (timeStr != null && timeStr.isNotEmpty) {
newDate = state.dateTime ?? DateTime.now();
} else {
newDate = _utcToLocalAndAddCurrentTime(date);
}
// if not updating the time, use the old time in the state
final String? newEndTime = endTime ?? state.endTime;
final String? newEndTime = endTimeStr ?? state.endTimeStr;
DateTime? newEndDate;
if (endTime != null && endTime.isNotEmpty) {
if (endTimeStr != null && endTimeStr.isNotEmpty) {
newEndDate = state.endDateTime ?? DateTime.now();
} else {
newEndDate = _utcToLocalAndAddCurrentTime(endDate);
@ -306,6 +366,12 @@ class DateCellCalendarEvent with _$DateCellCalendarEvent {
DateTime? start,
DateTime? end,
) = _SelectDateRange;
const factory DateCellCalendarEvent.setStartDay(
DateTime startDay,
) = _SetStartDay;
const factory DateCellCalendarEvent.setEndDay(
DateTime endDay,
) = _SetEndDay;
const factory DateCellCalendarEvent.setTime(String time) = _Time;
const factory DateCellCalendarEvent.setEndTime(String endTime) = _EndTime;
const factory DateCellCalendarEvent.setIncludeTime(bool includeTime) =
@ -334,10 +400,12 @@ class DateCellCalendarState with _$DateCellCalendarState {
// cell data from the backend
required DateTime? dateTime,
required DateTime? endDateTime,
required String? time,
required String? endTime,
required String? timeStr,
required String? endTimeStr,
required bool includeTime,
required bool isRange,
required String? dateStr,
required String? endDateStr,
// error and hint text
required String? parseTimeError,
@ -349,18 +417,19 @@ class DateCellCalendarState with _$DateCellCalendarState {
DateTypeOptionPB dateTypeOptionPB,
DateCellDataPB? cellData,
) {
final (dateTime, endDateTime, time, endTime, includeTime, isRange) =
_dateDataFromCellData(cellData);
final dateCellData = _dateDataFromCellData(cellData);
return DateCellCalendarState(
dateTypeOptionPB: dateTypeOptionPB,
startDay: isRange ? dateTime : null,
endDay: isRange ? endDateTime : null,
dateTime: dateTime,
endDateTime: endDateTime,
time: time,
endTime: endTime,
includeTime: includeTime,
isRange: isRange,
startDay: dateCellData.isRange ? dateCellData.dateTime : null,
endDay: dateCellData.isRange ? dateCellData.endDateTime : null,
dateTime: dateCellData.dateTime,
endDateTime: dateCellData.endDateTime,
timeStr: dateCellData.timeStr,
endTimeStr: dateCellData.endTimeStr,
dateStr: dateCellData.dateStr,
endDateStr: dateCellData.endDateStr,
includeTime: dateCellData.includeTime,
isRange: dateCellData.isRange,
parseTimeError: null,
parseEndTimeError: null,
timeHintText: _timeHintText(dateTypeOptionPB),
@ -379,31 +448,78 @@ String _timeHintText(DateTypeOptionPB typeOption) {
}
}
(DateTime?, DateTime?, String?, String?, bool, bool) _dateDataFromCellData(
DateCellData _dateDataFromCellData(
DateCellDataPB? cellData,
) {
// a null DateCellDataPB may be returned, indicating that all the fields are
// their default values: empty strings and false booleans
if (cellData == null) {
return (null, null, null, null, false, false);
return DateCellData(
dateTime: null,
endDateTime: null,
timeStr: null,
endTimeStr: null,
includeTime: false,
isRange: false,
dateStr: null,
endDateStr: null,
);
}
DateTime? dateTime;
String? time;
String? timeStr;
DateTime? endDateTime;
String? endTime;
String? endTimeStr;
String? endDateStr;
if (cellData.hasTimestamp()) {
final timestamp = cellData.timestamp * 1000;
dateTime = DateTime.fromMillisecondsSinceEpoch(timestamp.toInt());
time = cellData.time;
timeStr = cellData.time;
if (cellData.hasEndTimestamp()) {
final endTimestamp = cellData.endTimestamp * 1000;
endDateTime = DateTime.fromMillisecondsSinceEpoch(endTimestamp.toInt());
endTime = cellData.endTime;
endTimeStr = cellData.endTime;
}
}
final bool includeTime = cellData.includeTime;
final bool isRange = cellData.isRange;
return (dateTime, endDateTime, time, endTime, includeTime, isRange);
if (cellData.isRange) {
endDateStr = cellData.endDate;
}
final String dateStr = cellData.date;
return DateCellData(
dateTime: dateTime,
endDateTime: endDateTime,
timeStr: timeStr,
endTimeStr: endTimeStr,
includeTime: includeTime,
isRange: isRange,
dateStr: dateStr,
endDateStr: endDateStr,
);
}
class DateCellData {
final DateTime? dateTime;
final DateTime? endDateTime;
final String? timeStr;
final String? endTimeStr;
final bool includeTime;
final bool isRange;
final String? dateStr;
final String? endDateStr;
DateCellData({
required this.dateTime,
required this.endDateTime,
required this.timeStr,
required this.endTimeStr,
required this.includeTime,
required this.isRange,
required this.dateStr,
required this.endDateStr,
});
}

View File

@ -136,7 +136,7 @@ class StartTextField extends StatelessWidget {
child: state.includeTime
? _TimeTextField(
isEndTime: false,
timeStr: state.time,
timeStr: state.timeStr,
popoverMutex: popoverMutex,
)
: const SizedBox.shrink(),
@ -161,7 +161,7 @@ class EndTextField extends StatelessWidget {
padding: const EdgeInsets.only(top: 8.0),
child: _TimeTextField(
isEndTime: true,
timeStr: state.endTime,
timeStr: state.endTimeStr,
popoverMutex: popoverMutex,
),
)
@ -413,17 +413,17 @@ class _TimeTextFieldState extends State<_TimeTextField> {
return BlocConsumer<DateCellCalendarBloc, DateCellCalendarState>(
listener: (context, state) {
if (widget.isEndTime) {
_textController.text = state.endTime ?? "";
_textController.text = state.endTimeStr ?? "";
} else {
_textController.text = state.time ?? "";
_textController.text = state.timeStr ?? "";
}
},
builder: (context, state) {
String text = "";
if (!widget.isEndTime && state.time != null) {
text = state.time!;
} else if (state.endTime != null) {
text = state.endTime!;
if (!widget.isEndTime && state.timeStr != null) {
text = state.timeStr!;
} else if (state.endTimeStr != null) {
text = state.endTimeStr!;
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),

View File

@ -134,7 +134,7 @@ class _PropertyCellState extends State<_PropertyCell> {
@override
Widget build(BuildContext context) {
final style = _customCellStyle(widget.cellContext.fieldType);
final style = customCellStyle(widget.cellContext.fieldType);
final cell = widget.cellBuilder.build(widget.cellContext, style: style);
final dragThumb = MouseRegion(
@ -247,7 +247,7 @@ class _PropertyCellState extends State<_PropertyCell> {
}
}
GridCellStyle? _customCellStyle(FieldType fieldType) {
GridCellStyle? customCellStyle(FieldType fieldType) {
switch (fieldType) {
case FieldType.Checkbox:
return GridCheckboxCellStyle(

View File

@ -1,3 +1,7 @@
import 'package:appflowy/mobile/presentation/database/card/card.dart';
import 'package:appflowy/mobile/presentation/database/card/card_detail/mobile_create_row_field_screen.dart';
import 'package:appflowy/mobile/presentation/database/card/card_property_edit/card_property_edit_screen.dart';
import 'package:appflowy/mobile/presentation/database/card/row/cells/cells.dart';
import 'package:appflowy/mobile/presentation/database/mobile_board_screen.dart';
import 'package:appflowy/mobile/presentation/database/mobile_calendar_screen.dart';
import 'package:appflowy/mobile/presentation/database/mobile_grid_screen.dart';
@ -7,6 +11,7 @@ import 'package:appflowy/mobile/presentation/setting/font/font_picker_screen.dar
import 'package:appflowy/mobile/presentation/setting/language/language_picker_screen.dart';
import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/code_block/code_language_screen.dart';
import 'package:appflowy/plugins/database_view/grid/application/row/row_detail_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_picker_screen.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/startup/tasks/app_widget.dart';
@ -16,6 +21,7 @@ import 'package:appflowy/util/platform_extension.dart';
import 'package:appflowy/workspace/presentation/home/desktop_home_screen.dart';
import 'package:flowy_infra/time/duration.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
GoRouter generateRouter(Widget child) {
@ -45,6 +51,11 @@ GoRouter generateRouter(Widget child) {
_mobileGridScreenRoute(),
_mobileBoardScreenRoute(),
_mobileCalendarScreenRoute(),
// card detail page
_mobileCardDetailScreenRoute(),
_mobileCardPropertyEditScreenRoute(),
_mobileDateCellEditScreenRoute(),
_mobileCreateRowFieldScreenRoute(),
// home
// MobileHomeSettingPage is outside the bottom navigation bar, thus it is not in the StatefulShellRoute.
@ -415,6 +426,77 @@ GoRoute _mobileCalendarScreenRoute() {
);
}
GoRoute _mobileCardDetailScreenRoute() {
return GoRoute(
path: MobileCardDetailScreen.routeName,
pageBuilder: (context, state) {
final args = state.extra as Map<String, dynamic>;
final rowController = args[MobileCardDetailScreen.argRowController];
return MaterialPage(
child: MobileCardDetailScreen(
rowController: rowController,
),
);
},
);
}
GoRoute _mobileCardPropertyEditScreenRoute() {
return GoRoute(
parentNavigatorKey: AppGlobals.rootNavKey,
path: CardPropertyEditScreen.routeName,
pageBuilder: (context, state) {
final args = state.extra as Map<String, dynamic>;
final cellContext = args[CardPropertyEditScreen.argCellContext];
final rowDetailBloc = args[CardPropertyEditScreen.argRowDetailBloc];
return MaterialPage(
child: BlocProvider.value(
value: rowDetailBloc as RowDetailBloc,
child: CardPropertyEditScreen(
cellContext: cellContext,
),
),
);
},
);
}
GoRoute _mobileDateCellEditScreenRoute() {
return GoRoute(
parentNavigatorKey: AppGlobals.rootNavKey,
path: MobileDateCellEditScreen.routeName,
pageBuilder: (context, state) {
final args = state.extra as Map<String, dynamic>;
final cellController = args[MobileDateCellEditScreen.argCellController];
return MaterialPage(
child: MobileDateCellEditScreen(cellController),
fullscreenDialog: true,
);
},
);
}
GoRoute _mobileCreateRowFieldScreenRoute() {
return GoRoute(
parentNavigatorKey: AppGlobals.rootNavKey,
path: MobileCreateRowFieldScreen.routeName,
pageBuilder: (context, state) {
final args = state.extra as Map<String, dynamic>;
final viewId = args[MobileCreateRowFieldScreen.argViewId];
final typeOption = args[MobileCreateRowFieldScreen.argTypeOption];
return MaterialPage(
child:
MobileCreateRowFieldScreen(viewId: viewId, typeOption: typeOption),
fullscreenDialog: true,
);
},
);
}
GoRoute _rootRoute(Widget child) {
return GoRoute(
path: '/',

View File

@ -239,6 +239,9 @@ class MobileAppearance extends BaseAppearance {
),
colorScheme: colorTheme,
indicatorColor: Colors.blue,
textSelectionTheme: TextSelectionThemeData(
cursorColor: colorTheme.onBackground,
),
extensions: [
AFThemeExtension(
warning: theme.yellow,

View File

@ -108,7 +108,7 @@ dependencies:
hive: ^2.2.3
hive_flutter: ^1.1.0
super_clipboard: ^0.6.3
go_router: ^10.1.2
go_router: ^12.1.1
string_validator: ^1.0.0
unsplash_client: ^2.1.1
flutter_emoji_mart:

View File

@ -497,6 +497,14 @@
"timeFormatTwelveHour": "12 hour",
"timeFormatTwentyFourHour": "24 hour",
"clearDate": "Clear date",
"dateTime": "Date time",
"startDateTime": "Start date time",
"endDateTime": "End date time",
"failedToLoadDate": "Failed to load date value",
"selectTime": "Select time",
"selectDate": "Select date",
"visibility": "Visibility",
"propertyType": "Property type",
"addSelectOption": "Add an option",
"optionTitle": "Options",
"addOption": "Add option",
@ -786,13 +794,20 @@
"collapseTooltip": "Hide the hidden groups",
"expandTooltip": "View the hidden groups"
},
"cardDetail": "Card Detail",
"cardActions": "Card Actions",
"cardDuplicated": "Card has been duplicated",
"cardDeleted": "Card has been deleted",
"menuName": "Board",
"showUngrouped": "Show ungrouped items",
"ungroupedButtonText": "Ungrouped",
"ungroupedButtonTooltip": "Contains cards that don't belong in any group",
"ungroupedItemsTitle": "Click to add to the board",
"groupBy": "Group by",
"referencedBoardPrefix": "View of"
"referencedBoardPrefix": "View of",
"mobile": {
"editURL": "Edit URL"
}
},
"calendar": {
"menuName": "Calendar",