feat: display the titles of a view's ancestors and the view's title on the title bar. (#3898)

* feat: add no pages inside tips

* feat: show view's ancestors (include itself) title on bar

* feat: show view's ancestors (include itself) title on bar

* test: add integration tests

* fix: integration tests
This commit is contained in:
Lucas.Xu 2023-11-09 13:11:13 +08:00 committed by GitHub
parent 42e7317cd4
commit 9586ea0e6f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 507 additions and 70 deletions

View File

@ -1,5 +1,6 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:emoji_mart/emoji_mart.dart';
import 'package:flutter/material.dart';
@ -155,7 +156,11 @@ void main() {
const hand = '👋🏿';
await tester.tapEmoji(hand);
tester.expectToSeeDocumentIcon(hand);
tester.isPageWithIcon(gettingStarted, hand);
tester.expectViewHasIcon(
gettingStarted,
ViewLayoutPB.Document,
hand,
);
});
});
}

View File

@ -53,7 +53,7 @@ void main() {
await tester.favoriteViewByName(names[1]);
expect(
tester.findFavoritePageName(names[1]),
findsNWidgets(1),
findsNWidgets(2),
);
await tester.unfavoriteViewByName(gettingStarted);
@ -99,7 +99,7 @@ void main() {
);
expect(
tester.findFavoritePageName(name),
findsNothing,
findsOneWidget,
);
},
);
@ -127,11 +127,11 @@ void main() {
expect(
find.byWidgetPredicate(
(widget) =>
widget is ViewItem &&
widget is SingleInnerViewItem &&
widget.view.isFavorite &&
widget.categoryType == FolderCategoryType.favorite,
),
findsNWidgets(3),
findsNWidgets(6),
);
await tester.hoverOnPageName(
@ -144,13 +144,8 @@ void main() {
);
expect(
find.byWidgetPredicate(
(widget) =>
widget is ViewItem &&
widget.view.isFavorite &&
widget.categoryType == FolderCategoryType.favorite,
),
findsNWidgets(2),
tester.findAllFavoritePages(),
findsNWidgets(3),
);
await tester.hoverOnPageName(
@ -163,12 +158,7 @@ void main() {
);
expect(
find.byWidgetPredicate(
(widget) =>
widget is ViewItem &&
widget.view.isFavorite &&
widget.categoryType == FolderCategoryType.favorite,
),
tester.findAllFavoritePages(),
findsNothing,
);
},

View File

@ -0,0 +1,76 @@
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../util/base.dart';
import '../util/common_operations.dart';
import '../util/expectation.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
const emoji = '😁';
group('Icon', () {
testWidgets('Update page icon in sidebar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// create document, board, grid and calendar views
for (final value in ViewLayoutPB.values) {
await tester.createNewPageWithName(
name: value.name,
parentName: gettingStarted,
layout: value,
);
// update its icon
await tester.updatePageIconInSidebarByName(
name: value.name,
parentName: gettingStarted,
layout: value,
icon: emoji,
);
tester.expectViewHasIcon(
value.name,
value,
emoji,
);
}
});
testWidgets('Update page icon in title bar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// create document, board, grid and calendar views
for (final value in ViewLayoutPB.values) {
await tester.createNewPageWithName(
name: value.name,
parentName: gettingStarted,
layout: value,
);
// update its icon
await tester.updatePageIconInTitleBarByName(
name: value.name,
layout: value,
icon: emoji,
);
tester.expectViewHasIcon(
value.name,
value,
emoji,
);
tester.expectViewTitleHasIcon(
value.name,
value,
emoji,
);
}
});
});
}

View File

@ -1,8 +1,9 @@
import 'package:integration_test/integration_test.dart';
import 'sidebar_test.dart' as sidebar_test;
import 'sidebar_expand_test.dart' as sidebar_expanded_test;
import 'sidebar_favorites_test.dart' as sidebar_favorite_test;
import 'sidebar_icon_test.dart' as sidebar_icon_test;
import 'sidebar_test.dart' as sidebar_test;
void startTesting() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
@ -11,4 +12,5 @@ void startTesting() {
sidebar_test.main();
sidebar_expanded_test.main();
sidebar_favorite_test.main();
sidebar_icon_test.main();
}

View File

