feat: mobile board view (#4030)

This commit is contained in:
Yijing Huang 2023-11-29 19:01:29 -07:00 committed by GitHub
parent 64aa2ba7e4
commit 6b4d4fef15
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 2002 additions and 69 deletions

View File

@ -1,4 +1,4 @@
import 'package:appflowy/mobile/presentation/database/mobile_board_screen.dart';
import 'package:appflowy/mobile/presentation/database/board/mobile_board_screen.dart';
import 'package:appflowy/mobile/presentation/database/mobile_calendar_screen.dart';
import 'package:appflowy/mobile/presentation/database/mobile_grid_screen.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';

View File

@ -0,0 +1,4 @@
export 'mobile_board_screen.dart';
export 'mobile_board_content.dart';
export 'widgets/mobile_hidden_groups_column.dart';
export 'widgets/mobile_board_trailing.dart';

View File

@ -0,0 +1,284 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/database/board/board.dart';
import 'package:appflowy/mobile/presentation/database/board/widgets/group_card_header.dart';
import 'package:appflowy/mobile/presentation/database/card/card.dart';
import 'package:appflowy/plugins/database_view/application/field/field_controller.dart';
import 'package:appflowy/plugins/database_view/application/row/row_cache.dart';
import 'package:appflowy/plugins/database_view/application/row/row_controller.dart';
import 'package:appflowy/plugins/database_view/board/application/board_bloc.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_type_extension.dart';
import 'package:appflowy/plugins/database_view/widgets/card/card.dart';
import 'package:appflowy/plugins/database_view/widgets/card/card_cell_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/card/cells/card_cell.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_board/appflowy_board.dart';
import 'package:collection/collection.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';
class MobileBoardContent extends StatefulWidget {
const MobileBoardContent({
super.key,
});
@override
State<MobileBoardContent> createState() => _MobileBoardContentState();
}
class _MobileBoardContentState extends State<MobileBoardContent> {
final renderHook = RowCardRenderHook<String>();
late final ScrollController scrollController;
late final AppFlowyBoardScrollController scrollManager;
@override
void initState() {
super.initState();
//mobile may not need this
//scroll to bottom when add a new card
scrollManager = AppFlowyBoardScrollController();
scrollController = ScrollController();
renderHook.addSelectOptionHook((options, groupId, _) {
// The cell should hide if the option id is equal to the groupId.
final isInGroup =
options.where((element) => element.id == groupId).isNotEmpty;
if (isInGroup || options.isEmpty) {
return const SizedBox.shrink();
}
return null;
});
}
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final config = AppFlowyBoardConfig(
groupCornerRadius: 8,
groupBackgroundColor: Theme.of(context).colorScheme.secondary,
groupMargin: const EdgeInsets.fromLTRB(4, 8, 4, 12),
groupHeaderPadding: const EdgeInsets.all(8),
groupBodyPadding: const EdgeInsets.all(4),
groupFooterPadding: const EdgeInsets.all(8),
cardMargin: const EdgeInsets.all(4),
);
return BlocListener<BoardBloc, BoardState>(
listenWhen: (previous, current) =>
previous.recentAddedRowMeta != current.recentAddedRowMeta,
listener: (context, state) {
// when add a new card
// it push to the card detail screen of the new card
final rowCache = context.read<BoardBloc>().getRowCache()!;
context.push(
MobileCardDetailScreen.routeName,
extra: {
MobileCardDetailScreen.argRowController: RowController(
rowMeta: state.recentAddedRowMeta!,
viewId: state.viewId,
rowCache: rowCache,
),
MobileCardDetailScreen.argFieldController:
context.read<BoardBloc>().fieldController,
},
);
},
child: BlocBuilder<BoardBloc, BoardState>(
builder: (context, state) {
final showCreateGroupButton =
context.read<BoardBloc>().groupingFieldType.canCreateNewGroup;
final showHiddenGroups = state.hiddenGroups.isNotEmpty;
return AppFlowyBoard(
boardScrollController: scrollManager,
scrollController: scrollController,
controller: context.read<BoardBloc>().boardController,
groupConstraints: BoxConstraints.tightFor(width: screenWidth * 0.7),
config: config,
leading: showHiddenGroups
? MobileHiddenGroupsColumn(padding: config.groupHeaderPadding)
: const HSpace(16),
trailing: showCreateGroupButton
? const MobileBoardTrailing()
: const HSpace(16),
headerBuilder: (_, groupData) => BlocProvider<BoardBloc>.value(
value: context.read<BoardBloc>(),
child: GroupCardHeader(
groupData: groupData,
),
),
footerBuilder: _buildFooter,
cardBuilder: (_, column, columnItem) => _buildCard(
context: context,
afGroupData: column,
afGroupItem: columnItem,
cardMargin: config.cardMargin,
),
);
},
),
);
}
Widget _buildFooter(BuildContext context, AppFlowyGroupData columnData) {
final style = Theme.of(context);
return SizedBox(
height: 42,
width: double.infinity,
child: TextButton.icon(
style: TextButton.styleFrom(
padding: const EdgeInsets.only(left: 8),
alignment: Alignment.centerLeft,
),
icon: FlowySvg(
FlowySvgs.add_m,
color: style.colorScheme.onSurface,
),
label: Text(
LocaleKeys.board_column_createNewCard.tr(),
style: style.textTheme.bodyMedium?.copyWith(
color: style.colorScheme.onSurface,
),
),
onPressed: () => context
.read<BoardBloc>()
.add(BoardEvent.createBottomRow(columnData.id)),
),
);
}
Widget _buildCard({
required BuildContext context,
required AppFlowyGroupData afGroupData,
required AppFlowyGroupItem afGroupItem,
required EdgeInsets cardMargin,
}) {
final boardBloc = context.read<BoardBloc>();
final groupItem = afGroupItem as GroupItem;
final groupData = afGroupData.customData as GroupData;
final rowMeta = groupItem.row;
final rowCache = boardBloc.getRowCache();
/// Return placeholder widget if the rowCache is null.
if (rowCache == null) return SizedBox.shrink(key: ObjectKey(groupItem));
final cellCache = rowCache.cellCache;
final fieldController = boardBloc.fieldController;
final viewId = boardBloc.viewId;
final cellBuilder = CardCellBuilder<String>(cellCache);
final isEditing = boardBloc.state.isEditingRow &&
boardBloc.state.editingRow?.row.id == groupItem.row.id;
final groupItemId = groupItem.row.id + groupData.group.groupId;
return Container(
key: ValueKey(groupItemId),
margin: cardMargin,
decoration: _makeBoxDecoration(context),
child: RowCard<String>(
rowMeta: rowMeta,
viewId: viewId,
rowCache: rowCache,
cardData: groupData.group.groupId,
groupingFieldId: groupItem.fieldInfo.id,
groupId: groupData.group.groupId,
isEditing: isEditing,
cellBuilder: cellBuilder,
renderHook: renderHook,
openCard: (context) => _openCard(
context: context,
viewId: viewId,
groupId: groupData.group.groupId,
fieldController: fieldController,
rowMeta: rowMeta,
rowCache: rowCache,
),
onStartEditing: () => boardBloc
.add(BoardEvent.startEditingRow(groupData.group, groupItem.row)),
onEndEditing: () =>
boardBloc.add(BoardEvent.endEditingRow(groupItem.row.id)),
styleConfiguration: const RowCardStyleConfiguration(
showAccessory: false,
hoverStyle: null,
cardPadding: EdgeInsets.all(8),
cellPadding: EdgeInsets.zero,
),
),
);
}
BoxDecoration _makeBoxDecoration(BuildContext context) {
return BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border.fromBorderSide(
BorderSide(
color: Theme.of(context).colorScheme.outline.withOpacity(0.5),
),
),
boxShadow: [
// card shadow
BoxShadow(
color: Theme.of(context).colorScheme.outline.withOpacity(0.5),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
);
}
void _openCard({
required BuildContext context,
required String viewId,
required String groupId,
required FieldController fieldController,
required RowMetaPB rowMeta,
required RowCache rowCache,
}) {
final rowInfo = RowInfo(
viewId: viewId,
fields: UnmodifiableListView(fieldController.fieldInfos),
rowMeta: rowMeta,
rowId: rowMeta.id,
);
final dataController = RowController(
rowMeta: rowInfo.rowMeta,
viewId: rowInfo.viewId,
rowCache: rowCache,
groupId: groupId,
);
showModalBottomSheet(
isScrollControlled: true,
context: context,
// To avoid the appbar of [MobileCardDetailScreen] being covered by status bar.
useSafeArea: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(8),
),
),
clipBehavior: Clip.antiAliasWithSaveLayer,
builder: (context) => DraggableScrollableSheet(
expand: false,
initialChildSize: 0.4,
minChildSize: 0.4,
snapSizes: const [0.4, 1],
snap: true,
builder: (context, scrollController) {
return MobileCardDetailScreen(
fieldController: fieldController,
rowController: dataController,
scrollController: scrollController,
isBottomSheet: true,
);
},
),
);
}
}

