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
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() {}
}