@ -4,6 +4,7 @@ import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart';
import 'package:appflowy/plugins/document/presentation/share/share_button.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/presentation/screens/screens.dart';
@ -14,6 +15,7 @@ import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.
import 'package:appflowy/workspace/presentation/home/menu/view/view_add_button.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_more_action_button.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/settings_language_view.dart';
import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
@ -23,6 +25,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'emoji.dart';
import 'util.dart';
extension CommonOperations on WidgetTester {
@ -442,6 +445,47 @@ extension CommonOperations on WidgetTester {
);
await tapButton(button);
}
// update the page icon in the sidebar
Future<void> updatePageIconInSidebarByName({
required String name,
required String parentName,
required ViewLayoutPB layout,
required String icon,
}) async {
final iconButton = find.descendant(
of: findPageName(
name,
layout: layout,
parentName: parentName,
),
matching:
find.byTooltip(LocaleKeys.document_plugins_cover_changeIcon.tr()),
);
await tapButton(iconButton);
await tapEmoji(icon);
await pumpAndSettle();
}
// update the page icon in the sidebar
Future<void> updatePageIconInTitleBarByName({
required String name,
required ViewLayoutPB layout,
required String icon,
}) async {
await openPage(
name,
layout: layout,
);
final title = find.descendant(
of: find.byType(ViewTitleBar),
matching: find.text(name),
);
await tapButton(title);
await tapButton(find.byType(EmojiPickerButton));
await tapEmoji(icon);
await pumpAndSettle();
}
}
extension ViewLayoutPBTest on ViewLayoutPB {

View File

@ -1,10 +1,14 @@
import 'package:emoji_mart/emoji_mart.dart';
import 'package:flutter_test/flutter_test.dart';
import 'base.dart';
extension EmojiTestExtension on WidgetTester {
Future<void> tapEmoji(String emoji) async {
final emojiWidget = find.text(emoji);
final emojiWidget = find.descendant(
of: find.byType(EmojiPicker),
matching: find.text(emoji),
);
await tapButton(emojiWidget);
}
}

View File

@ -5,6 +5,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emo
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@ -164,7 +165,7 @@ extension Expectation on WidgetTester {
}) {
return find.byWidgetPredicate(
(widget) =>
widget is ViewItem &&
widget is SingleInnerViewItem &&
widget.view.isFavorite &&
widget.categoryType == FolderCategoryType.favorite &&
widget.view.name == name &&
@ -173,6 +174,15 @@ extension Expectation on WidgetTester {
);
}
Finder findAllFavoritePages() {
return find.byWidgetPredicate(
(widget) =>
widget is SingleInnerViewItem &&
widget.view.isFavorite &&
widget.categoryType == FolderCategoryType.favorite,
);
}
Finder findPageName(
String name, {
ViewLayoutPB layout = ViewLayoutPB.Document,
@ -201,12 +211,23 @@ extension Expectation on WidgetTester {
);
}
void isPageWithIcon(String name, String emoji) {
final pageName = findPageName(name);
void expectViewHasIcon(String name, ViewLayoutPB layout, String emoji) {
final pageName = findPageName(
name,
layout: layout,
);
final icon = find.descendant(
of: pageName,
matching: find.text(emoji),
);
expect(icon, findsOneWidget);
}
void expectViewTitleHasIcon(String name, ViewLayoutPB layout, String emoji) {
final icon = find.descendant(
of: find.byType(ViewTitleBar),
matching: find.text(emoji),
);
expect(icon, findsOneWidget);
}
}

View File

@ -3,8 +3,8 @@ import 'package:appflowy/plugins/database_view/widgets/share_button.dart';
import 'package:appflowy/plugins/util.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy/workspace/presentation/widgets/left_bar_item.dart';
import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart';
import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@ -190,7 +190,7 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {
});
@override
Widget get leftBarItem => ViewLeftBarItem(view: notifier.view);
Widget get leftBarItem => ViewTitleBar(view: notifier.view);
@override
Widget tabBarItem(String pluginId) => ViewTabBarItem(view: notifier.view);

View File

@ -9,10 +9,10 @@ import 'package:appflowy/plugins/document/presentation/share/share_button.dart';
import 'package:appflowy/plugins/util.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy/workspace/presentation/widgets/left_bar_item.dart';
import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@ -104,7 +104,7 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
}
@override
Widget get leftBarItem => ViewLeftBarItem(view: view);
Widget get leftBarItem => ViewTitleBar(view: view);
@override
Widget tabBarItem(String pluginId) => ViewTabBarItem(view: notifier.view);

View File