View File

@ -0,0 +1,175 @@
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_action_widget.dart';
import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_bottom_sheet.dart';
import 'package:appflowy/plugins/database_view/board/application/board_bloc.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_type_extension.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/group.pb.dart';
import 'package:appflowy_board/appflowy_board.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';
// similar to [BoardColumnHeader] in Desktop
class GroupCardHeader extends StatefulWidget {
const GroupCardHeader({
super.key,
required this.groupData,
});
final AppFlowyGroupData groupData;
@override
State<GroupCardHeader> createState() => _GroupCardHeaderState();
}
class _GroupCardHeaderState extends State<GroupCardHeader> {
late final TextEditingController _controller =
TextEditingController.fromValue(
TextEditingValue(
selection: TextSelection.collapsed(
offset: widget.groupData.headerData.groupName.length,
),
text: widget.groupData.headerData.groupName,
),
);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final boardCustomData = widget.groupData.customData as GroupData;
final titleTextStyle = Theme.of(context).textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.w600,
);
return BlocBuilder<BoardBloc, BoardState>(
builder: (context, state) {
Widget title = Text(
widget.groupData.headerData.groupName,
style: titleTextStyle,
overflow: TextOverflow.ellipsis,
);
// header can be edited if it's not default group(no status) and the field type can be edited
if (!boardCustomData.group.isDefault &&
boardCustomData.fieldType.canEditHeader) {
title = GestureDetector(
onTap: () => context
.read<BoardBloc>()
.add(BoardEvent.startEditingHeader(widget.groupData.id)),
child: Text(
widget.groupData.headerData.groupName,
style: titleTextStyle,
overflow: TextOverflow.ellipsis,
),
);
}
if (state.isEditingHeader &&
state.editingHeaderId == widget.groupData.id) {
title = TextField(
controller: _controller,
autofocus: true,
onEditingComplete: () => context.read<BoardBloc>().add(
BoardEvent.endEditingHeader(
widget.groupData.id,
_controller.text,
),
),
maxLines: 1,
style: titleTextStyle,
);
}
return Padding(
padding: const EdgeInsets.only(left: 16),
child: SizedBox(
height: 42,
child: Row(
children: [
_buildHeaderIcon(boardCustomData),
Expanded(child: title),
IconButton(
icon: Icon(
Icons.more_horiz_rounded,
color: Theme.of(context).colorScheme.onSurface,
),
splashRadius: 5,
onPressed: () => showFlowyMobileBottomSheet(
context,
title: LocaleKeys.board_column_groupActions.tr(),
builder: (_) {
return Row(
children: [
Expanded(
child: BottomSheetActionWidget(
svg: FlowySvgs.edit_s,
text: LocaleKeys.board_column_renameColumn.tr(),
onTap: () {
context.read<BoardBloc>().add(
BoardEvent.startEditingHeader(
widget.groupData.id,
),
);
context.pop();
},
),
),
const HSpace(8),
Expanded(
child: BottomSheetActionWidget(
svg: FlowySvgs.hide_s,
text: LocaleKeys.board_column_hideColumn.tr(),
onTap: () {
context.read<BoardBloc>().add(
BoardEvent.toggleGroupVisibility(
widget.groupData.customData.group
as GroupPB,
false,
),
);
context.pop();
},
),
),
],
);
},
),
),
IconButton(
icon: Icon(
Icons.add,
color: Theme.of(context).colorScheme.onSurface,
),
splashRadius: 5,
onPressed: () => context.read<BoardBloc>().add(
BoardEvent.createHeaderRow(widget.groupData.id),
),
),
],
),
),
);
},
);
}
Widget _buildHeaderIcon(GroupData customData) =>
switch (customData.fieldType) {
FieldType.Checkbox => FlowySvg(
customData.asCheckboxGroup()!.isCheck
? FlowySvgs.check_filled_s
: FlowySvgs.uncheck_s,
blendMode: BlendMode.dst,
),
_ => const SizedBox.shrink(),
};
}

View File

@ -0,0 +1,129 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/board/application/board_bloc.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
/// Add new group
class MobileBoardTrailing extends StatefulWidget {
const MobileBoardTrailing({
super.key,
});
@override
State<MobileBoardTrailing> createState() => _MobileBoardTrailingState();
}
class _MobileBoardTrailingState extends State<MobileBoardTrailing> {
final TextEditingController _textController = TextEditingController();
bool isEditing = false;
@override
Widget build(BuildContext context) {
final screenSize = MediaQuery.of(context).size;
final style = Theme.of(context);
return Container(
margin: const EdgeInsets.all(8),
child: SizedBox(
width: screenSize.width * 0.7,
child: isEditing
? DecoratedBox(
decoration: BoxDecoration(
color: style.colorScheme.secondary,
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _textController,
autofocus: true,
decoration: InputDecoration(
suffixIcon: IconButton(
icon: Icon(
Icons.close,
color: style.colorScheme.onBackground,
),
onPressed: () => _textController.clear(),
),
isDense: true,
),
onEditingComplete: () {
context.read<BoardBloc>().add(
BoardEvent.createGroup(
_textController.text,
),
);
_textController.clear();
setState(() {
isEditing = false;
});
},
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
child: Text(
LocaleKeys.button_cancel.tr(),
style: style.textTheme.titleSmall?.copyWith(
color: style.colorScheme.onBackground,
),
),
onPressed: () {
setState(() {
isEditing = false;
});
},
),
TextButton(
child: Text(
LocaleKeys.button_add.tr(),
style: style.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: style.colorScheme.onBackground,
),
),
onPressed: () {
context.read<BoardBloc>().add(
BoardEvent.createGroup(
_textController.text,
),
);
_textController.clear();
setState(() {
isEditing = false;
});
},
),
],
),
],
),
),
)
: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
foregroundColor: style.colorScheme.onBackground,
backgroundColor: style.colorScheme.secondary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
icon: const Icon(Icons.add),
label: Text(
LocaleKeys.board_column_newGroup.tr(),
),
onPressed: () => setState(
() => isEditing = true,
),
),
),
);
}
}

