mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
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:
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
@ -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('');
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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 '✋🏿';
|
||||
}
|
||||
}
|
||||
}
|
121
frontend/appflowy_flutter/lib/plugins/base/icon/icon_picker.dart
Normal file
121
frontend/appflowy_flutter/lib/plugins/base/icon/icon_picker.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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: () {},
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -186,7 +186,7 @@ class _CalloutBlockComponentWidgetState
|
||||
), // force to refresh the popover state
|
||||
emoji: emoji,
|
||||
onSubmitted: (emoji, controller) {
|
||||
setEmoji(emoji.emoji);
|
||||
setEmoji(emoji);
|
||||
controller.close();
|
||||
},
|
||||
),
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
Reference in New Issue
Block a user