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

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