View File

@ -0,0 +1,323 @@
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/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/plugins/database_view/application/database_controller.dart';
import 'package:appflowy/plugins/database_view/application/field/field_info.dart';
import 'package:appflowy/plugins/database_view/application/row/row_cache.dart';
import 'package:appflowy/plugins/database_view/application/row/row_controller.dart';
import 'package:appflowy/plugins/database_view/board/application/board_bloc.dart';
import 'package:appflowy/plugins/database_view/widgets/card/card_cell_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/card/cells/card_cell.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/text_cell/text_cell_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:collection/collection.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';
class MobileHiddenGroupsColumn extends StatelessWidget {
const MobileHiddenGroupsColumn({super.key, required this.padding});
final EdgeInsets padding;
@override
Widget build(BuildContext context) {
final databaseController = context.read<BoardBloc>().databaseController;
return BlocSelector<BoardBloc, BoardState, BoardLayoutSettingPB?>(
selector: (state) => state.layoutSettings,
builder: (context, layoutSettings) {
if (layoutSettings == null) {
return const SizedBox.shrink();
}
final isCollapsed = layoutSettings.collapseHiddenGroups;
return Container(
padding: padding,
child: AnimatedSize(
alignment: AlignmentDirectional.topStart,
curve: Curves.easeOut,
duration: const Duration(milliseconds: 150),
child: isCollapsed
? SizedBox(
height: 50,
child: _collapseExpandIcon(context, isCollapsed),
)
: SizedBox(
width: 180,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Spacer(),
_collapseExpandIcon(context, isCollapsed),
],
),
Text(
LocaleKeys.board_hiddenGroupSection_sectionTitle.tr(),
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: Theme.of(context).colorScheme.tertiary,
),
),
const VSpace(8),
Expanded(
child: MobileHiddenGroupList(
databaseController: databaseController,
),
),
],
),
),
),
);
},
);
}
Widget _collapseExpandIcon(BuildContext context, bool isCollapsed) {
return CircleAvatar(
radius: 20,
backgroundColor: Theme.of(context).colorScheme.secondary,
child: IconButton(
icon: FlowySvg(
isCollapsed
? FlowySvgs.hamburger_s_s
: FlowySvgs.pull_left_outlined_s,
size: isCollapsed ? const Size.square(12) : const Size.square(40),
),
onPressed: () => context
.read<BoardBloc>()
.add(BoardEvent.toggleHiddenSectionVisibility(!isCollapsed)),
),
);
}
}
class MobileHiddenGroupList extends StatelessWidget {
const MobileHiddenGroupList({
super.key,
required this.databaseController,
});
final DatabaseController databaseController;
@override
Widget build(BuildContext context) {
final bloc = context.read<BoardBloc>();
return BlocBuilder<BoardBloc, BoardState>(
builder: (_, state) => ReorderableListView.builder(
itemCount: state.hiddenGroups.length,
itemBuilder: (_, index) => MobileHiddenGroup(
key: ValueKey(state.hiddenGroups[index].groupId),
group: state.hiddenGroups[index],
index: index,
bloc: bloc,
),
physics: const ClampingScrollPhysics(),
onReorder: (oldIndex, newIndex) {
if (oldIndex < newIndex) {
newIndex--;
}
final fromGroupId = state.hiddenGroups[oldIndex].groupId;
final toGroupId = state.hiddenGroups[newIndex].groupId;
bloc.add(BoardEvent.reorderGroup(fromGroupId, toGroupId));
},
),
);
}
}
class MobileHiddenGroup extends StatelessWidget {
const MobileHiddenGroup({
super.key,
required this.group,
required this.index,
required this.bloc,
});
final GroupPB group;
final BoardBloc bloc;
final int index;
@override
Widget build(BuildContext context) {
final databaseController = bloc.databaseController;
final primaryField = databaseController.fieldController.fieldInfos
.firstWhereOrNull((element) => element.isPrimary)!;
return BlocProvider<BoardBloc>.value(
value: bloc,
child: BlocBuilder<BoardBloc, BoardState>(
builder: (context, state) {
final group = state.hiddenGroups.firstWhereOrNull(
(g) => g.groupId == this.group.groupId,
);
if (group == null) {
return const SizedBox.shrink();
}
return ExpansionTile(
tilePadding: EdgeInsets.zero,
childrenPadding: EdgeInsets.zero,
title: Row(
children: [
Expanded(
child: Text(
group.groupName,
style: Theme.of(context).textTheme.bodyMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
GestureDetector(
child: const Padding(
padding: EdgeInsets.all(4),
child: FlowySvg(
FlowySvgs.hide_m,
size: Size.square(20),
),
),
onTap: () => showFlowyMobileConfirmDialog(
context,
title: LocaleKeys.board_mobile_unhideGroup.tr(),
content: LocaleKeys.board_mobile_unhideGroupContent.tr(),
actionButtonTitle: LocaleKeys.button_yes.tr(),
actionButtonColor: Theme.of(context).colorScheme.primary,
onActionButtonPressed: () => context.read<BoardBloc>().add(
BoardEvent.toggleGroupVisibility(
group,
true,
),
),
),
),
],
),
children: [
MobileHiddenGroupItemList(
bloc: bloc,
viewId: databaseController.viewId,
groupId: group.groupId,
primaryField: primaryField,
rowCache: databaseController.rowCache,
),
],
);
},
),
);
}
}
class MobileHiddenGroupItemList extends StatelessWidget {
const MobileHiddenGroupItemList({
required this.bloc,
required this.groupId,
required this.viewId,
required this.primaryField,
required this.rowCache,
super.key,
});
final BoardBloc bloc;
final String groupId;
final String viewId;
final FieldInfo primaryField;
final RowCache rowCache;
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: bloc,
child: BlocBuilder<BoardBloc, BoardState>(
builder: (context, state) {
final group = state.hiddenGroups.firstWhereOrNull(
(g) => g.groupId == groupId,
);
if (group == null) {
return const SizedBox.shrink();
}
final cells = <Widget>[
...group.rows.map(
(item) {
final cellContext = rowCache.loadCells(item)[primaryField.id]!;
final rowController = RowController(
rowMeta: item,
viewId: viewId,
rowCache: rowCache,
);
final renderHook = RowCardRenderHook<String>();
renderHook.addTextCellHook((cellData, _, __) {
return BlocBuilder<TextCellBloc, TextCellState>(
builder: (context, state) {
final text = cellData.isEmpty
? LocaleKeys.grid_row_titlePlaceholder.tr()
: cellData;
if (text.isEmpty) {
return const SizedBox.shrink();
}
return Row(
children: [
if (!cellContext.rowMeta.isDocumentEmpty) ...[
const FlowySvg(FlowySvgs.notes_s),
const HSpace(4),
],
Expanded(
child: Text(
text,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
);
},
);
});
return TextButton(
style: TextButton.styleFrom(
textStyle: Theme.of(context).textTheme.bodyMedium,
foregroundColor: Theme.of(context).colorScheme.onBackground,
visualDensity: VisualDensity.compact,
),
child: CardCellBuilder<String>(rowController.cellCache)
.buildCell(
cellContext: cellContext,
renderHook: renderHook,
hasNotes: !cellContext.rowMeta.isDocumentEmpty,
),
onPressed: () {
context.push(
MobileCardDetailScreen.routeName,
extra: {
MobileCardDetailScreen.argRowController: rowController,
MobileCardDetailScreen.argFieldController:
context.read<BoardBloc>().fieldController,
},
);
},
);
},
),
];
return ListView.builder(
itemBuilder: (context, index) => cells[index],
itemCount: cells.length,
shrinkWrap: true,
physics: const ClampingScrollPhysics(),
);
},
),
);
}
}

View File

@ -0,0 +1,3 @@
export 'group_card_header.dart';
export 'mobile_board_trailing.dart';
export 'mobile_hidden_groups_column.dart';

View File

@ -1 +1,2 @@
export 'card_detail/mobile_card_detail_screen.dart';
export 'card_content/mobile_card_content.dart';

View File

@ -0,0 +1,9 @@
export 'checkbox.dart';
export 'date.dart';
export 'style.dart';
export 'time_stamp.dart';
export 'number.dart';
export 'text.dart';
export 'select_option.dart';
export 'url.dart';
export 'checklist.dart';

View File

@ -0,0 +1,68 @@
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/card/cells/card_cell.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 MobileCheckboxCardCell extends CardCell {
const MobileCheckboxCardCell({
super.key,
required this.cellControllerBuilder,
});
final CellControllerBuilder cellControllerBuilder;
@override
State<MobileCheckboxCardCell> createState() => _CheckboxCellState();
}
class _CheckboxCellState extends State<MobileCheckboxCardCell> {
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
Future<void> dispose() async {
_cellBloc.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cellBloc,
child: BlocBuilder<CheckboxCellBloc, CheckboxCellState>(
buildWhen: (previous, current) =>
previous.isSelected != current.isSelected,
builder: (context, state) {
return Align(
alignment: Alignment.centerLeft,
child: IconButton(
padding: EdgeInsets.zero,
alignment: Alignment.centerLeft,
visualDensity: VisualDensity.compact,
icon: FlowySvg(
state.isSelected
? FlowySvgs.check_filled_s
: FlowySvgs.uncheck_s,
blendMode: BlendMode.dst,
size: const Size.square(24),
),
onPressed: () => context
.read<CheckboxCellBloc>()
.add(const CheckboxCellEvent.select()),
),
);
},
),
);
}
}

View File

@ -0,0 +1,137 @@
import 'package:appflowy/mobile/presentation/database/card/card_content/card_cells/card_cells.dart';
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/card/cells/card_cell.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/checklist_cell/checklist_cell_bloc.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:percent_indicator/linear_percent_indicator.dart';
class MobileChecklistCardCell extends CardCell {
const MobileChecklistCardCell({
super.key,
required this.cellControllerBuilder,
});
final CellControllerBuilder cellControllerBuilder;
@override
State<MobileChecklistCardCell> createState() => _ChecklistCellState();
}
class _ChecklistCellState extends State<MobileChecklistCardCell> {
late final ChecklistCellBloc _cellBloc;
@override
void initState() {
super.initState();
final cellController =
widget.cellControllerBuilder.build() as ChecklistCellController;
_cellBloc = ChecklistCellBloc(cellController: cellController)
..add(const ChecklistCellEvent.initial());
}
@override
void dispose() {
_cellBloc.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
final cellStyle = MobileCardCellStyle(context);
return BlocProvider.value(
value: _cellBloc,
child: BlocBuilder<ChecklistCellBloc, ChecklistCellState>(
builder: (context, state) {
if (state.tasks.isEmpty) {
return const SizedBox.shrink();
}
return Padding(
padding: cellStyle.padding,
child: MobileChecklistProgressBar(
tasks: state.tasks,
percent: state.percent,
),
);
},
),
);
}
}
class MobileChecklistProgressBar extends StatefulWidget {
const MobileChecklistProgressBar({
super.key,
required this.tasks,
required this.percent,
});
final List<ChecklistSelectOption> tasks;
final double percent;
final int segmentLimit = 5;
@override
State<MobileChecklistProgressBar> createState() =>
_MobileChecklistProgresssBarState();
}
class _MobileChecklistProgresssBarState
extends State<MobileChecklistProgressBar> {
@override
Widget build(BuildContext context) {
final cellStyle = MobileCardCellStyle(context);
final numFinishedTasks = widget.tasks.where((e) => e.isSelected).length;
final completedTaskColor = numFinishedTasks == widget.tasks.length
? AFThemeExtension.of(context).success
: Theme.of(context).colorScheme.primary;
return Row(
children: [
Expanded(
child: Row(
children: [
if (widget.tasks.isNotEmpty &&
widget.tasks.length <= widget.segmentLimit)
...List<Widget>.generate(
widget.tasks.length,
(index) => Flexible(
child: Container(
decoration: BoxDecoration(
borderRadius:
const BorderRadius.all(Radius.circular(2)),
color: index < numFinishedTasks
? completedTaskColor
: AFThemeExtension.of(context).progressBarBGColor,
),
margin: const EdgeInsets.symmetric(horizontal: 1),
height: 6.0,
),
),
)
else
Expanded(
child: LinearPercentIndicator(
lineHeight: 6.0,
percent: widget.percent,
padding: EdgeInsets.zero,
progressColor: completedTaskColor,
backgroundColor:
AFThemeExtension.of(context).progressBarBGColor,
barRadius: const Radius.circular(2),
),
),
],
),
),
Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
"${(widget.percent * 100).round()}%",
style: cellStyle.secondaryTextStyle(),
),
),
],
);
}
}

