diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_row_page_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_row_page_test.dart index fec272768c84d..5dc1f612da53b 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/database/database_row_page_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_row_page_test.dart @@ -5,6 +5,7 @@ import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_cell_edi import 'package:appflowy/plugins/database/widgets/row/row_detail.dart'; import 'package:appflowy/plugins/document/document_page.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; +import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart'; import 'package:appflowy/util/field_type_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; @@ -22,7 +23,7 @@ import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - + RecentIcons.enable = false; group('grid row detail page:', () { testWidgets('opens', (tester) async { await tester.initializeAppFlowy(); diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_cover_image_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_cover_image_test.dart index f04c2ee0e1909..bd4e17124a122 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_cover_image_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_cover_image_test.dart @@ -1,6 +1,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -13,7 +14,7 @@ import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - + RecentIcons.enable = false; group('cover image:', () { testWidgets('document cover tests', (tester) async { await tester.initializeAppFlowy(); diff --git a/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_icon_test.dart b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_icon_test.dart index cd3cddd88e6ec..52e443cbf051d 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_icon_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_icon_test.dart @@ -1,5 +1,6 @@ import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; +import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -11,7 +12,7 @@ import '../../shared/expectation.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - + RecentIcons.enable = false; final emoji = EmojiIconData.emoji('😁'); Future loadIcon() async { diff --git a/frontend/appflowy_flutter/lib/core/config/kv_keys.dart b/frontend/appflowy_flutter/lib/core/config/kv_keys.dart index c0e7f63f6c1ff..251911ebf2a49 100644 --- a/frontend/appflowy_flutter/lib/core/config/kv_keys.dart +++ b/frontend/appflowy_flutter/lib/core/config/kv_keys.dart @@ -109,4 +109,9 @@ class KVKeys { /// /// The value is a boolean string static const String hasUpgradedSpace = 'hasUpgradedSpace060'; + + /// The key for saving the rencent icons + /// + /// The value is a json string of [RecentIcons] + static const String kRecentIcons = 'kRecentIcons'; } diff --git a/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker.dart b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker.dart index 457c7c1914f80..d588524ea8db6 100644 --- a/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker.dart +++ b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker.dart @@ -1,6 +1,9 @@ +import 'dart:math'; + import 'package:appflowy/plugins/base/emoji/emoji_picker_header.dart'; import 'package:appflowy/shared/icon_emoji_picker/emoji_search_bar.dart'; import 'package:appflowy/shared/icon_emoji_picker/emoji_skin_tone.dart'; +import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -24,7 +27,32 @@ class FlowyEmojiPicker extends StatefulWidget { } class _FlowyEmojiPickerState extends State { - EmojiData? emojiData; + late EmojiData emojiData; + bool loaded = false; + + void loadEmojis(EmojiData data) { + RecentIcons.getEmojiIds().then((v) { + if (v.isEmpty) { + emojiData = data; + setState(() { + loaded = true; + }); + return; + } + final categories = List.of(data.categories); + categories.insert( + 0, + Category( + id: 'Recent', + emojiIds: v.sublist(0, min(widget.emojiPerLine, v.length)), + ), + ); + emojiData = EmojiData(categories: categories, emojis: data.emojis); + setState(() { + loaded = true; + }); + }); + } @override void initState() { @@ -32,12 +60,12 @@ class _FlowyEmojiPickerState extends State { // load the emoji data from cache if it's available if (kCachedEmojiData != null) { - emojiData = kCachedEmojiData; + loadEmojis(kCachedEmojiData!); } else { EmojiData.builtIn().then( (value) { kCachedEmojiData = value; - setState(() => emojiData = value); + loadEmojis(value); }, ); } @@ -45,7 +73,7 @@ class _FlowyEmojiPickerState extends State { @override Widget build(BuildContext context) { - if (emojiData == null) { + if (!loaded) { return const Center( child: SizedBox.square( dimension: 24.0, @@ -57,13 +85,15 @@ class _FlowyEmojiPickerState extends State { } return EmojiPicker( - emojiData: emojiData!, + emojiData: emojiData, configuration: EmojiPickerConfiguration( showTabs: false, defaultSkinTone: lastSelectedEmojiSkinTone ?? EmojiSkinTone.none, - perLine: widget.emojiPerLine, ), - onEmojiSelected: widget.onEmojiSelected, + onEmojiSelected: (id, emoji) { + widget.onEmojiSelected.call(id, emoji); + RecentIcons.putEmoji(id); + }, padding: const EdgeInsets.symmetric(horizontal: 16.0), headerBuilder: (context, category) { return FlowyEmojiHeader( @@ -71,7 +101,7 @@ class _FlowyEmojiPickerState extends State { ); }, itemBuilder: (context, emojiId, emoji, callback) { - final name = emojiData?.emojis[emojiId]?.name ?? ''; + final name = emojiData.emojis[emojiId]?.name ?? ''; return SizedBox.square( dimension: 36.0, child: FlowyButton( @@ -93,7 +123,7 @@ class _FlowyEmojiPickerState extends State { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: FlowyEmojiSearchBar( - emojiData: emojiData!, + emojiData: emojiData, onKeywordChanged: (value) { keyword.value = value; }, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/icon/icon_selector.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/icon/icon_selector.dart deleted file mode 100644 index edeadf71d8a16..0000000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/icon/icon_selector.dart +++ /dev/null @@ -1,160 +0,0 @@ -import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_search_text_field.dart'; -import 'package:appflowy/plugins/base/emoji/emoji_picker.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_icon_bloc.dart'; -import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; - -class IconSelector extends StatefulWidget { - const IconSelector({ - super.key, - required this.scrollController, - }); - - final ScrollController scrollController; - - @override - State createState() => _IconSelectorState(); -} - -class _IconSelectorState extends State { - EmojiData? emojiData; - List availableEmojis = []; - - PageStyleIconBloc? pageStyleIconBloc; - - @override - void initState() { - super.initState(); - - // load the emoji data from cache if it's available - if (kCachedEmojiData != null) { - emojiData = kCachedEmojiData; - availableEmojis = _setupAvailableEmojis(emojiData!); - } else { - EmojiData.builtIn().then( - (value) { - kCachedEmojiData = value; - setState(() { - emojiData = value; - availableEmojis = _setupAvailableEmojis(value); - }); - }, - ); - } - - pageStyleIconBloc = context.read(); - } - - @override - void dispose() { - pageStyleIconBloc?.close(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - if (emojiData == null) { - return const Center(child: CircularProgressIndicator()); - } - - return RepaintBoundary( - child: BlocBuilder( - builder: (_, state) => Column( - children: [ - _buildSearchBar(context), - Expanded( - child: GridView.count( - crossAxisCount: 7, - controller: widget.scrollController, - children: [ - for (final emoji in availableEmojis) - _buildEmoji(context, emoji, state.icon), - ], - ), - ), - ], - ), - ), - ); - } - - Widget _buildEmoji( - BuildContext context, - String emoji, - EmojiIconData? selectedEmoji, - ) { - Widget child = SizedBox.square( - dimension: 24.0, - child: Center( - child: FlowyText.emoji( - emoji, - fontSize: 24, - ), - ), - ); - - if (emoji == selectedEmoji?.emoji) { - child = Center( - child: Container( - width: 40, - height: 40, - decoration: ShapeDecoration( - shape: RoundedRectangleBorder( - side: const BorderSide( - width: 1.40, - strokeAlign: BorderSide.strokeAlignOutside, - color: Color(0xFF00BCF0), - ), - borderRadius: BorderRadius.circular(10), - ), - ), - child: child, - ), - ); - } - - return GestureDetector( - onTap: () { - context.read().add( - PageStyleIconEvent.updateIcon(EmojiIconData.emoji(emoji), true), - ); - }, - child: child, - ); - } - - List _setupAvailableEmojis(EmojiData emojiData) { - final categories = emojiData.categories; - availableEmojis = categories - .map((e) => e.emojiIds.map((e) => emojiData.getEmojiById(e))) - .expand((e) => e) - .toList(); - return availableEmojis; - } - - Widget _buildSearchBar(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric( - vertical: 8.0, - horizontal: 12.0, - ), - child: FlowyMobileSearchTextField( - onChanged: (keyword) { - if (emojiData == null) { - return; - } - - final filtered = emojiData!.filterByKeyword(keyword); - final availableEmojis = _setupAvailableEmojis(filtered); - - setState(() { - this.availableEmojis = availableEmojis; - }); - }, - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_picker.dart b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_picker.dart index 20e8bf7fd5599..4b5a3cca9856c 100644 --- a/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_picker.dart +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_picker.dart @@ -8,6 +8,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/base/strin import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_search_bar.dart'; +import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart'; import 'package:appflowy/util/debounce.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart'; import 'package:appflowy_backend/log.dart'; @@ -78,31 +79,57 @@ class FlowyIconPicker extends StatefulWidget { super.key, required this.onSelectedIcon, required this.enableBackgroundColorSelection, + this.iconPerLine = 9, }); final bool enableBackgroundColorSelection; final ValueChanged onSelectedIcon; + final int iconPerLine; @override State createState() => _FlowyIconPickerState(); } class _FlowyIconPickerState extends State { - late final Future> iconGroups; + final List iconGroups = []; + bool loaded = false; final ValueNotifier keyword = ValueNotifier(''); final debounce = Debounce(duration: const Duration(milliseconds: 150)); + Future loadIcons() async { + final localIcons = await loadIconGroups(); + final recentIcons = await RecentIcons.getIcons(); + if (recentIcons.isNotEmpty) { + iconGroups.add( + IconGroup( + name: 'Recent', + icons: recentIcons.sublist( + 0, + min(recentIcons.length, widget.iconPerLine), + ), + ), + ); + } + iconGroups.addAll(localIcons); + if (mounted) { + setState(() { + loaded = true; + }); + } + } + @override void initState() { super.initState(); - - iconGroups = loadIconGroups(); + loadIcons(); } @override void dispose() { keyword.dispose(); debounce.dispose(); + iconGroups.clear(); + loaded = false; super.dispose(); } @@ -137,24 +164,15 @@ class _FlowyIconPickerState extends State { ), ), Expanded( - child: kIconGroups != null - ? _buildIcons(kIconGroups!) - : FutureBuilder( - future: iconGroups, - builder: (context, snapshot) { - if (snapshot.connectionState != ConnectionState.done) { - return const Center( - child: SizedBox.square( - dimension: 24.0, - child: CircularProgressIndicator( - strokeWidth: 2.0, - ), - ), - ); - } - final iconGroups = snapshot.data as List; - return _buildIcons(iconGroups); - }, + child: loaded + ? _buildIcons(iconGroups) + : const Center( + child: SizedBox.square( + dimension: 24.0, + child: CircularProgressIndicator( + strokeWidth: 2.0, + ), + ), ), ), ], @@ -175,12 +193,14 @@ class _FlowyIconPickerState extends State { enableBackgroundColorSelection: widget.enableBackgroundColorSelection, onSelectedIcon: widget.onSelectedIcon, + iconPerLine: widget.iconPerLine, ); } return IconPicker( iconGroups: iconGroups, enableBackgroundColorSelection: widget.enableBackgroundColorSelection, onSelectedIcon: widget.onSelectedIcon, + iconPerLine: widget.iconPerLine, ); }, ); @@ -220,9 +240,11 @@ class IconPicker extends StatefulWidget { required this.onSelectedIcon, required this.enableBackgroundColorSelection, required this.iconGroups, + required this.iconPerLine, }); final List iconGroups; + final int iconPerLine; final bool enableBackgroundColorSelection; final ValueChanged onSelectedIcon; @@ -250,43 +272,49 @@ class _IconPickerState extends State { color: context.pickerTextColor, ), const VSpace(4.0), - Wrap( - children: iconGroup.icons.map( - (icon) { - return widget.enableBackgroundColorSelection - ? _Icon( - icon: icon, - mutex: mutex, - onSelectedColor: (context, color) { - widget.onSelectedIcon( - IconsData( - iconGroup.name, - icon.content, - icon.name, - color, - ), - ); - PopoverContainer.of(context).close(); - }, - ) - : _IconNoBackground( - icon: icon, - onSelectedIcon: () { - widget.onSelectedIcon( - IconsData( - iconGroup.name, - icon.content, - icon.name, - null, - ), - ); - }, - ); - }, - ).toList(), + GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: widget.iconPerLine, + ), + itemCount: iconGroup.icons.length, + shrinkWrap: true, + itemBuilder: (context, index) { + final icon = iconGroup.icons[index]; + return widget.enableBackgroundColorSelection + ? _Icon( + icon: icon, + mutex: mutex, + onSelectedColor: (context, color) { + widget.onSelectedIcon( + IconsData( + iconGroup.name, + icon.content, + icon.name, + color, + ), + ); + RecentIcons.putIcon(icon); + PopoverContainer.of(context).close(); + }, + ) + : _IconNoBackground( + icon: icon, + onSelectedIcon: () { + widget.onSelectedIcon( + IconsData( + iconGroup.name, + icon.content, + icon.name, + null, + ), + ); + RecentIcons.putIcon(icon); + }, + ); + }, ), const VSpace(12.0), - if (index == kIconGroups!.length - 1) ...[ + if (index == widget.iconGroups.length - 1) ...[ const StreamlinePermit(), const VSpace(12.0), ], diff --git a/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/recent_icons.dart b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/recent_icons.dart new file mode 100644 index 0000000000000..046c631b66410 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/recent_icons.dart @@ -0,0 +1,88 @@ +import 'dart:convert'; + +import 'package:appflowy/shared/icon_emoji_picker/icon.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:flutter/foundation.dart'; + +import '../../core/config/kv.dart'; +import '../../core/config/kv_keys.dart'; +import '../../startup/startup.dart'; +import 'flowy_icon_emoji_picker.dart'; + +class RecentIcons { + static final Map> _dataMap = {}; + static bool _loaded = false; + static const maxLength = 20; + @visibleForTesting + static bool enable = true; + + static Future _save() async { + await getIt().set( + KVKeys.kRecentIcons, + jsonEncode(_dataMap), + ); + } + + static Future _load() async { + if (_loaded || !enable) return; + final v = await getIt().get(KVKeys.kRecentIcons); + try { + final data = jsonDecode(v ?? '') as Map; + _dataMap.clear(); + _dataMap.addAll( + data.map( + (k, v) => MapEntry>(k, List.from(v)), + ), + ); + } on FormatException catch (e) { + Log.error('RecentIcons load with :$v', e); + } on TypeError catch (e) { + Log.error('RecentIcons load with :$v', e); + } + _loaded = true; + } + + static Future _put(FlowyIconType key, String value) async { + await _load(); + final list = _dataMap[key.name] ?? []; + list.remove(value); + list.insert(0, value); + if (list.length > maxLength) list.removeLast(); + _dataMap[key.name] = list; + await _save(); + } + + static Future putEmoji(String id) async { + await _put(FlowyIconType.emoji, id); + } + + static Future putIcon(Icon icon) async { + await _put( + FlowyIconType.icon, + jsonEncode( + Icon(name: icon.name, keywords: icon.keywords, content: icon.content) + .toJson(), + ), + ); + } + + static Future> getEmojiIds() async { + await _load(); + return _dataMap[FlowyIconType.emoji.name] ?? []; + } + + static Future> getIcons() async { + await _load(); + final iconList = _dataMap[FlowyIconType.icon.name] ?? []; + try { + return iconList + .map((e) => Icon.fromJson(jsonDecode(e) as Map)) + .toList(); + } on FormatException catch (e) { + Log.error('RecentIcons getIcons with :$iconList', e); + } on TypeError catch (e) { + Log.error('RecentIcons getIcons with :$iconList', e); + } + return []; + } +} diff --git a/frontend/appflowy_flutter/test/unit_test/util/recent_icons.dart b/frontend/appflowy_flutter/test/unit_test/util/recent_icons.dart new file mode 100644 index 0000000000000..a378ce05e53db --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/util/recent_icons.dart @@ -0,0 +1,93 @@ +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; +import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + group('Testing for RecentIcons', () { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + getIt.registerFactory(() => DartKeyValue()); + Log.shared.disableLog = true; + + test('putEmoji', () async { + List emojiIds = await RecentIcons.getEmojiIds(); + assert(emojiIds.isEmpty); + + await RecentIcons.putEmoji('1'); + emojiIds = await RecentIcons.getEmojiIds(); + assert(emojiIds.equals(['1'])); + + await RecentIcons.putEmoji('2'); + assert(emojiIds.equals(['2', '1'])); + + await RecentIcons.putEmoji('1'); + emojiIds = await RecentIcons.getEmojiIds(); + assert(emojiIds.equals(['1', '2'])); + + for (var i = 0; i < RecentIcons.maxLength; ++i) { + await RecentIcons.putEmoji('${i + 100}'); + } + emojiIds = await RecentIcons.getEmojiIds(); + assert(emojiIds.length == RecentIcons.maxLength); + assert( + emojiIds.equals( + List.generate(RecentIcons.maxLength, (i) => '${i + 100}') + .reversed + .toList(), + ), + ); + }); + + test('putIcons', () async { + List icons = await RecentIcons.getIcons(); + assert(icons.isEmpty); + await loadIconGroups(); + final groups = kIconGroups!; + final List localIcons = []; + for (final e in groups) { + localIcons.addAll(e.icons); + } + + bool equalIcon(Icon a, Icon b) => + a.name == b.name && + a.keywords.equals(b.keywords) && + a.content == b.content; + + await RecentIcons.putIcon(localIcons.first); + icons = await RecentIcons.getIcons(); + assert(icons.length == 1); + assert(equalIcon(icons.first, localIcons.first)); + + await RecentIcons.putIcon(localIcons[1]); + icons = await RecentIcons.getIcons(); + assert(icons.length == 2); + assert(equalIcon(icons[0], localIcons[1])); + assert(equalIcon(icons[1], localIcons[0])); + + await RecentIcons.putIcon(localIcons.first); + icons = await RecentIcons.getIcons(); + assert(icons.length == 2); + assert(equalIcon(icons[1], localIcons[1])); + assert(equalIcon(icons[0], localIcons[0])); + + for (var i = 0; i < RecentIcons.maxLength; ++i) { + await RecentIcons.putIcon(localIcons[10 + i]); + } + + icons = await RecentIcons.getIcons(); + assert(icons.length == RecentIcons.maxLength); + + for (var i = 0; i < RecentIcons.maxLength; ++i) { + assert( + equalIcon(icons[RecentIcons.maxLength - i - 1], localIcons[10 + i]), + ); + } + }); + }); +}