feat: support customizing page icon (#3849)

* chore: don't use cache when building release package

* feat: refactor icon widget design

* feat: sync the emoji between page and view

* feat: use cache to store the emoji data to prevent reloading

* feat: customize the emoji item builder

* feat: add i18n and shuffle emoji button

* fix: integration test

* feat: replace emoji picker in Grid and slash menu

* feat: support adding icon on mobile platform

* feat: support adding and removing icon on mobile

* test: add integration tests
This commit is contained in:
Lucas.Xu 2023-11-02 15:24:17 +08:00 committed by GitHub
parent 21d34d1fe0
commit c34a7a92fb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1116 additions and 256 deletions

View File

@ -59,7 +59,6 @@ jobs:
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Install Rust toolchain
uses: actions-rs/toolchain@v1
@ -70,11 +69,6 @@ jobs:
components: rustfmt
profile: minimal
- uses: Swatinem/rust-cache@v2
with:
prefix-key: appflowy-lib-cache
key: ${{ matrix.job.os }}-${{ matrix.job.target }}
- name: Install prerequisites
working-directory: frontend
run: |
@ -151,7 +145,6 @@ jobs:
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Install Rust toolchain
uses: actions-rs/toolchain@v1
@ -162,11 +155,6 @@ jobs:
components: rustfmt
profile: minimal
- uses: Swatinem/rust-cache@v2
with:
prefix-key: appflowy-lib-cache
key: ${{ matrix.job.os }}-${{ matrix.job.target }}
- name: Install prerequisites
working-directory: frontend
run: |
@ -257,7 +245,6 @@ jobs:
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
@ -266,11 +253,6 @@ jobs:
targets: ${{ matrix.job.targets }}
components: rustfmt
- uses: Swatinem/rust-cache@v2
with:
prefix-key: appflowy-lib-cache
key: ${{ matrix.job.os }}-${{ matrix.job.target }}
- name: Install prerequisites
working-directory: frontend
run: |
@ -366,7 +348,6 @@ jobs:
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Install Rust toolchain
uses: actions-rs/toolchain@v1
@ -377,11 +358,6 @@ jobs:
components: rustfmt
profile: minimal
- uses: Swatinem/rust-cache@v2
with:
prefix-key: appflowy-lib-cache
key: ${{ matrix.job.os }}-${{ matrix.job.target }}
- name: Install prerequisites
working-directory: frontend
run: |

View File

@ -42,7 +42,6 @@ void main() {
await tester.hoverRowBanner();
await tester.openEmojiPicker();
await tester.switchToEmojiList();
await tester.tapEmoji('😀');
// After select the emoji, the EmojiButton will show up
@ -60,12 +59,10 @@ void main() {
await tester.openFirstRowDetailPage();
await tester.hoverRowBanner();
await tester.openEmojiPicker();
await tester.switchToEmojiList();
await tester.tapEmoji('😀');
// Update existing selected emoji
await tester.tapButton(find.byType(EmojiButton));
await tester.switchToEmojiList();
await tester.tapEmoji('😅');
// The emoji already displayed in the row banner
@ -89,7 +86,6 @@ void main() {
await tester.openFirstRowDetailPage();
await tester.hoverRowBanner();
await tester.openEmojiPicker();
await tester.switchToEmojiList();
await tester.tapEmoji('😀');
// Remove the emoji

View File

@ -1,4 +1,8 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:emoji_mart/emoji_mart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@ -61,7 +65,6 @@ void main() {
// Insert a document icon
await tester.editor.tapAddIconButton();
await tester.switchToEmojiList();
await tester.tapEmoji('😀');
tester.expectToSeeDocumentIcon('😀');
@ -73,13 +76,11 @@ void main() {
// Add the icon back for further testing
await tester.editor.hoverOnCoverToolbar();
await tester.editor.tapAddIconButton();
await tester.switchToEmojiList();
await tester.tapEmoji('😀');
tester.expectToSeeDocumentIcon('😀');
// Change the document icon
await tester.editor.tapOnIconWidget();
await tester.switchToEmojiList();
await tester.tapEmoji('😅');
tester.expectToSeeDocumentIcon('😅');
@ -102,7 +103,6 @@ void main() {
// Insert a document icon
await tester.editor.tapAddIconButton();
await tester.switchToEmojiList();
await tester.tapEmoji('😀');
// Insert a document cover
@ -116,5 +116,46 @@ void main() {
await tester.editor.hoverOnCoverToolbar();
tester.expectToSeeEmptyDocumentHeaderToolbar();
});
testWidgets('shuffle icon', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.editor.hoverOnCoverToolbar();
await tester.editor.tapAddIconButton();
// click the shuffle button
await tester.tapButton(
find.byTooltip(LocaleKeys.emoji_random.tr()),
);
tester.expectDocumentIconNotNull();
});
testWidgets('change skin tone', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.editor.hoverOnCoverToolbar();
await tester.editor.tapAddIconButton();
final searchEmojiTextField = find.byWidgetPredicate(
(widget) =>
widget is TextField &&
widget.decoration!.hintText == LocaleKeys.emoji_search.tr(),
);
await tester.enterText(
searchEmojiTextField,
'hand',
);
// change skin tone
await tester.editor.changeEmojiSkinTone(EmojiSkinTone.dark);
// select an icon with skin tone
const hand = '👋🏿';
await tester.tapEmoji(hand);
tester.expectToSeeDocumentIcon(hand);
tester.isPageWithIcon(gettingStarted, hand);
});
});
}

View File

@ -1,9 +1,11 @@
import 'dart:async';
import 'dart:io';
import 'package:appflowy/env/env.dart';
import 'package:appflowy/startup/entry_point.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/presentation/presentation.dart';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@ -81,9 +83,15 @@ extension AppFlowyTestBase on WidgetTester {
}
Future<void> waitUntilSignInPageShow() async {
final finder = find.byType(GoButton);
await pumpUntilFound(finder);
expect(finder, findsOneWidget);
if (isCloudEnabled) {
final finder = find.byType(SignInAnonymousButton);
await pumpUntilFound(finder);
expect(finder, findsOneWidget);
} else {
final finder = find.byType(GoButton);
await pumpUntilFound(finder);
expect(finder, findsOneWidget);
}
}
Future<void> pumpUntilFound(

View File

@ -7,6 +7,7 @@ import 'package:appflowy/generated/locale_keys.g.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';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_new_page_button.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/draggable_view_item.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart';
@ -27,8 +28,15 @@ import 'util.dart';
extension CommonOperations on WidgetTester {
/// Tap the GetStart button on the launch page.
Future<void> tapGoButton() async {
// local version
final goButton = find.byType(GoButton);
await tapButton(goButton);
if (goButton.evaluate().isNotEmpty) {
await tapButton(goButton);
} else {
// cloud version
final anonymousButton = find.byType(SignInAnonymousButton);
await tapButton(anonymousButton);
}
if (Platform.isWindows) {
await pumpAndSettle(const Duration(milliseconds: 200));

View File

@ -38,7 +38,6 @@ import 'package:appflowy/plugins/database_view/grid/presentation/widgets/toolbar
import 'package:appflowy/plugins/database_view/tar_bar/tab_bar_header.dart';
import 'package:appflowy/plugins/database_view/tar_bar/tar_bar_add_button.dart';
import 'package:appflowy/plugins/database_view/widgets/database_layout_ext.dart';
import 'package:appflowy/plugins/database_view/widgets/setting/setting_property_list.dart';
import 'package:appflowy/plugins/database_view/widgets/row/accessory/cell_accessory.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/cells.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/checklist_cell/checklist_cell_editor.dart';
@ -53,7 +52,7 @@ import 'package:appflowy/plugins/database_view/widgets/row/row_detail.dart';
import 'package:appflowy/plugins/database_view/widgets/row/row_document.dart';
import 'package:appflowy/plugins/database_view/widgets/row/row_property.dart';
import 'package:appflowy/plugins/database_view/widgets/setting/setting_button.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_menu_item.dart';
import 'package:appflowy/plugins/database_view/widgets/setting/setting_property_list.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
@ -618,7 +617,6 @@ extension AppFlowyDatabaseTest on WidgetTester {
Future<void> openEmojiPicker() async {
await tapButton(find.byType(EmojiPickerButton));
await tapButton(find.byType(EmojiSelectionMenu));
}
Future<void> tapDateCellInRowDetailPage() async {

View File

@ -1,15 +1,18 @@
import 'dart:ui';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
import 'package:appflowy/plugins/base/emoji/emoji_skin_tone.dart';
import 'package:appflowy/plugins/base/icon/icon_picker.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_add_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/custom_cover_picker.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_popover.dart';
import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:easy_localization/easy_localization.dart';
import 'package:emoji_mart/emoji_mart.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
@ -54,7 +57,18 @@ class EditorOperations {
await tester.tapButtonWithName(
LocaleKeys.document_plugins_cover_addIcon.tr(),
);
expect(find.byType(EmojiPopover), findsOneWidget);
expect(find.byType(FlowyEmojiPicker), findsOneWidget);
}
/// Taps on the 'Skin tone' button
///
/// Must call [tapAddIconButton] first.
Future<void> changeEmojiSkinTone(EmojiSkinTone skinTone) async {
await tester.tapButton(
find.byTooltip(LocaleKeys.emoji_selectSkinTone.tr()),
);
final skinToneButton = find.text(EmojiSkinToneWrapper(skinTone).name);
await tester.tapButton(skinToneButton);
}
/// Taps the 'Remove Icon' button in the cover toolbar and the icon popover
@ -62,7 +76,10 @@ class EditorOperations {
Finder button =
find.text(LocaleKeys.document_plugins_cover_removeIcon.tr());
if (isInPicker) {
button = find.descendant(of: find.byType(EmojiPopover), matching: button);
button = find.descendant(
of: find.byType(FlowyIconPicker),
matching: button,
);
}
await tester.tapButton(button);

View File

@ -1,15 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'base.dart';
extension EmojiTestExtension on WidgetTester {
/// Must call [openEmojiPicker] first
Future<void> switchToEmojiList() async {
final icon = find.byIcon(Icons.tag_faces);
await tapButton(icon);
}
Future<void> tapEmoji(String emoji) async {
final emojiWidget = find.text(emoji);
await tapButton(emojiWidget);

View File

@ -108,6 +108,13 @@ extension Expectation on WidgetTester {
expect(iconWidget, findsOneWidget);
}
void expectDocumentIconNotNull() {
final iconWidget = find.byWidgetPredicate(
(widget) => widget is EmojiIconWidget && widget.emoji.isNotEmpty,
);
expect(iconWidget, findsOneWidget);
}
void expectToSeeDocumentCover(CoverType type) {
final findCover = find.byWidgetPredicate(
(widget) => widget is DocumentCover && widget.coverType == type,
@ -193,4 +200,13 @@ extension Expectation on WidgetTester {
matching: findPageName(name, layout: layout),
);
}
void isPageWithIcon(String name, String emoji) {
final pageName = findPageName(name);
final icon = find.descendant(
of: pageName,
matching: find.text(emoji),
);
expect(icon, findsOneWidget);
}
}

View File

@ -106,12 +106,22 @@ class _MobileViewPageState extends State<MobileViewPage> {
}
Widget _buildApp(ViewPB? view, List<Widget> actions, Widget child) {
final icon = view?.icon.value;
return Scaffold(
appBar: AppBar(
titleSpacing: 0,
title: FlowyText.semibold(
view?.name ?? widget.title ?? '',
fontSize: 14.0,
title: Row(
children: [
if (icon != null)
FlowyText(
'$icon ',
fontSize: 22.0,
),
FlowyText.regular(
view?.name ?? widget.title ?? '',
fontSize: 14.0,
),
],
),
leading: AppBarBackButton(
onTap: () => context.pop(),

View File

@ -303,11 +303,8 @@ class _SingleMobileInnerViewItemState extends State<SingleMobileInnerViewItem> {
_buildLeftIcon(),
const HSpace(4),
// icon
SizedBox.square(
dimension: 22,
child: widget.view.defaultIcon(),
),
const HSpace(12),
_buildViewIconButton(),
const HSpace(8),
// title
Expanded(
child: FlowyText.regular(
@ -356,6 +353,19 @@ class _SingleMobileInnerViewItemState extends State<SingleMobileInnerViewItem> {
return child;
}
Widget _buildViewIconButton() {
final icon = widget.view.icon.value.isNotEmpty
? FlowyText(
widget.view.icon.value,
fontSize: 24.0,
)
: SizedBox.square(
dimension: 26.0,
child: widget.view.defaultIcon(),
);
return icon;
}
// > button or · button
// show > if the view is expandable.
// show · if the view can't contain child views.

View File

@ -0,0 +1,95 @@
import 'package:appflowy/plugins/base/emoji/emoji_picker_header.dart';
import 'package:appflowy/plugins/base/emoji/emoji_search_bar.dart';
import 'package:appflowy/plugins/base/emoji/emoji_skin_tone.dart';
import 'package:emoji_mart/emoji_mart.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
// use a global value to store the selected emoji to prevent reloading every time.
EmojiData? _cachedEmojiData;
class FlowyEmojiPicker extends StatefulWidget {
const FlowyEmojiPicker({
super.key,
required this.onEmojiSelected,
});
final EmojiSelectedCallback onEmojiSelected;
@override
State<FlowyEmojiPicker> createState() => _FlowyEmojiPickerState();
}
class _FlowyEmojiPickerState extends State<FlowyEmojiPicker> {
EmojiData? emojiData;
@override
void initState() {
super.initState();
// load the emoji data from cache if it's available
if (_cachedEmojiData != null) {
emojiData = _cachedEmojiData;
} else {
EmojiData.builtIn().then(
(value) {
_cachedEmojiData = value;
setState(() {
emojiData = value;
});
},
);
}
}
@override
Widget build(BuildContext context) {
if (emojiData == null) {
return const Center(
child: SizedBox.square(
dimension: 24.0,
child: CircularProgressIndicator(
strokeWidth: 2.0,
),
),
);
}
return EmojiPicker(
emojiData: emojiData!,
configuration: EmojiPickerConfiguration(
showSectionHeader: true,
showTabs: false,
defaultSkinTone: lastSelectedEmojiSkinTone ?? EmojiSkinTone.none,
),
onEmojiSelected: widget.onEmojiSelected,
headerBuilder: (context, category) {
return FlowyEmojiHeader(
category: category,
);
},
itemBuilder: (context, emojiId, emoji, callback) {
return FlowyIconButton(
iconPadding: const EdgeInsets.all(2.0),
icon: FlowyText(
emoji,
fontSize: 28.0,
),
onPressed: () => callback(emojiId, emoji),
);
},
searchBarBuilder: (context, keyword, skinTone) {
return FlowyEmojiSearchBar(
emojiData: emojiData!,
onKeywordChanged: (value) {
keyword.value = value;
},
onSkinToneChanged: (value) {
skinTone.value = value;
},
onRandomEmojiSelected: widget.onEmojiSelected,
);
},
);
}
}

View File

@ -0,0 +1,48 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:emoji_mart/emoji_mart.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
class FlowyEmojiHeader extends StatelessWidget {
const FlowyEmojiHeader({
super.key,
required this.category,
});
final Category category;
@override
Widget build(BuildContext context) {
if (PlatformExtension.isDesktopOrWeb) {
return Container(
height: 22,
padding: const EdgeInsets.symmetric(horizontal: 8.0),
color: Theme.of(context).cardColor,
child: FlowyText.regular(category.id),
);
} else {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 40,
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 8.0),
color: Theme.of(context).cardColor,
child: Padding(
padding: const EdgeInsets.only(
top: 14.0,
bottom: 4.0,
),
child: FlowyText.regular(category.id),
),
),
const Divider(
height: 1,
thickness: 1,
),
],
);
}
}
}

View File

@ -0,0 +1,41 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:emoji_mart/emoji_mart.dart';
class FlowyEmojiPickerI18n extends EmojiPickerI18n {
@override
String get activity => LocaleKeys.emoji_categories_activities.tr();
@override
String get flags => LocaleKeys.emoji_categories_flags.tr();
@override
String get foods => LocaleKeys.emoji_categories_food.tr();
@override
String get frequent => LocaleKeys.emoji_categories_frequentlyUsed.tr();
@override
String get nature => LocaleKeys.emoji_categories_nature.tr();
@override
String get objects => LocaleKeys.emoji_categories_objects.tr();
@override
String get people => LocaleKeys.emoji_categories_smileys.tr();
@override
String get places => LocaleKeys.emoji_categories_places.tr();
@override
String get search => LocaleKeys.emoji_search.tr();
@override
String get symbols => LocaleKeys.emoji_categories_symbols.tr();
@override
String get searchHintText => LocaleKeys.emoji_search.tr();
@override
String get searchNoResult => LocaleKeys.emoji_noEmojiFound.tr();
}

View File

@ -0,0 +1,22 @@
import 'package:appflowy/plugins/base/icon/icon_picker_page.dart';
import 'package:flutter/material.dart';
class MobileEmojiPickerScreen extends StatelessWidget {
static const routeName = '/emoji_picker';
static const viewId = 'id';
const MobileEmojiPickerScreen({
super.key,
required this.id,
});
/// view id
final String id;
@override
Widget build(BuildContext context) {
return IconPickerPage(
id: id,
);
}
}

View File

@ -0,0 +1,156 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/base/emoji/emoji_skin_tone.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:emoji_mart/emoji_mart.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/material.dart';
typedef EmojiKeywordChangedCallback = void Function(String keyword);
typedef EmojiSkinToneChanged = void Function(EmojiSkinTone skinTone);
class FlowyEmojiSearchBar extends StatefulWidget {
const FlowyEmojiSearchBar({
super.key,
required this.emojiData,
required this.onKeywordChanged,
required this.onSkinToneChanged,
required this.onRandomEmojiSelected,
});
final EmojiData emojiData;
final EmojiKeywordChangedCallback onKeywordChanged;
final EmojiSkinToneChanged onSkinToneChanged;
final EmojiSelectedCallback onRandomEmojiSelected;
@override
State<FlowyEmojiSearchBar> createState() => _FlowyEmojiSearchBarState();
}
class _FlowyEmojiSearchBarState extends State<FlowyEmojiSearchBar> {
final TextEditingController controller = TextEditingController();
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.symmetric(
vertical: 8.0,
horizontal: PlatformExtension.isDesktopOrWeb ? 0.0 : 8.0,
),
child: Row(
children: [
Expanded(
child: _SearchTextField(
onKeywordChanged: widget.onKeywordChanged,
),
),
const HSpace(6.0),
_RandomEmojiButton(
emojiData: widget.emojiData,
onRandomEmojiSelected: widget.onRandomEmojiSelected,
),
const HSpace(6.0),
FlowyEmojiSkinToneSelector(
onEmojiSkinToneChanged: widget.onSkinToneChanged,
),
const HSpace(6.0),
],
),
);
}
}
class _RandomEmojiButton extends StatelessWidget {
const _RandomEmojiButton({
required this.emojiData,
required this.onRandomEmojiSelected,
});
final EmojiData emojiData;
final EmojiSelectedCallback onRandomEmojiSelected;
@override
Widget build(BuildContext context) {
return FlowyTooltip(
message: LocaleKeys.emoji_random.tr(),
child: FlowyButton(
useIntrinsicWidth: true,
text: const Icon(
Icons.shuffle_rounded,
),
onTap: () {
final random = emojiData.random;
onRandomEmojiSelected(
random.$1,
random.$2,
);
},
),
);
}
}
class _SearchTextField extends StatefulWidget {
const _SearchTextField({
required this.onKeywordChanged,
});
final EmojiKeywordChangedCallback onKeywordChanged;
@override
State<_SearchTextField> createState() => _SearchTextFieldState();
}
class _SearchTextFieldState extends State<_SearchTextField> {
final TextEditingController controller = TextEditingController();
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: 32.0,
),
child: FlowyTextField(
autoFocus: true,
hintText: LocaleKeys.emoji_search.tr(),
controller: controller,
onChanged: widget.onKeywordChanged,
prefixIcon: const Padding(
padding: EdgeInsets.only(
left: 8.0,
right: 4.0,
),
child: FlowySvg(
FlowySvgs.search_s,
),
),
prefixIconConstraints: const BoxConstraints(
maxHeight: 18.0,
),
suffixIcon: Padding(
padding: const EdgeInsets.all(4.0),
child: FlowyButton(
text: const FlowySvg(
FlowySvgs.close_lg,
),
margin: EdgeInsets.zero,
useIntrinsicWidth: true,
onTap: () {
controller.clear();
widget.onKeywordChanged('');
},
),
),
),
);
}
}

View File

@ -0,0 +1,112 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:emoji_mart/emoji_mart.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/material.dart';
// use a temporary global value to store last selected skin tone
EmojiSkinTone? lastSelectedEmojiSkinTone;
class FlowyEmojiSkinToneSelector extends StatefulWidget {
const FlowyEmojiSkinToneSelector({
super.key,
required this.onEmojiSkinToneChanged,
});
final EmojiSkinToneChanged onEmojiSkinToneChanged;
@override
State<FlowyEmojiSkinToneSelector> createState() =>
_FlowyEmojiSkinToneSelectorState();
}
class _FlowyEmojiSkinToneSelectorState
extends State<FlowyEmojiSkinToneSelector> {
EmojiSkinTone skinTone = EmojiSkinTone.none;
@override
Widget build(BuildContext context) {
return PopoverActionList<EmojiSkinToneWrapper>(
direction: PopoverDirection.bottomWithCenterAligned,
offset: const Offset(0, 8),
actions: EmojiSkinTone.values
.map((action) => EmojiSkinToneWrapper(action))
.toList(),
buildChild: (controller) {
return FlowyTooltip(
message: LocaleKeys.emoji_selectSkinTone.tr(),
child: FlowyIconButton(
icon: Padding(
// add a left padding to align the emoji center
padding: const EdgeInsets.only(
left: 3.0,
),
child: FlowyText(
lastSelectedEmojiSkinTone?.icon ?? '',
fontSize: 22.0,
),
),
onPressed: () => controller.show(),
),
);
},
onSelected: (action, controller) async {
widget.onEmojiSkinToneChanged(action.inner);
setState(() {
lastSelectedEmojiSkinTone = action.inner;
});
controller.close();
},
);
}
}
class EmojiSkinToneWrapper extends ActionCell {
EmojiSkinToneWrapper(this.inner);
final EmojiSkinTone inner;
Widget? icon(Color iconColor) => null;
@override
String get name {
final String i18n;
switch (inner) {
case EmojiSkinTone.none:
i18n = LocaleKeys.emoji_skinTone_default.tr();
case EmojiSkinTone.light:
i18n = LocaleKeys.emoji_skinTone_light.tr();
case EmojiSkinTone.mediumLight:
i18n = LocaleKeys.emoji_skinTone_mediumLight.tr();
case EmojiSkinTone.medium:
i18n = LocaleKeys.emoji_skinTone_medium.tr();
case EmojiSkinTone.mediumDark:
i18n = LocaleKeys.emoji_skinTone_mediumDark.tr();
case EmojiSkinTone.dark:
i18n = LocaleKeys.emoji_skinTone_dark.tr();
}
return '${inner.icon} $i18n';
}
}
extension on EmojiSkinTone {
String get icon {
switch (this) {
case EmojiSkinTone.none:
return '';
case EmojiSkinTone.light:
return '✋🏻';
case EmojiSkinTone.mediumLight:
return '✋🏼';
case EmojiSkinTone.medium:
return '✋🏽';
case EmojiSkinTone.mediumDark:
return '✋🏾';
case EmojiSkinTone.dark:
return '✋🏿';
}
}
}

View File

@ -0,0 +1,121 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
enum FlowyIconType {
emoji,
icon,
custom;
}
class FlowyIconPicker extends StatefulWidget {
const FlowyIconPicker({
super.key,
required this.onSelected,
});
final void Function(FlowyIconType type, String value) onSelected;
@override
State<FlowyIconPicker> createState() => _FlowyIconPickerState();
}
class _FlowyIconPickerState extends State<FlowyIconPicker>
with SingleTickerProviderStateMixin {
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
// ONLY supports emoji picker for now
return DefaultTabController(
length: 1,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
_buildTabs(context),
const Spacer(),
_RemoveIconButton(
onTap: () {
widget.onSelected(FlowyIconType.icon, '');
},
),
],
),
const Divider(
height: 2,
),
Expanded(
child: TabBarView(
children: [
FlowyEmojiPicker(
onEmojiSelected: (_, emoji) {
widget.onSelected(FlowyIconType.emoji, emoji);
},
),
],
),
),
],
),
);
}
Widget _buildTabs(BuildContext context) {
return Align(
alignment: Alignment.centerLeft,
child: TabBar(
indicatorSize: TabBarIndicatorSize.label,
isScrollable: true,
overlayColor: MaterialStatePropertyAll(
Theme.of(context).colorScheme.secondary,
),
padding: EdgeInsets.zero,
tabs: [
FlowyHover(
style: const HoverStyle(borderRadius: BorderRadius.zero),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 8.0,
),
child: FlowyText(
LocaleKeys.emoji_emojiTab.tr(),
),
),
)
],
),
);
}
}
class _RemoveIconButton extends StatelessWidget {
const _RemoveIconButton({
required this.onTap,
});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 28,
child: FlowyButton(
onTap: onTap,
useIntrinsicWidth: true,
text: FlowyText(
LocaleKeys.document_plugins_cover_removeIcon.tr(),
),
leftIcon: const FlowySvg(FlowySvgs.delete_s),
),
);
}
}