View File

@ -0,0 +1,74 @@
import 'package:appflowy/mobile/presentation/database/card/card_content/card_cells/style.dart';
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/card/cells/card_cell.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';
class MobileDateCardCell<CustomCardData> extends CardCell {
const MobileDateCardCell({
super.key,
required this.cellControllerBuilder,
this.renderHook,
});
final CellControllerBuilder cellControllerBuilder;
final CellRenderHook<dynamic, CustomCardData>? renderHook;
@override
State<MobileDateCardCell> createState() => _DateCellState();
}
class _DateCellState extends State<MobileDateCardCell> {
late final DateCellBloc _cellBloc;
@override
void initState() {
super.initState();
final cellController =
widget.cellControllerBuilder.build() as DateCellController;
_cellBloc = DateCellBloc(cellController: cellController)
..add(const DateCellEvent.initial());
}
@override
Future<void> dispose() async {
_cellBloc.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
final cellStyle = MobileCardCellStyle(context);
return BlocProvider.value(
value: _cellBloc,
child: BlocBuilder<DateCellBloc, DateCellState>(
buildWhen: (previous, current) => previous.dateStr != current.dateStr,
builder: (context, state) {
if (state.dateStr.isEmpty) {
return const SizedBox();
} else {
final Widget? custom = widget.renderHook?.call(
state.data,
widget.cardData,
context,
);
if (custom != null) {
return custom;
}
return Container(
alignment: Alignment.centerLeft,
padding: cellStyle.padding,
child: Text(
state.dateStr,
style: cellStyle.secondaryTextStyle(),
),
);
}
},
),
);
}
}

View File

