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
34 changed files with 1116 additions and 256 deletions

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;