View File

@ -0,0 +1,48 @@
import 'package:appflowy/mobile/presentation/base/app_bar_actions.dart';
import 'package:appflowy/plugins/base/icon/icon_picker.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class IconPickerPage extends StatefulWidget {
const IconPickerPage({
super.key,
required this.id,
});
/// view id
final String id;
@override
State<IconPickerPage> createState() => _IconPickerPageState();
}
class _IconPickerPageState extends State<IconPickerPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
titleSpacing: 0,
title: const FlowyText.semibold(
'Page icon',
fontSize: 14.0,
),
leading: AppBarBackButton(
onTap: () => context.pop(),
),
),
body: SafeArea(
child: FlowyIconPicker(
onSelected: (_, emoji) {
ViewBackendService.updateViewIcon(
viewId: widget.id,
viewIcon: emoji,
);
context.pop();
},
),
),
);
}
}

View File

@ -186,10 +186,9 @@ class _BannerTitleState extends State<_BannerTitle> {
controller: widget.popoverController,
triggerActions: PopoverTriggerFlags.none,
direction: PopoverDirection.bottomWithLeftAligned,
constraints: const BoxConstraints(maxWidth: 380, maxHeight: 300),
popupBuilder: (popoverContext) => _buildEmojiPicker((emoji) {
context
.read<RowBannerBloc>()
.add(RowBannerEvent.setIcon(emoji.emoji));
context.read<RowBannerBloc>().add(RowBannerEvent.setIcon(emoji));
widget.popoverController.close();
}),
child: Row(children: children),
@ -199,7 +198,7 @@ class _BannerTitleState extends State<_BannerTitle> {
}
}
typedef OnSubmittedEmoji = void Function(Emoji emoji);
typedef OnSubmittedEmoji = void Function(String emoji);
const _kBannerActionHeight = 40.0;
class EmojiButton extends StatelessWidget {
@ -286,12 +285,9 @@ class RemoveEmojiButton extends StatelessWidget {
}
Widget _buildEmojiPicker(OnSubmittedEmoji onSubmitted) {
return SizedBox(
height: 250,
child: EmojiSelectionMenu(
onSubmitted: onSubmitted,
onExit: () {},
),
return EmojiSelectionMenu(
onSubmitted: onSubmitted,
onExit: () {},
);
}

View File

@ -12,6 +12,7 @@ import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/base64_string.dart';
import 'package:appflowy/workspace/application/notifications/notification_action.dart';
import 'package:appflowy/workspace/application/notifications/notification_action_bloc.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart'
@ -111,9 +112,7 @@ class _DocumentPageState extends State<DocumentPage> {
styleCustomizer: EditorStyleCustomizer(
context: context,
// the 44 is the width of the left action list
padding: PlatformExtension.isMobile
? const EdgeInsets.only(left: 20, right: 20)
: const EdgeInsets.only(left: 40, right: 40 + 44),
padding: EditorStyleCustomizer.documentPadding,
),
header: _buildCoverAndIcon(context),
);
@ -140,6 +139,13 @@ class _DocumentPageState extends State<DocumentPage> {
return DocumentHeaderNodeWidget(
node: page,
editorState: editorState!,
view: widget.view,
onIconChanged: (icon) async {
await ViewBackendService.updateViewIcon(
viewId: widget.view.id,
viewIcon: icon,
);
},
);
}

View File

@ -15,7 +15,7 @@ class EmojiPickerButton extends StatelessWidget {
final String emoji;
final double emojiSize;
final Size emojiPickerSize;
final void Function(Emoji emoji, PopoverController controller) onSubmitted;
final void Function(String emoji, PopoverController controller) onSubmitted;
final PopoverController popoverController = PopoverController();
@override

View File

@ -186,7 +186,7 @@ class _CalloutBlockComponentWidgetState
), // force to refresh the popover state
emoji: emoji,
onSubmitted: (emoji, controller) {
setEmoji(emoji.emoji);
setEmoji(emoji);
controller.close();
},
),

View File

@ -2,17 +2,23 @@ import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart';
import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart';
import 'package:appflowy/plugins/base/icon/icon_picker.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/application/view/view_listener.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/rounded_button.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'cover_editor.dart';
import 'emoji_icon_widget.dart';
import 'emoji_popover.dart';
const double kCoverHeight = 250.0;
const double kIconHeight = 60.0;
@ -45,13 +51,17 @@ enum CoverType {
class DocumentHeaderNodeWidget extends StatefulWidget {
const DocumentHeaderNodeWidget({
super.key,
required this.node,
required this.editorState,
super.key,
required this.onIconChanged,
required this.view,
});
final Node node;
final EditorState editorState;
final void Function(String icon) onIconChanged;
final ViewPB view;
@override
State<DocumentHeaderNodeWidget> createState() =>
@ -64,19 +74,33 @@ class _DocumentHeaderNodeWidgetState extends State<DocumentHeaderNodeWidget> {
);
String? get coverDetails =>
widget.node.attributes[DocumentHeaderBlockKeys.coverDetails];
String get icon => widget.node.attributes[DocumentHeaderBlockKeys.icon];
bool get hasIcon =>
widget.node.attributes[DocumentHeaderBlockKeys.icon]?.isNotEmpty ?? false;
String? get icon => widget.node.attributes[DocumentHeaderBlockKeys.icon];
bool get hasIcon => viewIcon.isNotEmpty;
bool get hasCover => coverType != CoverType.none;
String viewIcon = '';
late final ViewListener viewListener;
@override
void initState() {
super.initState();
final value = widget.view.icon.value;
viewIcon = value.isNotEmpty ? value : icon ?? '';
widget.node.addListener(_reload);
viewListener = ViewListener(
viewId: widget.view.id,
)..start(
onViewUpdated: (p0) {
setState(() {
viewIcon = p0.icon.value;
});
},
);
}
@override
void dispose() {
viewListener.stop();
widget.node.removeListener(_reload);
super.dispose();
}
@ -108,7 +132,7 @@ class _DocumentHeaderNodeWidgetState extends State<DocumentHeaderNodeWidget> {
),
if (hasIcon)
Positioned(
left: 80,
left: PlatformExtension.isDesktopOrWeb ? 80 : 20,
// if hasCover, there shouldn't be icons present so the icon can
// be closer to the bottom.
bottom:
@ -116,8 +140,10 @@ class _DocumentHeaderNodeWidgetState extends State<DocumentHeaderNodeWidget> {
child: DocumentIcon(
editorState: widget.editorState,
node: widget.node,
icon: icon,
onIconChanged: (icon) => _saveCover(icon: icon),
icon: viewIcon,
onIconChanged: (icon) async {
_saveCover(icon: icon);
},
),
),
],
@ -153,6 +179,7 @@ class _DocumentHeaderNodeWidgetState extends State<DocumentHeaderNodeWidget> {
}
if (icon != null) {
attributes[DocumentHeaderBlockKeys.icon] = icon;
widget.onIconChanged(icon);
}
transaction.updateNode(widget.node, attributes);
@ -188,29 +215,42 @@ class _DocumentHeaderToolbarState extends State<DocumentHeaderToolbar> {
final PopoverController _popoverController = PopoverController();
@override
void initState() {
super.initState();
isHidden = PlatformExtension.isDesktopOrWeb;
}
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (event) => setHidden(false),
onExit: (event) {
if (!isPopoverOpen) {
setHidden(true);
}
},
opaque: false,
child: Container(
alignment: Alignment.bottomLeft,
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 40),
child: SizedBox(
height: 28,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: buildRowChildren(),
),
Widget child = Container(
alignment: Alignment.bottomLeft,
width: double.infinity,
padding: EditorStyleCustomizer.documentPadding,
child: SizedBox(
height: 28,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: buildRowChildren(),
),
),
);
if (PlatformExtension.isDesktopOrWeb) {
child = MouseRegion(
onEnter: (event) => setHidden(false),
onExit: (event) {
if (!isPopoverOpen) {
setHidden(true);
}
},
opaque: false,
child: child,
);
}
return child;
}
List<Widget> buildRowChildren() {
@ -251,42 +291,50 @@ class _DocumentHeaderToolbarState extends State<DocumentHeaderToolbar> {
),
);
} else {
children.add(
AppFlowyPopover(
Widget child = FlowyButton(
leftIconSize: const Size.square(18),
useIntrinsicWidth: true,
leftIcon: const Icon(
Icons.emoji_emotions_outlined,
size: 18,
),
text: FlowyText.regular(
LocaleKeys.document_plugins_cover_addIcon.tr(),
),
onTap: PlatformExtension.isDesktop
? null
: () => context.push(
Uri(
path: MobileEmojiPickerScreen.routeName,
queryParameters: {
MobileEmojiPickerScreen.viewId:
context.read<ViewBloc>().state.view.id,
},
).toString(),
),
);
if (PlatformExtension.isDesktop) {
child = AppFlowyPopover(
onClose: () => isPopoverOpen = false,
controller: _popoverController,
offset: const Offset(0, 8),
direction: PopoverDirection.bottomWithCenterAligned,
constraints: BoxConstraints.loose(const Size(300, 250)),
child: FlowyButton(
leftIconSize: const Size.square(18),
useIntrinsicWidth: true,
leftIcon: const Icon(
Icons.emoji_emotions_outlined,
size: 18,
),
text: FlowyText.regular(
LocaleKeys.document_plugins_cover_addIcon.tr(),
),
),
constraints: BoxConstraints.loose(const Size(360, 380)),
child: child,
popupBuilder: (BuildContext popoverContext) {
isPopoverOpen = true;
return EmojiPopover(
showRemoveButton: widget.hasIcon,
removeIcon: () {
widget.onCoverChanged(icon: "");
_popoverController.close();
},
node: widget.node,
editorState: widget.editorState,
onEmojiChanged: (Emoji emoji) {
widget.onCoverChanged(icon: emoji.emoji);
return FlowyIconPicker(
onSelected: (type, value) {
widget.onCoverChanged(icon: value);
_popoverController.close();
},
);
},
),
);
);
}
children.add(child);
}
return children;
@ -471,27 +519,41 @@ class _DocumentIconState extends State<DocumentIcon> {
@override
Widget build(BuildContext context) {
return AppFlowyPopover(
direction: PopoverDirection.bottomWithCenterAligned,
controller: _popoverController,
offset: const Offset(0, 8),
constraints: BoxConstraints.loose(const Size(320, 380)),
child: EmojiIconWidget(emoji: widget.icon),
popupBuilder: (BuildContext popoverContext) {
return EmojiPopover(
node: widget.node,
showRemoveButton: true,
removeIcon: () {
widget.onIconChanged("");
_popoverController.close();
},
editorState: widget.editorState,
onEmojiChanged: (Emoji emoji) {
widget.onIconChanged(emoji.emoji);
_popoverController.close();
},
);
},
Widget child = EmojiIconWidget(
emoji: widget.icon,
);
if (PlatformExtension.isDesktopOrWeb) {
child = AppFlowyPopover(
direction: PopoverDirection.bottomWithCenterAligned,
controller: _popoverController,
offset: const Offset(0, 8),
constraints: BoxConstraints.loose(const Size(360, 380)),
child: child,
popupBuilder: (BuildContext popoverContext) {
return FlowyIconPicker(
onSelected: (type, value) {
widget.onIconChanged(value);
_popoverController.close();
},
);
},
);
} else {
child = GestureDetector(
child: child,
onTap: () => context.push(
Uri(
path: MobileEmojiPickerScreen.routeName,
queryParameters: {
MobileEmojiPickerScreen.viewId:
context.read<ViewBloc>().state.view.id,
},
).toString(),
),
);
}
return child;
}
}

View File

@ -1,76 +0,0 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
/// Add icon menu in Header
class EmojiPopover extends StatefulWidget {
final EditorState editorState;
final Node node;
final void Function(Emoji emoji) onEmojiChanged;
final VoidCallback removeIcon;
final bool showRemoveButton;
const EmojiPopover({
super.key,
required this.editorState,
required this.node,
required this.onEmojiChanged,
required this.removeIcon,
required this.showRemoveButton,
});
@override
State<EmojiPopover> createState() => _EmojiPopoverState();
}
class _EmojiPopoverState extends State<EmojiPopover> {
@override
Widget build(BuildContext context) {
return Column(
children: [
if (widget.showRemoveButton)
Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Align(
alignment: Alignment.centerRight,
child: DeleteButton(onTap: widget.removeIcon),
),
),
Expanded(
child: EmojiPicker(
onEmojiSelected: (category, emoji) {
widget.onEmojiChanged(emoji);
},
config: buildFlowyEmojiPickerConfig(context),
),
),
],
);
}
}
class DeleteButton extends StatelessWidget {
final VoidCallback onTap;
const DeleteButton({required this.onTap, Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return SizedBox(
height: 28,
child: FlowyButton(
onTap: onTap,
useIntrinsicWidth: true,
text: FlowyText(
LocaleKeys.document_plugins_cover_removeIcon.tr(),
),
leftIcon: const FlowySvg(FlowySvgs.delete_s),
),
);
}
}

View File

@ -29,6 +29,10 @@ class EditorStyleCustomizer {
throw UnimplementedError();
}
static EdgeInsets get documentPadding => PlatformExtension.isMobile
? const EdgeInsets.only(left: 20, right: 20)
: const EdgeInsets.only(left: 40, right: 40 + 44);
EditorStyle desktop() {
final theme = Theme.of(context);
final fontSize = context.read<DocumentAppearanceCubit>().state.fontSize;

View File

@ -3,6 +3,7 @@ import 'package:appflowy/mobile/presentation/database/mobile_calendar_screen.dar
import 'package:appflowy/mobile/presentation/database/mobile_grid_screen.dart';
import 'package:appflowy/mobile/presentation/favorite/mobile_favorite_page.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/startup/tasks/app_widget.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
@ -47,6 +48,9 @@ GoRouter generateRouter(Widget child) {
// trash
_mobileHomeTrashPageRoute(),
// emoji picker
_mobileEmojiPickerPageRoute(),
],
// Desktop and Mobile
@ -200,6 +204,21 @@ GoRoute _mobileHomeTrashPageRoute() {
);
}
GoRoute _mobileEmojiPickerPageRoute() {
return GoRoute(
parentNavigatorKey: AppGlobals.rootNavKey,
path: MobileEmojiPickerScreen.routeName,
pageBuilder: (context, state) {
final id = state.uri.queryParameters[MobileEmojiPickerScreen.viewId]!;
return MaterialPage(
child: MobileEmojiPickerScreen(
id: id,
),
);
},
);
}
GoRoute _desktopHomeScreenRoute() {
return GoRoute(
path: DesktopHomeScreen.routeName,

View File

@ -1,10 +1,9 @@
import 'dart:async';
import 'package:appflowy_backend/protobuf/flowy-folder2/workspace.pb.dart';
import 'package:dartz/dartz.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
import 'package:dartz/dartz.dart';
class ViewBackendService {
static Future<Either<ViewPB, FlowyError>> createView({
@ -149,9 +148,24 @@ class ViewBackendService {
if (isFavorite != null) {
payload.isFavorite = isFavorite;
}
return FolderEventUpdateView(payload).send();
}
static Future<Either<Unit, FlowyError>> updateViewIcon({
required String viewId,
required String viewIcon,
}) {
final icon = ViewIconPB()
..ty = ViewIconTypePB.Emoji
..value = viewIcon;
final payload = UpdateViewIconPayloadPB.create()
..viewId = viewId
..icon = icon;
return FolderEventUpdateViewIcon(payload).send();
}
// deprecated
static Future<Either<Unit, FlowyError>> moveView({
required String viewId,

View File

@ -1,10 +1,11 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/base/icon/icon_picker.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/rename_view_dialog.dart';
@ -14,6 +15,7 @@ import 'package:appflowy/workspace/presentation/home/menu/view/view_add_button.d
import 'package:appflowy/workspace/presentation/home/menu/view/view_more_action_button.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
@ -36,6 +38,7 @@ class ViewItem extends StatelessWidget {
this.isFirstChild = false,
this.isDraggable = true,
required this.isFeedback,
this.height = 28.0,
});
final ViewPB view;
@ -67,6 +70,8 @@ class ViewItem extends StatelessWidget {
// identify if the view item is rendered as feedback widget inside DraggableItem
final bool isFeedback;
final double height;
@override
Widget build(BuildContext context) {
return BlocProvider(
@ -96,6 +101,7 @@ class ViewItem extends StatelessWidget {
isFirstChild: isFirstChild,
isDraggable: isDraggable,
isFeedback: isFeedback,
height: height,
);
},
),
@ -121,6 +127,7 @@ class InnerViewItem extends StatelessWidget {
this.onTertiarySelected,
this.isFirstChild = false,
required this.isFeedback,
required this.height,
});
final ViewPB view;
@ -140,6 +147,7 @@ class InnerViewItem extends StatelessWidget {
final bool showActions;
final ViewItemOnSelected onSelected;
final ViewItemOnSelected? onTertiarySelected;
final double height;
@override
Widget build(BuildContext context) {
@ -155,6 +163,7 @@ class InnerViewItem extends StatelessWidget {
isDraggable: isDraggable,
leftPadding: leftPadding,
isFeedback: isFeedback,
height: height,
);
// if the view is expanded and has child views, render its child views
@ -233,6 +242,7 @@ class SingleInnerViewItem extends StatefulWidget {
required this.onSelected,
this.onTertiarySelected,
required this.isFeedback,
required this.height,
});
final ViewPB view;
@ -249,12 +259,16 @@ class SingleInnerViewItem extends StatefulWidget {
final ViewItemOnSelected onSelected;
final ViewItemOnSelected? onTertiarySelected;
final FolderCategoryType categoryType;
final double height;
@override
State<SingleInnerViewItem> createState() => _SingleInnerViewItemState();
}
class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
final controller = PopoverController();
bool isIconPickerOpened = false;
@override
Widget build(BuildContext context) {
if (widget.isFeedback) {
@ -266,7 +280,8 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
hoverColor: Theme.of(context).colorScheme.secondary,
),
resetHoverOnRebuild: widget.showActions,
buildWhenOnHover: () => !widget.showActions && !_isDragging,
buildWhenOnHover: () =>
!widget.showActions && !_isDragging && !isIconPickerOpened,
builder: (_, onHover) => _buildViewItem(onHover),
isSelected: () =>
widget.showActions ||
@ -279,10 +294,7 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
// expand icon
_buildLeftIcon(),
// icon
SizedBox.square(
dimension: 16,
child: widget.view.defaultIcon(),
),
_buildViewIconButton(),
const HSpace(5),
// title
Expanded(
@ -309,7 +321,7 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
onTap: () => widget.onSelected(widget.view),
onTertiaryTapDown: (_) => widget.onTertiarySelected?.call(widget.view),
child: SizedBox(
height: 26,
height: widget.height,
child: Padding(
padding: EdgeInsets.only(left: widget.level * widget.leftPadding),
child: Row(
@ -320,6 +332,47 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
);
}
Widget _buildViewIconButton() {
final icon = widget.view.icon.value.isNotEmpty
? FlowyText(
widget.view.icon.value,
fontSize: 18.0,
)
: SizedBox.square(
dimension: 20.0,
child: widget.view.defaultIcon(),
);
return AppFlowyPopover(
offset: const Offset(20, 0),
controller: controller,
direction: PopoverDirection.rightWithCenterAligned,
constraints: BoxConstraints.loose(const Size(360, 380)),
onClose: () => setState(() {
isIconPickerOpened = false;
}),
child: GestureDetector(
// prevent the tap event from being passed to the parent widget
onTap: () {},
child: FlowyTooltip(
message: LocaleKeys.document_plugins_cover_changeIcon.tr(),
child: icon,
),
),
popupBuilder: (context) {
isIconPickerOpened = true;
return FlowyIconPicker(
onSelected: (_, emoji) {
ViewBackendService.updateViewIcon(
viewId: widget.view.id,
viewIcon: emoji,
);
controller.close();
},
);
},
);
}
// > button or · button
// show > if the view is expandable.
// show · if the view can't contain child views.

View File

@ -1,11 +1,10 @@
import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra_ui/style_widget/decoration.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'emoji_picker.dart';
SelectionMenuItem emojiMenuItem = SelectionMenuItem(
name: 'Emoji',
icon: (editorState, onSelected, style) => SelectableIconWidget(
@ -54,7 +53,7 @@ void showEmojiPickerMenu(
),
child: EmojiSelectionMenu(
onSubmitted: (emoji) {
editorState.insertTextAtCurrentSelection(emoji.emoji);
editorState.insertTextAtCurrentSelection(emoji);
},
onExit: () {
// close emoji panel
@ -73,7 +72,7 @@ class EmojiSelectionMenu extends StatefulWidget {
required this.onExit,
}) : super(key: key);
final void Function(Emoji emoji) onSubmitted;
final void Function(String emoji) onSubmitted;
final void Function() onExit;
@override
@ -111,9 +110,10 @@ class _EmojiSelectionMenuState extends State<EmojiSelectionMenu> {
@override
Widget build(BuildContext context) {
return EmojiPicker(
onEmojiSelected: (category, emoji) => widget.onSubmitted(emoji),
config: buildFlowyEmojiPickerConfig(context),
return FlowyEmojiPicker(
onEmojiSelected: (_, emoji) {
widget.onSubmitted(emoji);
},
);
}
}

View File

@ -22,6 +22,10 @@ class FlowyTextField extends StatefulWidget {
final String? errorText;
final int maxLines;
final bool showCounter;
final Widget? prefixIcon;
final Widget? suffixIcon;
final BoxConstraints? prefixIconConstraints;
final BoxConstraints? suffixIconConstraints;
const FlowyTextField({
super.key,
@ -42,6 +46,10 @@ class FlowyTextField extends StatefulWidget {
this.errorText,
this.maxLines = 1,
this.showCounter = true,
this.prefixIcon,
this.suffixIcon,
this.prefixIconConstraints,
this.suffixIconConstraints,
});
@override
@ -55,6 +63,8 @@ class FlowyTextFieldState extends State<FlowyTextField> {
@override
void initState() {
super.initState();
focusNode = widget.focusNode ?? FocusNode();
focusNode.addListener(notifyDidEndEditing);
@ -67,10 +77,10 @@ class FlowyTextFieldState extends State<FlowyTextField> {
WidgetsBinding.instance.addPostFrameCallback((_) {
focusNode.requestFocus();
controller.selection = TextSelection.fromPosition(
TextPosition(offset: controller.text.length));
TextPosition(offset: controller.text.length),
);
});
}
super.initState();
}
void _debounceOnChangedText(Duration duration, String text) {
@ -113,6 +123,7 @@ class FlowyTextFieldState extends State<FlowyTextField> {
maxLength: widget.maxLength,
maxLengthEnforcement: MaxLengthEnforcement.truncateAfterCompositionEnds,
style: widget.textStyle ?? Theme.of(context).textTheme.bodySmall,
textAlignVertical: TextAlignVertical.center,
decoration: InputDecoration(
constraints: BoxConstraints(
maxHeight: widget.errorText?.isEmpty ?? true ? 32 : 58),
@ -158,6 +169,10 @@ class FlowyTextFieldState extends State<FlowyTextField> {
),
borderRadius: Corners.s8Border,
),
prefixIcon: widget.prefixIcon,
suffixIcon: widget.suffixIcon,
prefixIconConstraints: widget.prefixIconConstraints,
suffixIconConstraints: widget.suffixIconConstraints,
),
);
}

View File

@ -53,12 +53,11 @@ packages:
appflowy_editor:
dependency: "direct main"
description:
path: "."
ref: "7336274"
resolved-ref: "7336274ff90402c8dd790b029e00cac60c580f28"
url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
source: git
version: "1.5.0"
name: appflowy_editor
sha256: d3112408f28ca3b7b8d3d1ecc90a0c1ba7c1fe807ab285c07b1e9d312b1d3cad
url: "https://pub.dev"
source: hosted
version: "1.5.1"
appflowy_popover:
dependency: "direct main"
description:
@ -346,6 +345,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0+3"
easy_debounce:
dependency: transitive
description:
name: easy_debounce
sha256: f082609cfb8f37defb9e37fc28bc978c6712dedf08d4c5a26f820fa10165a236
url: "https://pub.dev"
source: hosted
version: "2.0.3"
easy_localization:
dependency: "direct main"
description:
@ -362,6 +369,15 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.0.2"
emoji_mart:
dependency: "direct main"
description:
path: "."
ref: "067f718"
resolved-ref: "067f7188965c8fcb7be02ce174ce2b6757f288ee"
url: "https://github.com/LucasXu0/emoji_mart.git"
source: git
version: "0.0.1"
envied:
dependency: "direct main"
description:
@ -533,6 +549,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0"
flutter_sticky_header:
dependency: transitive
description:
name: flutter_sticky_header
sha256: "017f398fbb45a589e01491861ca20eb6570a763fd9f3888165a978e11248c709"
url: "https://pub.dev"
source: hosted
version: "0.6.5"
flutter_svg:
dependency: "direct main"
description:
@ -571,10 +595,10 @@ packages:
dependency: "direct main"
description:
name: freezed_annotation
sha256: aeac15850ef1b38ee368d4c53ba9a847e900bb2c53a4db3f6881cbb3cb684338
sha256: c3fd9336eb55a38cc1bbd79ab17573113a8deccd0ecbbf926cca3c62803b5c2d
url: "https://pub.dev"
source: hosted
version: "2.2.0"
version: "2.4.1"
frontend_server_client:
dependency: transitive
description:
@ -1390,6 +1414,14 @@ packages:
description: flutter
source: sdk
version: "0.0.99"
sliver_tools:
dependency: transitive
description:
name: sliver_tools
sha256: eae28220badfb9d0559207badcbbc9ad5331aac829a88cb0964d330d2a4636a6
url: "https://pub.dev"
source: hosted
version: "0.2.12"
source_gen:
dependency: transitive
description:
@ -1728,6 +1760,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
value_layout_builder:
dependency: transitive
description:
name: value_layout_builder
sha256: "98202ec1807e94ac72725b7f0d15027afde513c55c69ff3f41bcfccb950831bc"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
vector_graphics:
dependency: transitive
description:

View File

@ -44,10 +44,7 @@ dependencies:
git:
url: https://github.com/AppFlowy-IO/appflowy-board.git
ref: 6aba8dd
appflowy_editor:
git:
url: https://github.com/AppFlowy-IO/appflowy-editor.git
ref: "7336274"
appflowy_editor: ^1.5.1
appflowy_popover:
path: packages/appflowy_popover
@ -110,6 +107,10 @@ dependencies:
go_router: ^10.1.2
string_validator: ^1.0.0
unsplash_client: ^2.1.1
emoji_mart:
git:
url: https://github.com/LucasXu0/emoji_mart.git
ref: "067f718"
# Notifications
# TODO: Consider implementing custom package

View File

@ -619,13 +619,14 @@
"add": "Add",
"back": "Back",
"saveToGallery": "Save to gallery",
"removeIcon": "Remove Icon",
"removeIcon": "Remove icon",
"pasteImageUrl": "Paste image URL",
"or": "OR",
"pickFromFiles": "Pick from files",
"couldNotFetchImage": "Could not fetch image",
"imageSavingFailed": "Image Saving Failed",
"addIcon": "Add Icon",
"addIcon": "Add icon",
"changeIcon": "Change icon",
"coverRemoveAlert": "It will be removed from cover after it is deleted.",
"alertDialogConfirmation": "Are you sure, you want to continue?"
},
@ -818,6 +819,7 @@
"gray": "Gray"
},
"emoji": {
"emojiTab": "Emoji",
"search": "Search emoji",
"noRecent": "No recent emoji",
"noEmojiFound": "No emoji found",
@ -837,6 +839,14 @@
"flags": "Flags",
"nature": "Nature",
"frequentlyUsed": "Frequently Used"
},
"skinTone": {
"default": "Default",
"light": "Light",
"mediumLight": "Medium-Light",
"medium": "Medium",
"mediumDark": "Medium-Dark",
"dark": "Dark"
}
},
"inlineActions": {