From 9586ea0e6fab60bc365b36c91ca42ae5e15f8873 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Thu, 9 Nov 2023 13:11:13 +0800 Subject: [PATCH] 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 --- .../document_with_cover_image_test.dart | 7 +- .../sidebar/sidebar_favorites_test.dart | 24 +- .../sidebar/sidebar_icon_test.dart | 76 ++++++ .../sidebar/sidebar_test_runner.dart | 4 +- .../util/common_operations.dart | 44 ++++ .../integration_test/util/emoji.dart | 6 +- .../integration_test/util/expectation.dart | 27 +- .../database_view/tar_bar/tab_bar_view.dart | 4 +- .../lib/plugins/document/document.dart | 6 +- .../base/emoji_picker_button.dart | 40 ++- .../workspace/application/view/view_ext.dart | 19 ++ .../home/menu/view/view_item.dart | 68 +++-- .../presentation/widgets/left_bar_item.dart | 8 +- .../presentation/widgets/view_title_bar.dart | 236 ++++++++++++++++++ .../lib/style_widget/text_field.dart | 8 +- 15 files changed, 507 insertions(+), 70 deletions(-) create mode 100644 frontend/appflowy_flutter/integration_test/sidebar/sidebar_icon_test.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/widgets/view_title_bar.dart diff --git a/frontend/appflowy_flutter/integration_test/document/document_with_cover_image_test.dart b/frontend/appflowy_flutter/integration_test/document/document_with_cover_image_test.dart index 360202e0164b3..b2a8d2a690473 100644 --- a/frontend/appflowy_flutter/integration_test/document/document_with_cover_image_test.dart +++ b/frontend/appflowy_flutter/integration_test/document/document_with_cover_image_test.dart @@ -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, + ); }); }); } diff --git a/frontend/appflowy_flutter/integration_test/sidebar/sidebar_favorites_test.dart b/frontend/appflowy_flutter/integration_test/sidebar/sidebar_favorites_test.dart index b0af036eeadd7..4aff4ca836b54 100644 --- a/frontend/appflowy_flutter/integration_test/sidebar/sidebar_favorites_test.dart +++ b/frontend/appflowy_flutter/integration_test/sidebar/sidebar_favorites_test.dart @@ -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, ); }, diff --git a/frontend/appflowy_flutter/integration_test/sidebar/sidebar_icon_test.dart b/frontend/appflowy_flutter/integration_test/sidebar/sidebar_icon_test.dart new file mode 100644 index 0000000000000..e2681c2e95334 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/sidebar/sidebar_icon_test.dart @@ -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, + ); + } + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/sidebar/sidebar_test_runner.dart b/frontend/appflowy_flutter/integration_test/sidebar/sidebar_test_runner.dart index f4d54a2160c01..bf199036a83e2 100644 --- a/frontend/appflowy_flutter/integration_test/sidebar/sidebar_test_runner.dart +++ b/frontend/appflowy_flutter/integration_test/sidebar/sidebar_test_runner.dart @@ -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(); } diff --git a/frontend/appflowy_flutter/integration_test/util/common_operations.dart b/frontend/appflowy_flutter/integration_test/util/common_operations.dart index 7094f4a333bef..ea7232a0fb2f3 100644 --- a/frontend/appflowy_flutter/integration_test/util/common_operations.dart +++ b/frontend/appflowy_flutter/integration_test/util/common_operations.dart @@ -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 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 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 { diff --git a/frontend/appflowy_flutter/integration_test/util/emoji.dart b/frontend/appflowy_flutter/integration_test/util/emoji.dart index a3bfbc02f6e5c..f0e5c693a6158 100644 --- a/frontend/appflowy_flutter/integration_test/util/emoji.dart +++ b/frontend/appflowy_flutter/integration_test/util/emoji.dart @@ -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 tapEmoji(String emoji) async { - final emojiWidget = find.text(emoji); + final emojiWidget = find.descendant( + of: find.byType(EmojiPicker), + matching: find.text(emoji), + ); await tapButton(emojiWidget); } } diff --git a/frontend/appflowy_flutter/integration_test/util/expectation.dart b/frontend/appflowy_flutter/integration_test/util/expectation.dart index ec2f63d30c4c3..582cd85ae3dec 100644 --- a/frontend/appflowy_flutter/integration_test/util/expectation.dart +++ b/frontend/appflowy_flutter/integration_test/util/expectation.dart @@ -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); + } } diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/tar_bar/tab_bar_view.dart b/frontend/appflowy_flutter/lib/plugins/database_view/tar_bar/tab_bar_view.dart index 54d244dc2a946..87355800c84a1 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/tar_bar/tab_bar_view.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/tar_bar/tab_bar_view.dart @@ -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); diff --git a/frontend/appflowy_flutter/lib/plugins/document/document.dart b/frontend/appflowy_flutter/lib/plugins/document/document.dart index 56e6cfe016a19..23b4ae88b8bd2 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/document.dart @@ -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); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart index 61e505cf61436..cc81c1e7f8430 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart @@ -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( diff --git a/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart index 8e4490b9ce900..4b8a155901489 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart @@ -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> getAncestors({bool includeSelf = false}) async { + final ancestors = []; + if (includeSelf) { + ancestors.add(this); + } + var parent = await ViewBackendService.getView(parentViewId); + while (parent.isLeft()) { + // parent is not null + final view = parent.getLeftOrNull(); + if (view == null) { + break; + } + ancestors.add(view); + parent = await ViewBackendService.getView(view.parentViewId); + } + return ancestors.reversed.toList(); + } } extension ViewLayoutExtension on ViewLayoutPB { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart index dac51e7232298..6a49b6a5bdd74 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart @@ -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, + 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, + ], ); - }).toList(); - - 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 diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/left_bar_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/left_bar_item.dart index 7aeed651afe51..b30e5ef931bc3 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/left_bar_item.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/left_bar_item.dart @@ -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 createState() => _ViewLeftBarItemState(); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/view_title_bar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/view_title_bar.dart new file mode 100644 index 0000000000000..4f36c5cb4c3bb --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/view_title_bar.dart @@ -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 createState() => _ViewTitleBarState(); +} + +class _ViewTitleBarState extends State { + late Future> 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>( + 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 _buildViewTitles(List views) { + final children = []; + 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().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, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_field.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_field.dart index efad4a6f304aa..2765c58a9fa11 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_field.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_field.dart @@ -76,9 +76,11 @@ class FlowyTextFieldState extends State { 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), + ); + } }); } }