@ -0,0 +1,76 @@
import 'package:appflowy/mobile/presentation/database/card/card_content/card_cells/style.dart';
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/card/cells/card_cell.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 MobileNumberCardCell<CustomCardData> extends CardCell {
const MobileNumberCardCell({
super.key,
required this.cellControllerBuilder,
CustomCardData? cardData,
this.renderHook,
});
final CellRenderHook<String, CustomCardData>? renderHook;
final CellControllerBuilder cellControllerBuilder;
@override
State<MobileNumberCardCell> createState() => _NumberCellState();
}
class _NumberCellState extends State<MobileNumberCardCell> {
late final NumberCellBloc _cellBloc;
@override
void initState() {
super.initState();
final cellController =
widget.cellControllerBuilder.build() as NumberCellController;
_cellBloc = NumberCellBloc(cellController: cellController)
..add(const NumberCellEvent.initial());
}
@override
Future<void> dispose() async {
_cellBloc.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
final cellStyle = MobileCardCellStyle(context);
return BlocProvider.value(
value: _cellBloc,
child: BlocBuilder<NumberCellBloc, NumberCellState>(
buildWhen: (previous, current) =>
previous.cellContent != current.cellContent,
builder: (context, state) {
if (state.cellContent.isEmpty) {
return const SizedBox();
} else {
final Widget? custom = widget.renderHook?.call(
state.cellContent,
widget.cardData,
context,
);
if (custom != null) {
return custom;
}
return Container(
alignment: Alignment.centerLeft,
padding: cellStyle.padding,
child: Text(
state.cellContent,
style: cellStyle.primaryTextStyle(),
),
);
}
},
),
);
}
}

View File

@ -0,0 +1,147 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/mobile/presentation/database/card/card_content/card_cells/style.dart';
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/card/cells/card_cell.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/select_option_cell/extension.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/select_option_cell/select_option_cell_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option.pb.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class MobileSelectOptionCardCell<CustomCardData> extends CardCell {
const MobileSelectOptionCardCell({
super.key,
required this.cellControllerBuilder,
required CustomCardData? cardData,
this.renderHook,
});
final CellControllerBuilder cellControllerBuilder;
final CellRenderHook<List<SelectOptionPB>, CustomCardData>? renderHook;
@override
State<MobileSelectOptionCardCell> createState() => _SelectOptionCellState();
}
class _SelectOptionCellState extends State<MobileSelectOptionCardCell> {
late final SelectOptionCellBloc _cellBloc;
@override
void initState() {
super.initState();
final cellController =
widget.cellControllerBuilder.build() as SelectOptionCellController;
_cellBloc = SelectOptionCellBloc(cellController: cellController)
..add(const SelectOptionCellEvent.initial());
}
@override
Future<void> dispose() async {
_cellBloc.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
final cellStyle = MobileCardCellStyle(context);
return BlocProvider.value(
value: _cellBloc,
child: BlocBuilder<SelectOptionCellBloc, SelectOptionCellState>(
buildWhen: (previous, current) {
return previous.selectedOptions != current.selectedOptions;
},
builder: (context, state) {
final Widget? custom = widget.renderHook?.call(
state.selectedOptions,
widget.cardData,
context,
);
if (custom != null) {
return custom;
}
final children = state.selectedOptions
.map(
(option) => MobileSelectOptionTag.fromOption(
context: context,
option: option,
),
)
.toList();
return IntrinsicHeight(
child: Padding(
padding: cellStyle.padding,
child: SizedBox.expand(
child: Wrap(spacing: 4, runSpacing: 2, children: children),
),
),
);
},
),
);
}
}
class MobileSelectOptionTag extends StatelessWidget {
const MobileSelectOptionTag({
super.key,
required this.name,
required this.color,
this.onSelected,
this.onRemove,
});
factory MobileSelectOptionTag.fromOption({
required BuildContext context,
required SelectOptionPB option,
VoidCallback? onSelected,
Function(String)? onRemove,
}) {
return MobileSelectOptionTag(
name: option.name,
color: option.color.toColor(context),
onSelected: onSelected,
onRemove: onRemove,
);
}
final String name;
final Color color;
final VoidCallback? onSelected;
final void Function(String)? onRemove;
@override
Widget build(BuildContext context) {
final cellStyle = MobileCardCellStyle(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
name,
style: cellStyle.tagTextStyle(),
),
),
if (onRemove != null) ...[
const HSpace(2),
IconButton(
onPressed: () => onRemove?.call(name),
icon: const FlowySvg(
FlowySvgs.close_s,
),
),
],
],
),
);
}
}

View File

@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
class MobileCardCellStyle {
MobileCardCellStyle(this.context);
BuildContext context;
EdgeInsets get padding => const EdgeInsets.symmetric(
vertical: 4,
);
TextStyle? primaryTextStyle() {
final theme = Theme.of(context);
return theme.textTheme.bodyMedium?.copyWith(
fontSize: 16,
color: theme.colorScheme.onBackground,
);
}
TextStyle? secondaryTextStyle() {
final theme = Theme.of(context);
return theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.tertiary,
fontSize: 14,
);
}
TextStyle? tagTextStyle() {
final theme = Theme.of(context);
return theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onBackground,
fontSize: 12,
);
}
TextStyle? urlTextStyle() {
final theme = Theme.of(context);
return theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.primary,
fontSize: 16,
decoration: TextDecoration.underline,
);
}
}

View File

@ -0,0 +1,75 @@
import 'package:appflowy/mobile/presentation/database/card/card_content/card_cells/style.dart';
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/card/cells/card_cell.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 MobileTextCardCell<CustomCardData> extends CardCell {
const MobileTextCardCell({
super.key,
required this.cellControllerBuilder,
CustomCardData? cardData,
this.renderHook,
});
final CellRenderHook<String, CustomCardData>? renderHook;
final CellControllerBuilder cellControllerBuilder;
@override
State<MobileTextCardCell> createState() => _NumberCellState();
}
class _NumberCellState extends State<MobileTextCardCell> {
late final TextCellBloc _cellBloc;
@override
void initState() {
super.initState();
final cellController =
widget.cellControllerBuilder.build() as TextCellController;
_cellBloc = TextCellBloc(cellController: cellController)
..add(const TextCellEvent.initial());
}
@override
Future<void> dispose() async {
_cellBloc.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
final cellStyle = MobileCardCellStyle(context);
return BlocProvider.value(
value: _cellBloc,
child: BlocBuilder<TextCellBloc, TextCellState>(
buildWhen: (previous, current) => previous.content != current.content,
builder: (context, state) {
if (state.content.isEmpty) {
return const SizedBox();
} else {
final Widget? custom = widget.renderHook?.call(
state.content,
widget.cardData,
context,
);
if (custom != null) {
return custom;
}
return Container(
alignment: Alignment.centerLeft,
padding: cellStyle.padding,
child: Text(
state.content,
style: cellStyle.primaryTextStyle(),
),
);
}
},
),
);
}
}

View File