@ -12,8 +12,11 @@ class EmojiPickerButton extends StatelessWidget {
super.key,
required this.emoji,
required this.onSubmitted,
this.emojiPickerSize = const Size(300, 250),
this.emojiPickerSize = const Size(360, 380),
this.emojiSize = 18.0,
this.defaultIcon,
this.offset,
this.direction,
});
final String emoji;
@ -21,6 +24,9 @@ class EmojiPickerButton extends StatelessWidget {
final Size emojiPickerSize;
final void Function(String emoji, PopoverController? controller) onSubmitted;
final PopoverController popoverController = PopoverController();
final Widget? defaultIcon;
final Offset? offset;
final PopoverDirection? direction;
@override
Widget build(BuildContext context) {
@ -32,6 +38,8 @@ class EmojiPickerButton extends StatelessWidget {
width: emojiPickerSize.width,
height: emojiPickerSize.height,
),
offset: offset,
direction: direction ?? PopoverDirection.rightWithTopAligned,
popupBuilder: (context) => Container(
width: emojiPickerSize.width,
height: emojiPickerSize.height,
@ -41,18 +49,24 @@ class EmojiPickerButton extends StatelessWidget {
onExit: () {},
),
),
child: FlowyTextButton(
emoji,
overflow: TextOverflow.visible,
fontSize: emojiSize,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 35.0),
fillColor: Colors.transparent,
mainAxisAlignment: MainAxisAlignment.center,
onPressed: () {
popoverController.show();
},
),
child: emoji.isEmpty && defaultIcon != null
? FlowyButton(
useIntrinsicWidth: true,
text: defaultIcon!,
onTap: () => popoverController.show(),
)
: FlowyTextButton(
emoji,
overflow: TextOverflow.visible,
fontSize: emojiSize,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 35.0),
fillColor: Colors.transparent,
mainAxisAlignment: MainAxisAlignment.center,
onPressed: () {
popoverController.show();
},
),
);
} else {
return FlowyTextButton(

View File

@ -5,6 +5,7 @@ import 'package:appflowy/plugins/database_view/grid/presentation/grid_page.dart'
import 'package:appflowy/plugins/database_view/tar_bar/tab_bar_view.dart';
import 'package:appflowy/plugins/document/document.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:flutter/material.dart';
@ -104,6 +105,24 @@ extension ViewExtension on ViewPB {
}
FlowySvgData get iconData => layout.icon;
Future<List<ViewPB>> getAncestors({bool includeSelf = false}) async {
final ancestors = <ViewPB>[];
if (includeSelf) {
ancestors.add(this);
}
var parent = await ViewBackendService.getView(parentViewId);
while (parent.isLeft()) {
// parent is not null
final view = parent.getLeftOrNull<ViewPB>();
if (view == null) {
break;
}
ancestors.add(view);
parent = await ViewBackendService.getView(view.parentViewId);
}
return ancestors.reversed.toList();
}
}
extension ViewLayoutExtension on ViewLayoutPB {

View File

@ -167,30 +167,52 @@ class InnerViewItem extends StatelessWidget {
);
// if the view is expanded and has child views, render its child views
if (isExpanded && childViews.isNotEmpty) {
final children = childViews.map((childView) {
return ViewItem(
key: ValueKey('${categoryType.name} ${childView.id}'),
parentView: view,
categoryType: categoryType,
isFirstChild: childView.id == childViews.first.id,
view: childView,
level: level + 1,
onSelected: onSelected,
onTertiarySelected: onTertiarySelected,
isDraggable: isDraggable,
leftPadding: leftPadding,
isFeedback: isFeedback,
);
}).toList();
if (isExpanded) {
if (childViews.isNotEmpty) {
final children = childViews.map((childView) {
return ViewItem(
key: ValueKey('${categoryType.name} ${childView.id}'),
parentView: view,
categoryType: categoryType,
isFirstChild: childView.id == childViews.first.id,
view: childView,
level: level + 1,
onSelected: onSelected,
onTertiarySelected: onTertiarySelected,
isDraggable: isDraggable,
leftPadding: leftPadding,
isFeedback: isFeedback,
);
}).toList();
child = Column(
mainAxisSize: MainAxisSize.min,
children: [
child,
...children,
],
);
child = Column(
mainAxisSize: MainAxisSize.min,
children: [
child,
...children,
],
);
} else {
child = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
child,
Container(
height: height,
alignment: Alignment.centerLeft,
child: Padding(
// add 2px to make the text align with the view item
padding: EdgeInsets.only(left: (level + 1) * leftPadding + 2),
child: FlowyText.medium(
LocaleKeys.noPagesInside.tr(),
color: Theme.of(context).hintColor,
),
),
),
],
);
}
}
// wrap the child with DraggableItem if isDraggable is true

View File

@ -3,11 +3,13 @@ import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:flutter/material.dart';
// TODO: Remove this file after the migration is done.
class ViewLeftBarItem extends StatefulWidget {
final ViewPB view;
ViewLeftBarItem({
required this.view,
}) : super(key: ValueKey(view.id));
ViewLeftBarItem({required this.view, Key? key})
: super(key: ValueKey(view.hashCode));
final ViewPB view;
@override
State<ViewLeftBarItem> createState() => _ViewLeftBarItemState();

View File

@ -0,0 +1,236 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_listener.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
// workspaces / ... / view_title
class ViewTitleBar extends StatefulWidget {
const ViewTitleBar({
super.key,
required this.view,
});
final ViewPB view;
@override
State<ViewTitleBar> createState() => _ViewTitleBarState();
}
class _ViewTitleBarState extends State<ViewTitleBar> {
late Future<List<ViewPB>> ancestors;
@override
void initState() {
super.initState();
ancestors = widget.view.getAncestors(
includeSelf: true,
);
}
@override
void didUpdateWidget(covariant ViewTitleBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.view.id != widget.view.id) {
ancestors = widget.view.getAncestors(
includeSelf: true,
);
}
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<ViewPB>>(
future: ancestors,
builder: ((context, snapshot) {
final ancestors = snapshot.data;
if (ancestors == null ||
snapshot.connectionState != ConnectionState.done) {
return const SizedBox.shrink();
}
return Row(
children: _buildViewTitles(ancestors),
);
}),
);
}
List<Widget> _buildViewTitles(List<ViewPB> views) {
final children = <Widget>[];
for (var i = 0; i < views.length; i++) {
final view = views[i];
children.add(
_ViewTitle(
view: view,
behavior: i == views.length - 1
? _ViewTitleBehavior.editable // only the last one is editable
: _ViewTitleBehavior.uneditable, // others are not editable
),
);
if (i != views.length - 1) {
// if not the last one, add a divider
children.add(const FlowyText.regular('/'));
}
}
return children;
}
}
enum _ViewTitleBehavior {
editable,
uneditable,
}
class _ViewTitle extends StatefulWidget {
const _ViewTitle({
required this.view,
this.behavior = _ViewTitleBehavior.editable,
});
final ViewPB view;
final _ViewTitleBehavior behavior;
@override
State<_ViewTitle> createState() => _ViewTitleState();
}
class _ViewTitleState extends State<_ViewTitle> {
final popoverController = PopoverController();
final textEditingController = TextEditingController();
late final viewListener = ViewListener(viewId: widget.view.id);
String name = '';
String icon = '';
@override
void initState() {
super.initState();
name = widget.view.name;
icon = widget.view.icon.value;
_resetTextEditingController();
viewListener.start(
onViewUpdated: (view) {
setState(() {
name = view.name;
icon = view.icon.value;
_resetTextEditingController();
});
},
);
}
@override
void dispose() {
textEditingController.dispose();
popoverController.close();
viewListener.stop();
super.dispose();
}
@override
Widget build(BuildContext context) {
// root view
if (widget.view.parentViewId.isEmpty) {
return Row(
children: [
FlowyText.regular(name),
const HSpace(4.0),
],
);
}
final child = Row(
children: [
FlowyText.regular(
icon,
fontSize: 18.0,
),
const HSpace(2.0),
FlowyText.regular(name),
],
);
if (widget.behavior == _ViewTitleBehavior.uneditable) {
return FlowyButton(
useIntrinsicWidth: true,
onTap: () {
context.read<TabsBloc>().openPlugin(widget.view);
},
text: child,
);
}
return AppFlowyPopover(
constraints: const BoxConstraints(
maxWidth: 300,
maxHeight: 44,
),
controller: popoverController,
direction: PopoverDirection.bottomWithCenterAligned,
offset: const Offset(0, 18),
popupBuilder: (context) {
// icon + textfield
return Row(
mainAxisSize: MainAxisSize.min,
children: [
EmojiPickerButton(
emoji: icon,
defaultIcon: widget.view.defaultIcon(),
direction: PopoverDirection.bottomWithCenterAligned,
offset: const Offset(0, 18),
onSubmitted: (emoji, _) {
ViewBackendService.updateViewIcon(
viewId: widget.view.id,
viewIcon: emoji,
);
popoverController.close();
},
),
const HSpace(4.0),
SizedBox(
height: 36.0,
width: 220,
child: FlowyTextField(
autoFocus: true,
controller: textEditingController,
onSubmitted: (text) {
if (text.isNotEmpty && text != name) {
ViewBackendService.updateView(
viewId: widget.view.id,
name: text,
);
popoverController.close();
}
},
),
),
const HSpace(4.0),
],
);
},
child: FlowyButton(
useIntrinsicWidth: true,
text: child,
),
);
}
void _resetTextEditingController() {
textEditingController
..text = name
..selection = TextSelection(
baseOffset: 0,
extentOffset: name.length,
);
}
}

View File

@ -76,9 +76,11 @@ class FlowyTextFieldState extends State<FlowyTextField> {
if (widget.autoFocus) {
WidgetsBinding.instance.addPostFrameCallback((_) {
focusNode.requestFocus();
controller.selection = TextSelection.fromPosition(
TextPosition(offset: controller.text.length),
);
if (widget.controller == null) {
controller.selection = TextSelection.fromPosition(
TextPosition(offset: controller.text.length),
);
}
});
}
}