fix: kanban UX bugs (#5227)

* chore: improve title editing behavior

* chore: fix editable text field

* chore: fix autoscroll
This commit is contained in:
Richard Shiue 2024-04-30 17:35:03 +08:00 committed by GitHub
parent 33802fa62d
commit f3544375c9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 180 additions and 150 deletions

View File

@ -11,6 +11,8 @@ const uint8_t *sync_event(const uint8_t *input, uintptr_t len);
int32_t set_stream_port(int64_t port);
int32_t set_log_stream_port(int64_t port);
void link_me_please(void);
void rust_log(int64_t level, const char *data);

View File

@ -1,6 +1,6 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/database/board/presentation/widgets/board_column_header.dart';
import 'package:appflowy/plugins/database/widgets/card/container/card_container.dart';
import 'package:appflowy/plugins/database/widgets/card/card.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_board/appflowy_board.dart';
import 'package:flutter/material.dart';
@ -22,13 +22,15 @@ void main() {
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board);
final findFirstCard = find.descendant(
of: find.byType(AppFlowyGroupCard),
matching: find.byType(Text),
);
final firstCard = find.byType(RowCard).first;
Text firstCardText = tester.firstWidget(findFirstCard);
expect(firstCardText.data, defaultFirstCardName);
expect(
find.descendant(
of: firstCard,
matching: find.text(defaultFirstCardName),
),
findsOneWidget,
);
await tester.tap(
find
@ -45,7 +47,7 @@ void main() {
const newCardName = 'Card 4';
await tester.enterText(
find.descendant(
of: find.byType(RowCardContainer),
of: firstCard,
matching: find.byType(TextField),
),
newCardName,
@ -55,8 +57,13 @@ void main() {
await tester.tap(find.byType(AppFlowyBoard));
await tester.pumpAndSettle();
firstCardText = tester.firstWidget(findFirstCard);
expect(firstCardText.data, newCardName);
expect(
find.descendant(
of: find.byType(RowCard).first,
matching: find.text(newCardName),
),
findsOneWidget,
);
});
testWidgets('from footer', (tester) async {
@ -65,13 +72,15 @@ void main() {
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board);
final findLastCard = find.descendant(
of: find.byType(AppFlowyGroupCard),
matching: find.byType(Text),
);
final lastCard = find.byType(RowCard).last;
Text? lastCardText = tester.widgetList(findLastCard).last as Text;
expect(lastCardText.data, defaultLastCardName);
expect(
find.descendant(
of: lastCard,
matching: find.text(defaultLastCardName),
),
findsOneWidget,
);
await tester.tap(
find
@ -81,12 +90,11 @@ void main() {
)
.at(1),
);
await tester.pumpAndSettle();
const newCardName = 'Card 4';
await tester.enterText(
find.descendant(
of: find.byType(RowCardContainer),
of: lastCard,
matching: find.byType(TextField),
),
newCardName,
@ -96,8 +104,13 @@ void main() {
await tester.tap(find.byType(AppFlowyBoard));
await tester.pumpAndSettle();
lastCardText = tester.widgetList(findLastCard).last as Text;
expect(lastCardText.data, newCardName);
expect(
find.descendant(
of: find.byType(RowCard).last,
matching: find.text(newCardName),
),
findsOneWidget,
);
});
});
}

View File

@ -1,10 +1,10 @@
import 'package:appflowy/plugins/database/widgets/card/card.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart';
import 'package:appflowy/plugins/database/widgets/row/row_property.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:appflowy_board/appflowy_board.dart';
import '../../shared/util.dart';
@ -20,7 +20,7 @@ void main() {
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board);
final card1 = find.ancestor(
of: find.text(card1Name),
matching: find.byType(AppFlowyGroupCard),
matching: find.byType(RowCard),
);
final doingGroup = find.text('Doing');
final doingGroupCenter = tester.getCenter(doingGroup);

View File

@ -68,6 +68,8 @@ class CellController<T, D> {
Timer? _loadDataOperation;
Timer? _saveDataOperation;
Completer? _completer;
RowId get rowId => _cellContext.rowId;
String get fieldId => _cellContext.fieldId;
FieldInfo get fieldInfo => _fieldController.getField(_cellContext.fieldId)!;
@ -192,6 +194,7 @@ class CellController<T, D> {
_loadDataOperation?.cancel();
if (debounce) {
_saveDataOperation?.cancel();
_completer = Completer();
_saveDataOperation = Timer(const Duration(milliseconds: 300), () async {
final result = await _cellDataPersistence.save(
viewId: viewId,
@ -199,6 +202,7 @@ class CellController<T, D> {
data: data,
);
onFinish?.call(result);
_completer?.complete();
});
} else {
final result = await _cellDataPersistence.save(
@ -241,6 +245,7 @@ class CellController<T, D> {
);
_loadDataOperation?.cancel();
await _completer?.future;
_saveDataOperation?.cancel();
_cellDataNotifier?.dispose();
_cellDataNotifier = null;

View File

@ -59,7 +59,7 @@ class FieldCellState with _$FieldCellState {
factory FieldCellState.initial(FieldInfo fieldInfo) => FieldCellState(
fieldInfo: fieldInfo,
isResizing: false,
width: fieldInfo.fieldSettings!.width.toDouble(),
width: fieldInfo.width!.toDouble(),
resizeStart: 0,
);

View File

@ -102,11 +102,12 @@ class BoardPage extends StatelessWidget {
)..add(const BoardEvent.initial()),
child: BlocBuilder<BoardBloc, BoardState>(
buildWhen: (p, c) => p.loadingState != c.loadingState,
builder: (context, state) => state.loadingState.map(
loading: (_) => const Center(
builder: (context, state) => state.loadingState.when(
loading: () => const Center(
child: CircularProgressIndicator.adaptive(),
),
finish: (result) => result.successOrFail.fold(
idle: () => const SizedBox.shrink(),
finish: (result) => result.fold(
(_) => PlatformExtension.isMobile
? const MobileBoardContent()
: DesktopBoardContent(onEditStateChanged: onEditStateChanged),
@ -121,7 +122,6 @@ class BoardPage extends StatelessWidget {
howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(),
),
),
idle: (_) => const SizedBox.shrink(),
),
),
);
@ -154,6 +154,10 @@ class _DesktopBoardContentState extends State<DesktopBoardContent> {
stretchGroupHeight: false,
);
late final cellBuilder = CardCellBuilder(
databaseController: context.read<BoardBloc>().databaseController,
);
@override
void dispose() {
scrollController.dispose();
@ -164,7 +168,6 @@ class _DesktopBoardContentState extends State<DesktopBoardContent> {
Widget build(BuildContext context) {
return BlocListener<BoardBloc, BoardState>(
listener: (context, state) {
_handleEditStateChanged(state, context);
widget.onEditStateChanged?.call();
},
child: BlocBuilder<BoardBloc, BoardState>(
@ -182,7 +185,7 @@ class _DesktopBoardContentState extends State<DesktopBoardContent> {
leading: HiddenGroupsColumn(margin: config.groupHeaderPadding),
trailing: showCreateGroupButton
? BoardTrailing(scrollController: scrollController)
: null,
: const HSpace(40),
headerBuilder: (_, groupData) => BlocProvider<BoardBloc>.value(
value: context.read<BoardBloc>(),
child: BoardColumnHeader(
@ -203,16 +206,6 @@ class _DesktopBoardContentState extends State<DesktopBoardContent> {
);
}
void _handleEditStateChanged(BoardState state, BuildContext context) {
if (state.isEditingRow && state.editingRow != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (state.editingRow!.index == null) {
scrollManager.scrollToBottom(state.editingRow!.group.groupId);
}
});
}
}
Widget _buildFooter(BuildContext context, AppFlowyGroupData columnData) {
return Padding(
padding: config.groupFooterPadding,
@ -257,14 +250,13 @@ class _DesktopBoardContentState extends State<DesktopBoardContent> {
final databaseController = boardBloc.databaseController;
final viewId = boardBloc.viewId;
final cellBuilder = CardCellBuilder(databaseController: databaseController);
final isEditing = boardBloc.state.isEditingRow &&
boardBloc.state.editingRow?.row.id == groupItem.row.id;
final groupItemId = "${groupData.group.groupId}${groupItem.row.id}";
final rowMeta = rowInfo?.rowMeta ?? groupItem.row;
return AppFlowyGroupCard(
return Container(
key: ValueKey(groupItemId),
margin: config.cardMargin,
decoration: _makeBoxDecoration(context),
@ -412,52 +404,50 @@ class _BoardTrailingState extends State<BoardTrailing> {
}
});
return Padding(
padding: const EdgeInsets.only(left: 8.0, top: 12),
child: Align(
alignment: AlignmentDirectional.topStart,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: isEditing
? SizedBox(
width: 256,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
controller: _textController,
focusNode: _focusNode,
decoration: InputDecoration(
suffixIcon: Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8.0),
child: FlowyIconButton(
icon: const FlowySvg(FlowySvgs.close_filled_m),
hoverColor: Colors.transparent,
onPressed: () => _textController.clear(),
),
return Container(
padding: const EdgeInsets.only(left: 8.0, top: 12, right: 40),
alignment: AlignmentDirectional.topStart,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: isEditing
? SizedBox(
width: 256,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
controller: _textController,
focusNode: _focusNode,
decoration: InputDecoration(
suffixIcon: Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8.0),
child: FlowyIconButton(
icon: const FlowySvg(FlowySvgs.close_filled_m),
hoverColor: Colors.transparent,
onPressed: () => _textController.clear(),
),
suffixIconConstraints:
BoxConstraints.loose(const Size(20, 24)),
border: const UnderlineInputBorder(),
contentPadding: const EdgeInsets.fromLTRB(8, 4, 8, 8),
isDense: true,
),
style: Theme.of(context).textTheme.bodySmall,
onSubmitted: (groupName) => context
.read<BoardBloc>()
.add(BoardEvent.createGroup(groupName)),
suffixIconConstraints:
BoxConstraints.loose(const Size(20, 24)),
border: const UnderlineInputBorder(),
contentPadding: const EdgeInsets.fromLTRB(8, 4, 8, 8),
isDense: true,
),
),
)
: FlowyTooltip(
message: LocaleKeys.board_column_createNewColumn.tr(),
child: FlowyIconButton(
width: 26,
icon: const FlowySvg(FlowySvgs.add_s),
iconColorOnHover: Theme.of(context).colorScheme.onSurface,
onPressed: () => setState(() => isEditing = true),
style: Theme.of(context).textTheme.bodySmall,
onSubmitted: (groupName) => context
.read<BoardBloc>()
.add(BoardEvent.createGroup(groupName)),
),
),
),
)
: FlowyTooltip(
message: LocaleKeys.board_column_createNewColumn.tr(),
child: FlowyIconButton(
width: 26,
icon: const FlowySvg(FlowySvgs.add_s),
iconColorOnHover: Theme.of(context).colorScheme.onSurface,
onPressed: () => setState(() => isEditing = true),
),
),
),
);
}

View File

@ -45,14 +45,14 @@ class HiddenGroupsColumn extends StatelessWidget {
? SizedBox(
height: 50,
child: Padding(
padding: const EdgeInsets.only(left: 40, right: 8),
padding: const EdgeInsets.only(left: 80, right: 8),
child: Center(
child: _collapseExpandIcon(context, isCollapsed),
),
),
)
: SizedBox(
width: 234,
width: 274,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -60,7 +60,7 @@ class HiddenGroupsColumn extends StatelessWidget {
height: 50,
child: Padding(
padding: EdgeInsets.only(
left: 40 + margin.left,
left: 80 + margin.left,
right: margin.right + 4,
),
child: Row(

View File

@ -167,10 +167,13 @@ class _EventCardState extends State<EventCard> {
),
);
},
child: Container(
padding: widget.padding,
decoration: decoration,
child: card,
child: Material(
color: Colors.transparent,
child: Container(
padding: widget.padding,
decoration: decoration,
child: card,
),
),
);

View File

@ -188,7 +188,8 @@ class _CalendarPageState extends State<CalendarPage> {
return Padding(
padding: PlatformExtension.isMobile
? CalendarSize.contentInsetsMobile
: CalendarSize.contentInsets,
: CalendarSize.contentInsets +
const EdgeInsets.symmetric(horizontal: 40),
child: ScrollConfiguration(
behavior:
ScrollConfiguration.of(context).copyWith(scrollbars: false),

View File

@ -154,7 +154,11 @@ class _GridPageState extends State<GridPage> {
loading: (_) =>
const Center(child: CircularProgressIndicator.adaptive()),
finish: (result) => result.successOrFail.fold(
(_) => GridShortcuts(child: GridPageContent(view: widget.view)),
(_) => GridShortcuts(
child: GridPageContent(
view: widget.view,
),
),
(err) => FlowyErrorPage.message(
err.toString(),
howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(),
@ -234,7 +238,9 @@ class _GridPageContentState extends State<GridPageContent> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_GridHeader(headerScrollController: headerScrollController),
_GridHeader(
headerScrollController: headerScrollController,
),
_GridRows(
viewId: widget.view.id,
scrollController: _scrollController,
@ -498,7 +504,7 @@ class _PositionedCalculationsRowState
left: 0,
right: 0,
child: Container(
margin: EdgeInsets.only(left: GridSize.horizontalHeaderPadding),
margin: EdgeInsets.only(left: GridSize.horizontalHeaderPadding + 40),
padding: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: Theme.of(context).canvasColor,

View File

@ -12,11 +12,12 @@ class GridLayout {
element.visibility != null &&
element.visibility != FieldVisibility.AlwaysHidden,
)
.map((fieldInfo) => fieldInfo.fieldSettings!.width.toDouble())
.map((fieldInfo) => fieldInfo.width!.toDouble())
.reduce((value, element) => value + element);
return fieldsWidth +
GridSize.horizontalHeaderPadding +
40 +
GridSize.trailHeaderPadding;
}
}

View File

@ -37,7 +37,7 @@ class GridCalculationsRow extends StatelessWidget {
key: Key(
'${field.id}-${state.calculationsByFieldId[field.id]?.id}',
),
width: field.fieldSettings!.width.toDouble(),
width: field.width!.toDouble(),
fieldInfo: field,
calculation: state.calculationsByFieldId[field.id],
),

View File

@ -42,9 +42,8 @@ class GridRowBottomBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: GridSize.footerContentInsets,
padding: GridSize.footerContentInsets + const EdgeInsets.only(left: 40),
height: GridSize.footerHeight,
// margin: const EdgeInsets.only(bottom: 8, top: 8),
child: const GridAddRowButton(),
);
}

View File

@ -139,7 +139,7 @@ class _GridHeaderState extends State<_GridHeader> {
}
Widget _cellLeading() {
return SizedBox(width: GridSize.horizontalHeaderPadding);
return SizedBox(width: GridSize.horizontalHeaderPadding + 40);
}
}

View File

@ -112,7 +112,7 @@ class _RowLeadingState extends State<_RowLeading> {
child: Consumer<RegionStateNotifier>(
builder: (context, state, _) {
return SizedBox(
width: GridSize.horizontalHeaderPadding,
width: GridSize.horizontalHeaderPadding + 40,
child: state.onEnter ? _activeWidget() : null,
);
},
@ -122,7 +122,7 @@ class _RowLeadingState extends State<_RowLeading> {
Widget _activeWidget() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.end,
children: [
const InsertRowButton(),
if (isDraggable)
@ -246,7 +246,7 @@ class RowContent extends StatelessWidget {
EditableCellStyle.desktopGrid,
);
return CellContainer(
width: fieldInfo.fieldSettings!.width.toDouble(),
width: fieldInfo.width!.toDouble(),
isPrimary: fieldInfo.field.isPrimary,
accessoryBuilder: (buildContext) {
final builder = child.accessoryBuilder;

View File

@ -24,7 +24,7 @@ class TabBarHeader extends StatelessWidget {
return Container(
height: 30,
padding: EdgeInsets.symmetric(
horizontal: GridSize.horizontalHeaderPadding,
horizontal: GridSize.horizontalHeaderPadding + 40,
),
child: Stack(
children: [

View File

@ -286,4 +286,7 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {
),
);
}
@override
EdgeInsets get contentPadding => const EdgeInsets.only(top: 28);
}

View File

@ -83,7 +83,9 @@ class _TextCellState extends State<TextCardCell> {
void _bindEditableNotifier() {
widget.editableNotifier?.isCellEditing.addListener(() {
if (!mounted) return;
if (!mounted) {
return;
}
final isEditing = widget.editableNotifier?.isCellEditing.value ?? false;
if (isEditing) {
@ -106,15 +108,14 @@ class _TextCellState extends State<TextCardCell> {
return BlocProvider.value(
value: cellBloc,
child: BlocConsumer<TextCellBloc, TextCellState>(
listenWhen: (previous, current) =>
previous.content != current.content && !current.enableEdit,
listener: (context, state) {
if (_textEditingController.text != state.content) {
_textEditingController.text = state.content;
}
_textEditingController.text = state.content;
},
buildWhen: (previous, current) {
if (previous.content != current.content &&
_textEditingController.text == current.content &&
current.enableEdit) {
_textEditingController.text == current.content) {
return false;
}
@ -129,10 +130,10 @@ class _TextCellState extends State<TextCardCell> {
return const SizedBox.shrink();
}
final icon = _buildIcon(state, isTitle);
final child = state.enableEdit || focusWhenInit
? _buildTextField()
: _buildText(state, isTitle);
final icon = isTitle ? _buildIcon(state) : null;
final child = isTitle
? _buildTextField(state.enableEdit || focusWhenInit)
: _buildText(state.content);
return Row(
children: [
@ -156,10 +157,7 @@ class _TextCellState extends State<TextCardCell> {
super.dispose();
}
Widget? _buildIcon(TextCellState state, bool isTitle) {
if (!isTitle) {
return null;
}
Widget? _buildIcon(TextCellState state) {
if (state.emoji.isNotEmpty) {
return Text(
state.emoji,
@ -178,43 +176,52 @@ class _TextCellState extends State<TextCardCell> {
return null;
}
Widget _buildText(TextCellState state, bool isTitle) {
final text = state.content.isEmpty
? isTitle
? LocaleKeys.grid_row_titlePlaceholder.tr()
: LocaleKeys.grid_row_textPlaceholder.tr()
: state.content;
final color = state.content.isEmpty ? Theme.of(context).hintColor : null;
final textStyle =
isTitle ? widget.style.titleTextStyle : widget.style.textStyle;
Widget _buildText(String content) {
final text =
content.isEmpty ? LocaleKeys.grid_row_textPlaceholder.tr() : content;
final color = content.isEmpty ? Theme.of(context).hintColor : null;
return Padding(
padding: widget.style.padding,
child: Text(
text,
style: textStyle.copyWith(color: color),
style: widget.style.textStyle.copyWith(color: color),
maxLines: widget.style.maxLines,
),
);
}
Widget _buildTextField() {
Widget _buildTextField(bool isEditing) {
final padding =
widget.style.padding.add(const EdgeInsets.symmetric(vertical: 4.0));
return TextField(
controller: _textEditingController,
focusNode: focusNode,
onChanged: (_) =>
cellBloc.add(TextCellEvent.updateText(_textEditingController.text)),
onEditingComplete: () => focusNode.unfocus(),
maxLines: null,
style: widget.style.titleTextStyle,
decoration: InputDecoration(
contentPadding: padding,
border: InputBorder.none,
isDense: true,
isCollapsed: true,
hintText: LocaleKeys.grid_row_titlePlaceholder.tr(),
return IgnorePointer(
ignoring: !isEditing,
child: TextField(
controller: _textEditingController,
focusNode: focusNode,
onChanged: (_) {
if (_textEditingController.value.composing.isCollapsed) {
cellBloc.add(TextCellEvent.updateText(_textEditingController.text));
}
},
onEditingComplete: () => focusNode.unfocus(),
maxLines: isEditing ? null : 2,
minLines: 1,
textInputAction: TextInputAction.done,
readOnly: !isEditing,
enableInteractiveSelection: isEditing,
style: widget.style.titleTextStyle,
decoration: InputDecoration(
contentPadding: padding,
border: InputBorder.none,
enabledBorder: InputBorder.none,
isDense: true,
isCollapsed: true,
hintText: LocaleKeys.grid_row_titlePlaceholder.tr(),
hintStyle: widget.style.titleTextStyle.copyWith(
color: Theme.of(context).hintColor,
),
),
),
);
}

View File

@ -56,7 +56,7 @@ CardCellStyleMap desktopBoardCardCellStyleMap(BuildContext context) {
FieldType.RichText: TextCardCellStyle(
padding: padding,
textStyle: textStyle,
maxLines: null,
maxLines: 2,
titleTextStyle: Theme.of(context).textTheme.bodyMedium!.copyWith(
overflow: TextOverflow.ellipsis,
),

View File

@ -180,8 +180,7 @@ class _DatabasePropertyCellState extends State<DatabasePropertyCell> {
return;
}
final newVisiblity =
widget.fieldInfo.fieldSettings!.visibility.toggle();
final newVisiblity = widget.fieldInfo.visibility!.toggle();
context.read<DatabasePropertyBloc>().add(
DatabasePropertyEvent.setFieldVisibility(
widget.fieldInfo.id,

View File

@ -44,11 +44,11 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "15a3a50"
resolved-ref: "15a3a5071ffdb002ffaefda9df343b6800844d8d"
ref: f88b4ce01d2728c05125cbe9170013f4a7c85a31
resolved-ref: f88b4ce01d2728c05125cbe9170013f4a7c85a31
url: "https://github.com/AppFlowy-IO/appflowy-board.git"
source: git
version: "0.1.1"
version: "0.1.2"
appflowy_editor:
dependency: "direct main"
description:
@ -1405,10 +1405,10 @@ packages:
dependency: "direct main"
description:
name: provider
sha256: "9a96a0a19b594dbc5bf0f1f27d2bc67d5f95957359b461cd9feb44ed6ae75096"
sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c
url: "https://pub.dev"
source: hosted
version: "6.1.1"
version: "6.1.2"
pub_semver:
dependency: transitive
description:

View File

@ -41,9 +41,10 @@ dependencies:
flowy_svg:
path: packages/flowy_svg
appflowy_board:
# path: ../../../appflowy-board
git:
url: https://github.com/AppFlowy-IO/appflowy-board.git
ref: 15a3a50
ref: f88b4ce01d2728c05125cbe9170013f4a7c85a31
appflowy_result:
path: packages/appflowy_result
appflowy_editor_plugins: ^0.0.2