@ -0,0 +1,73 @@
import 'package:appflowy/mobile/presentation/database/card/card_content/card_cells/style.dart';
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/card/cells/card_cell.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 MobileTimestampCardCell<CustomCardData> extends CardCell {
const MobileTimestampCardCell({
super.key,
required this.cellControllerBuilder,
this.renderHook,
});
final CellControllerBuilder cellControllerBuilder;
final CellRenderHook<dynamic, CustomCardData>? renderHook;
@override
State<MobileTimestampCardCell> createState() => _TimestampCellState();
}
class _TimestampCellState extends State<MobileTimestampCardCell> {
late final TimestampCellBloc _cellBloc;
@override
void initState() {
super.initState();
final cellController =
widget.cellControllerBuilder.build() as TimestampCellController;
_cellBloc = TimestampCellBloc(cellController: cellController)
..add(const TimestampCellEvent.initial());
}
@override
Future<void> dispose() async {
_cellBloc.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
final cellStyle = MobileCardCellStyle(context);
return BlocProvider.value(
value: _cellBloc,
child: BlocBuilder<TimestampCellBloc, TimestampCellState>(
buildWhen: (previous, current) => previous.dateStr != current.dateStr,
builder: (context, state) {
if (state.dateStr.isEmpty) {
return const SizedBox.shrink();
}
final Widget? custom = widget.renderHook?.call(
state.data,
widget.cardData,
context,
);
if (custom != null) {
return custom;
}
return Container(
alignment: Alignment.centerLeft,
padding: cellStyle.padding,
child: Text(
state.dateStr,
style: cellStyle.secondaryTextStyle(),
),
);
},
),
);
}
}

View File

@ -0,0 +1,63 @@
import 'package:appflowy/mobile/presentation/database/card/card_content/card_cells/style.dart';
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/card/cells/card_cell.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/url_cell/url_cell_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class MobileURLCardCell<CustomCardData> extends CardCell {
const MobileURLCardCell({
super.key,
required this.cellControllerBuilder,
});
final CellControllerBuilder cellControllerBuilder;
@override
State<MobileURLCardCell> createState() => _URLCellState();
}
class _URLCellState extends State<MobileURLCardCell> {
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) {
final cellStyle = MobileCardCellStyle(context);
return BlocProvider.value(
value: _cellBloc,
child: BlocBuilder<URLCellBloc, URLCellState>(
buildWhen: (previous, current) => previous.content != current.content,
builder: (context, state) {
if (state.content.isEmpty) {
return const SizedBox();
} else {
return Container(
alignment: Alignment.centerLeft,
padding: cellStyle.padding,
child: Text(
state.content,
style: cellStyle.urlTextStyle(),
),
);
}
},
),
);
}
}

View File

@ -0,0 +1,118 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart';
import 'package:appflowy/plugins/database_view/widgets/card/card.dart';
import 'package:appflowy/plugins/database_view/widgets/card/card_cell_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/card/cells/card_cell.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/text_cell/text_cell_bloc.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 MobileCardContent<CustomCardData> extends StatelessWidget {
const MobileCardContent({
super.key,
required this.cellBuilder,
required this.cells,
required this.cardData,
required this.styleConfiguration,
this.renderHook,
});
final CardCellBuilder<CustomCardData> cellBuilder;
final List<DatabaseCellContext> cells;
final RowCardRenderHook<CustomCardData>? renderHook;
final CustomCardData? cardData;
final RowCardStyleConfiguration styleConfiguration;
@override
Widget build(BuildContext context) {
return Padding(
padding: styleConfiguration.cardPadding,
child: Column(
mainAxisSize: MainAxisSize.min,
children: _makeCells(context, cells),
),
);
}
List<Widget> _makeCells(
BuildContext context,
List<DatabaseCellContext> cells,
) {
final List<Widget> children = [];
cells.asMap().forEach((int index, DatabaseCellContext cellContext) {
Widget child;
if (index == 0) {
// The title cell UI is different with a normal text cell.
// Use render hook to customize its UI
child = _buildTitleCell(cellContext);
} else {
child = Padding(
key: cellContext.key(),
padding: styleConfiguration.cellPadding,
child: cellBuilder.buildCell(
cellContext: cellContext,
cardData: cardData,
renderHook: renderHook,
hasNotes: !cellContext.rowMeta.isDocumentEmpty,
),
);
}
children.add(child);
});
return children;
}
Widget _buildTitleCell(
DatabaseCellContext cellContext,
) {
final renderHook = RowCardRenderHook<String>();
renderHook.addTextCellHook((cellData, _, __) {
return BlocBuilder<TextCellBloc, TextCellState>(
builder: (context, state) {
final text = cellData.isEmpty
? LocaleKeys.grid_row_titlePlaceholder.tr()
: cellData;
final textStyle = Theme.of(context).textTheme.bodyMedium?.copyWith(
color: cellData.isEmpty
? Theme.of(context).colorScheme.onSecondary
: Theme.of(context).colorScheme.onBackground,
fontSize: 20,
);
return Row(
children: [
if (!cellContext.rowMeta.isDocumentEmpty) ...[
const FlowySvg(FlowySvgs.notes_s),
const HSpace(4),
],
Expanded(
child: Text(
text,
style: textStyle,
overflow: TextOverflow.ellipsis,
),
),
],
);
},
);
});
return Padding(
key: cellContext.key(),
padding: styleConfiguration.cellPadding,
child: CardCellBuilder<String>(cellBuilder.cellCache).buildCell(
cellContext: cellContext,
renderHook: renderHook,
hasNotes: !cellContext.rowMeta.isDocumentEmpty,
),
);
}
}

View File

@ -25,6 +25,8 @@ class MobileCardDetailScreen extends StatefulWidget {
const MobileCardDetailScreen({
super.key,
required this.rowController,
this.scrollController,
this.isBottomSheet = false,
required this.fieldController,
});
@ -34,6 +36,8 @@ class MobileCardDetailScreen extends StatefulWidget {
static const argFieldController = 'fieldController';
final RowController rowController;
final ScrollController? scrollController;
final bool isBottomSheet;
final FieldController fieldController;
@override
@ -41,24 +45,16 @@ class MobileCardDetailScreen extends StatefulWidget {
}
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
@ -66,10 +62,18 @@ class _MobileCardDetailScreenState extends State<MobileCardDetailScreen> {
create: (context) => RowDetailBloc(rowController: widget.rowController)
..add(const RowDetailEvent.initial()),
child: Scaffold(
// appbar with duplicate and delete card features
appBar: AppBar(
leading: IconButton(
onPressed: () {
context.pop();
},
icon: Icon(
widget.isBottomSheet ? Icons.close : Icons.arrow_back,
),
),
title: Text(LocaleKeys.board_cardDetail.tr()),
actions: [
// appbar with duplicate and delete card features
BlocProvider<RowActionSheetBloc>(
create: (context) => RowActionSheetBloc(
viewId: widget.rowController.viewId,
@ -135,9 +139,9 @@ class _MobileCardDetailScreenState extends State<MobileCardDetailScreen> {
],
),
body: Padding(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: ListView(
controller: _scrollController,
controller: widget.scrollController,
children: [
BlocProvider<RowBannerBloc>(
create: (context) => RowBannerBloc(
@ -181,7 +185,7 @@ class _MobileCardDetailScreenState extends State<MobileCardDetailScreen> {
RowDocument(
viewId: widget.rowController.viewId,
rowId: widget.rowController.rowId,
scrollController: _scrollController,
scrollController: widget.scrollController ?? ScrollController(),
),
],
),

View File

@ -83,7 +83,7 @@ class _MobileRecentViewState extends State<MobileRecentView> {
color: theme.colorScheme.background,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.outline.withOpacity(0.5),
color: theme.colorScheme.outline,
),
),
child: Stack(

View File

@ -85,7 +85,12 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
startRowId: startRowId,
);
result.fold((_) {}, (err) => Log.error(err));
result.fold(
(rowMeta) {
emit(state.copyWith(recentAddedRowMeta: rowMeta));
},
(err) => Log.error(err),
);
},
createHeaderRow: (String groupId) async {
final result = await databaseController.createRow(
@ -93,7 +98,12 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
fromBeginning: true,
);
result.fold((_) {}, (err) => Log.error(err));
result.fold(
(rowMeta) {
emit(state.copyWith(recentAddedRowMeta: rowMeta));
},
(err) => Log.error(err),
);
},
createGroup: (name) async {
final result = await groupBackendSvc.createGroup(name: name);
@ -527,6 +537,7 @@ class BoardState with _$BoardState {
required BoardLayoutSettingPB? layoutSettings,
String? editingHeaderId,
BoardEditingRow? editingRow,
RowMetaPB? recentAddedRowMeta,
required List<GroupPB> hiddenGroups,
}) = _BoardState;

View File

@ -2,7 +2,8 @@ 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/mobile/presentation/database/board/mobile_board_content.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_state_container.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';
@ -23,7 +24,6 @@ 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/card.dart';
import '../../widgets/card/card_cell_builder.dart';
@ -89,11 +89,19 @@ class BoardPage extends StatelessWidget {
child: CircularProgressIndicator.adaptive(),
),
finish: (result) => result.successOrFail.fold(
(_) => BoardContent(onEditStateChanged: onEditStateChanged),
(err) => FlowyErrorPage.message(
err.toString(),
howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(),
),
(_) => PlatformExtension.isMobile
? const MobileBoardContent()
: DesktopBoardContent(onEditStateChanged: onEditStateChanged),
(err) => PlatformExtension.isMobile
? FlowyMobileStateContainer.error(
emoji: '🛸',
title: LocaleKeys.board_mobile_faildToLoad.tr(),
errorMsg: err.toString(),
)
: FlowyErrorPage.message(
err.toString(),
howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(),
),
),
),
),
@ -101,8 +109,8 @@ class BoardPage extends StatelessWidget {
}
}
class BoardContent extends StatefulWidget {
const BoardContent({
class DesktopBoardContent extends StatefulWidget {
const DesktopBoardContent({
super.key,
this.onEditStateChanged,
});
@ -110,18 +118,18 @@ class BoardContent extends StatefulWidget {
final VoidCallback? onEditStateChanged;
@override
State<BoardContent> createState() => _BoardContentState();
State<DesktopBoardContent> createState() => _DesktopBoardContentState();
}
class _BoardContentState extends State<BoardContent> {
class _DesktopBoardContentState extends State<DesktopBoardContent> {
final renderHook = RowCardRenderHook<String>();
late final ScrollController scrollController;
late final AppFlowyBoardScrollController scrollManager;
final config = const AppFlowyBoardConfig(
groupBackgroundColor: Color(0xffF7F8FC),
headerPadding: EdgeInsets.symmetric(horizontal: 8),
cardPadding: EdgeInsets.symmetric(horizontal: 4, vertical: 3),
groupHeaderPadding: EdgeInsets.symmetric(horizontal: 8),
cardMargin: EdgeInsets.symmetric(horizontal: 4, vertical: 3),
);
@override
@ -162,12 +170,12 @@ class _BoardContentState extends State<BoardContent> {
controller: context.read<BoardBloc>().boardController,
groupConstraints: const BoxConstraints.tightFor(width: 256),
config: const AppFlowyBoardConfig(
groupPadding: EdgeInsets.symmetric(horizontal: 4),
groupItemPadding: EdgeInsets.symmetric(horizontal: 4),
footerPadding: EdgeInsets.fromLTRB(4, 14, 4, 4),
groupMargin: EdgeInsets.symmetric(horizontal: 4),
groupBodyPadding: EdgeInsets.symmetric(horizontal: 4),
groupFooterPadding: EdgeInsets.fromLTRB(4, 14, 4, 4),
stretchGroupHeight: false,
),
leading: HiddenGroupsColumn(margin: config.headerPadding),
leading: HiddenGroupsColumn(margin: config.groupHeaderPadding),
trailing: showCreateGroupButton
? BoardTrailing(scrollController: scrollController)
: null,
@ -175,7 +183,7 @@ class _BoardContentState extends State<BoardContent> {
value: context.read<BoardBloc>(),
child: BoardColumnHeader(
groupData: groupData,
margin: config.headerPadding,
margin: config.groupHeaderPadding,
),
),
footerBuilder: _buildFooter,
@ -204,7 +212,7 @@ class _BoardContentState extends State<BoardContent> {
Widget _buildFooter(BuildContext context, AppFlowyGroupData columnData) {
return AppFlowyGroupFooter(
height: 36,
margin: config.footerPadding,
margin: config.groupFooterPadding,
icon: FlowySvg(
FlowySvgs.add_s,
color: Theme.of(context).hintColor,
@ -244,7 +252,7 @@ class _BoardContentState extends State<BoardContent> {
return AppFlowyGroupCard(
key: ValueKey(groupItemId),
margin: config.cardPadding,
margin: config.cardMargin,
decoration: _makeBoxDecoration(context),
child: RowCard<String>(
rowMeta: rowMeta,
@ -329,25 +337,14 @@ class _BoardContentState extends State<BoardContent> {
groupId: groupId,
);
// navigate to card detail screen when it is in mobile
if (PlatformExtension.isMobile) {
context.push(
MobileCardDetailScreen.routeName,
extra: {
MobileCardDetailScreen.argRowController: dataController,
MobileCardDetailScreen.argFieldController: fieldController,
},
);
} else {
FlowyOverlay.show(
context: context,
builder: (_) => RowDetailPage(
fieldController: fieldController,
cellBuilder: GridCellBuilder(cellCache: dataController.cellCache),
rowController: dataController,
),
);
}
FlowyOverlay.show(
context: context,
builder: (_) => RowDetailPage(
fieldController: fieldController,
cellBuilder: GridCellBuilder(cellCache: dataController.cellCache),
rowController: dataController,
),
);
}
}

View File

@ -1,4 +1,5 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/mobile/presentation/database/card/card.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';
@ -120,14 +121,12 @@ class _RowCardState<T> extends State<RowCard<T>> {
},
builder: (context, state) {
if (PlatformExtension.isMobile) {
// TODO(yijing): refactor it in mobile to display card in database view
return RowCardContainer(
buildAccessoryWhen: () => state.isEditing == false,
accessories: const [],
openAccessory: (p0) {},
openCard: (context) => widget.openCard(context),
child: _CardContent<T>(
rowNotifier: rowNotifier,
child: MobileCardContent<T>(
cellBuilder: widget.cellBuilder,
styleConfiguration: widget.styleConfiguration,
cells: state.cells,

View File

@ -1,4 +1,7 @@
import 'package:appflowy/mobile/presentation/database/card/card_content/card_cells/card_cells.dart';
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/card/cells/timestamp_card_cell.dart';
import 'package:appflowy/util/platform_extension.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:flutter/material.dart';
@ -10,7 +13,6 @@ import 'cells/date_card_cell.dart';
import 'cells/number_card_cell.dart';
import 'cells/select_option_card_cell.dart';
import 'cells/text_card_cell.dart';
import 'cells/timestamp_card_cell.dart';
import 'cells/url_card_cell.dart';
// T represents as the Generic card data
@ -34,6 +36,40 @@ class CardCellBuilder<CustomCardData> {
final key = cellContext.key();
final style = styles?[cellContext.fieldType];
return PlatformExtension.isMobile
? _getMobileCardCellWidget(
key: key,
cellContext: cellContext,
cellControllerBuilder: cellControllerBuilder,
style: style,
cardData: cardData,
cellNotifier: cellNotifier,
renderHook: renderHook,
hasNotes: hasNotes,
)
: _getDesktopCardCellWidget(
key: key,
cellContext: cellContext,
cellControllerBuilder: cellControllerBuilder,
style: style,
cardData: cardData,
cellNotifier: cellNotifier,
renderHook: renderHook,
hasNotes: hasNotes,
);
}
Widget _getDesktopCardCellWidget({
required Key key,
required DatabaseCellContext cellContext,
required CellControllerBuilder cellControllerBuilder,
CardCellStyle? style,
CustomCardData? cardData,
EditableCardNotifier? cellNotifier,
RowCardRenderHook<CustomCardData>? renderHook,
required bool hasNotes,
}) {
switch (cellContext.fieldType) {
case FieldType.Checkbox:
return CheckboxCardCell(
@ -104,4 +140,79 @@ class CardCellBuilder<CustomCardData> {
}
throw UnimplementedError;
}
Widget _getMobileCardCellWidget({
required Key key,
required DatabaseCellContext cellContext,
required CellControllerBuilder cellControllerBuilder,
CardCellStyle? style,
CustomCardData? cardData,
EditableCardNotifier? cellNotifier,
RowCardRenderHook<CustomCardData>? renderHook,
required bool hasNotes,
}) {
switch (cellContext.fieldType) {
case FieldType.Checkbox:
return MobileCheckboxCardCell(
cellControllerBuilder: cellControllerBuilder,
key: key,
);
case FieldType.DateTime:
return MobileDateCardCell<CustomCardData>(
renderHook: renderHook?.renderHook[FieldType.DateTime],
cellControllerBuilder: cellControllerBuilder,
key: key,
);
case FieldType.LastEditedTime:
return MobileTimestampCardCell<CustomCardData>(
renderHook: renderHook?.renderHook[FieldType.LastEditedTime],
cellControllerBuilder: cellControllerBuilder,
key: key,
);
case FieldType.CreatedTime:
return MobileTimestampCardCell<CustomCardData>(
renderHook: renderHook?.renderHook[FieldType.CreatedTime],
cellControllerBuilder: cellControllerBuilder,
key: key,
);
case FieldType.SingleSelect:
return MobileSelectOptionCardCell<CustomCardData>(
renderHook: renderHook?.renderHook[FieldType.SingleSelect],
cellControllerBuilder: cellControllerBuilder,
cardData: cardData,
key: key,
);
case FieldType.MultiSelect:
return MobileSelectOptionCardCell<CustomCardData>(
renderHook: renderHook?.renderHook[FieldType.MultiSelect],
cellControllerBuilder: cellControllerBuilder,
cardData: cardData,
key: key,
);
case FieldType.Checklist:
return MobileChecklistCardCell(
cellControllerBuilder: cellControllerBuilder,
key: key,
);
case FieldType.Number:
return MobileNumberCardCell<CustomCardData>(
renderHook: renderHook?.renderHook[FieldType.Number],
cellControllerBuilder: cellControllerBuilder,
key: key,
);
case FieldType.RichText:
return MobileTextCardCell<CustomCardData>(
key: key,
cardData: cardData,
renderHook: renderHook?.renderHook[FieldType.RichText],
cellControllerBuilder: cellControllerBuilder,
);
case FieldType.URL:
return MobileURLCardCell<CustomCardData>(
cellControllerBuilder: cellControllerBuilder,
key: key,
);
}
throw UnimplementedError;
}
}

View File

@ -15,7 +15,7 @@ typedef CellRenderHook<C, CustomCardData> = Widget? Function(
typedef RenderHookByFieldType<C> = Map<FieldType, CellRenderHook<dynamic, C>>;
/// The [RowCardRenderHook] is used to customize the rendering of the
/// card cell. Each cell has itw own field type. So the [renderHook]
/// card cell. Each cell has its own field type. So the [renderHook]
/// is a map of [FieldType] to [CellRenderHook].
class RowCardRenderHook<CustomCardData> {
final RenderHookByFieldType<CustomCardData> renderHook = {};

View File

@ -1,8 +1,8 @@
import 'package:appflowy/mobile/presentation/database/board/mobile_board_screen.dart';
import 'package:appflowy/mobile/presentation/database/card/card.dart';
import 'package:appflowy/mobile/presentation/database/card/card_detail/mobile_create_row_field_screen.dart';
import 'package:appflowy/mobile/presentation/database/card/card_property_edit/card_property_edit_screen.dart';
import 'package:appflowy/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart';
import 'package:appflowy/mobile/presentation/database/mobile_board_screen.dart';
import 'package:appflowy/mobile/presentation/database/mobile_calendar_events_screen.dart';
import 'package:appflowy/mobile/presentation/database/mobile_calendar_screen.dart';
import 'package:appflowy/mobile/presentation/database/mobile_grid_screen.dart';

View File

@ -37,15 +37,18 @@ class MobileAppearance extends BaseAppearance {
brightness: brightness,
primary: _primaryColor,
onPrimary: Colors.white,
// group card header background color
primaryContainer: const Color(0xffF1F1F4), // primary 20
// group card & property edit background color
secondary: const Color(0xfff7f8fc), // shade 10
onSecondary: _onSecondaryColor,
// hidden group title & card text color
tertiary: const Color(0xff858585), // for light text
error: const Color(0xffFB006D),
onError: const Color(0xffFB006D),
background: Colors.white,
onBackground: _onBackgroundColor,
outline: const Color(0xffE3E3E3), //caption
// outline: const Color(0xffBDC0C5), //caption
outline: const Color(0xffe3e3e3),
outlineVariant: const Color(0xffCBD5E0).withOpacity(0.24),
//Snack bar
surface: Colors.white,

View File

@ -45,8 +45,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "2de4fe0"
resolved-ref: "2de4fe0b0245dcdf2c2bf43410661c28acbcc687"
ref: "93a1b70"
resolved-ref: "93a1b70858801583b5a7edb0b2c06308d7982054"
url: "https://github.com/AppFlowy-IO/appflowy-board.git"
source: git
version: "0.1.1"

View File

@ -40,10 +40,9 @@ dependencies:
flowy_svg:
path: packages/flowy_svg
appflowy_board:
# path: packages/appflowy_board
git:
url: https://github.com/AppFlowy-IO/appflowy-board.git
ref: 2de4fe0
ref: 93a1b70
appflowy_editor:
git:
url: https://github.com/AppFlowy-IO/appflowy-editor.git

View File

@ -228,7 +228,8 @@
"addToFavorites": "Add to favorites",
"rename": "Rename",
"helpCenter": "Help Center",
"OK": "Ok",
"add": "Add",
"yes": "Yes",
"Done": "Done",
"Cancel": "Cancel"
},
@ -813,6 +814,8 @@
"addToColumnTopTooltip": "Add a new card at the top",
"renameColumn": "Rename",
"hideColumn": "Hide",
"groupActions": "Group Actions",
"newGroup": "New Group",
"deleteColumn": "Delete",
"deleteColumnConfirmation": "This will delete this group and all the cards in it.\nAre you sure you want to continue?"
},
@ -836,7 +839,10 @@
"groupBy": "Group by",
"referencedBoardPrefix": "View of",
"mobile": {
"editURL": "Edit URL"
"editURL": "Edit URL",
"unhideGroup": "Unhide group",
"unhideGroupContent": "Are you sure you want to show this group on the board?",
"faildToLoad": "Failed to load board view"
}
},
"calendar": {