diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 18c3a7a..4ade4a7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,16 +2,16 @@ name: emoji_picker_flutter on: push: - branches: [ master ] + branches: [master] pull_request: - branches: [ master ] + branches: [master] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: env: - flutter_version: '3.13.6' - + flutter_version: "3.16.0" + jobs: build: runs-on: ubuntu-latest @@ -23,7 +23,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-java@v1 with: - java-version: '12.x' + java-version: "12.x" - name: Init Flutter uses: subosito/flutter-action@v2 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index d7f8fb6..f6f7c3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,60 @@ +## 2.0.0 🚀 + +- Better customization +- Better support for emoji and custom font +- Restructure of configurations +- Added Search View feature +- Update examples (Also added WhatsApp example) +- Add auto-scroll support +- Add long-press backspace support +- Improve SkinTone Overlay +- Updated Readme +- Update License to MIT +- Add tests + +## 2.0.0-dev.7 + +- Improve emoji regex and its performance +- Fix rendering issue with some emoji due to TextStyle handling +- Add tests + +## 2.0.0-dev.6 + +- Fix issue with `EmojiTextEditingController` during selection +- Fix issue with onBackspacePressed logic +- Fix example for custom font + +## 2.0.0-dev.5 + +- Improve documentation +- Improve examples +- prioritize emojiTextStyle over emojiSizeMax +- improve onBackspacePressed logic (trigger controller and callback method) + +## 2.0.0-dev.4 + +- Improve SkinTone Overlay logic +- Add WhatsApp example +- Update min Flutter version to 3.16.0 +- Update deprecated API's + +## 2.0.0-dev.3 + +- Add auto-scroll support by using `ScrollController` +- Add long-press backspace support + +## 2.0.0-dev.2 + +- Improve `EmojiTextEditingController` to ensure consistent appearance in Text and TextInput +- Update examples code +- Todo: Update ReadMe, Improve Search UI + +## 2.0.0-dev.1 + +- Added search feature 🔎 +- Add more customization possibilities +- Restructure & rename files + ## 1.6.4 - Add long-press backspace support (thx @vedasjad) diff --git a/LICENSE b/LICENSE index b7f3dfd..5e69c35 100644 --- a/LICENSE +++ b/LICENSE @@ -1,25 +1,21 @@ -BSD 2-Clause License +MIT License -Copyright (c) 2021, Stefan Humm -All rights reserved. +Copyright (c) 2024 Stefan Humm -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 74ba00a..49e810b 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,20 @@ -

-platform flutter -build -Star on Github -License: BSD-2-Clause +

+platform flutter +build +Star on Github +License: BSD-2-Clause

# emoji_picker_flutter Yet another Emoji Picker for Flutter 🤩 - + ## Key features + - Lightweight Package -- Faster Loading +- Fast Loading - Null-safety - Completely customizable - Material Design and Cupertino mode @@ -21,11 +22,16 @@ Yet another Emoji Picker for Flutter 🤩 - Optional recently used emoji tab - Skin Tone Support - Custom-Font Support +- Search Option + +[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/fintasys) ## Getting Started ```dart + import 'package:flutter/foundation.dart' as foundation; + EmojiPicker( onEmojiSelected: (Category category, Emoji emoji) { // Do something when emoji is tapped (optional) @@ -36,102 +42,188 @@ EmojiPicker( }, textEditingController: textEditingController, // pass here the same [TextEditingController] that is connected to your input field, usually a [TextFormField] config: Config( - columns: 7, - emojiSizeMax: 32 * (foundation.defaultTargetPlatform == TargetPlatform.iOS ? 1.30 : 1.0), // Issue: https://github.com/flutter/flutter/issues/28894 - verticalSpacing: 0, - horizontalSpacing: 0, - gridPadding: EdgeInsets.zero, - initCategory: Category.RECENT, - bgColor: Color(0xFFF2F2F2), - indicatorColor: Colors.blue, - iconColor: Colors.grey, - iconColorSelected: Colors.blue, - backspaceColor: Colors.blue, - skinToneDialogBgColor: Colors.white, - skinToneIndicatorColor: Colors.grey, - enableSkinTones: true, - recentTabBehavior: RecentTabBehavior.RECENT, - recentsLimit: 28, - noRecents: const Text( - 'No Recents', - style: TextStyle(fontSize: 20, color: Colors.black26), - textAlign: TextAlign.center, - ), // Needs to be const Widget - loadingIndicator: const SizedBox.shrink(), // Needs to be const Widget - tabIndicatorAnimDuration: kTabScrollDuration, - categoryIcons: const CategoryIcons(), - buttonMode: ButtonMode.MATERIAL, + height: 256, + bgColor: const Color(0xFFF2F2F2), + checkPlatformCompatibility: true, + emojiViewConfig: EmojiViewConfig( + // Issue: https://github.com/flutter/flutter/issues/28894 + emojiSizeMax: 28 * + (foundation.defaultTargetPlatform == TargetPlatform.iOS + ? 1.20 + : 1.0), + ), + swapCategoryAndBottomBar: false, + skinToneConfig: const SkinToneConfig(), + categoryViewConfig: const CategoryViewConfig(), + bottomActionBarConfig: const BottomActionBarConfig(), + searchViewConfig: const SearchViewConfig(), ), ) + ``` -See the [demo](https://github.com/Fintasys/emoji_picker_flutter/blob/master/example/lib/main.dart) for more detailed sample project. + +## Examples + +All examples can be found [here](https://github.com/Fintasys/emoji_picker_flutter/tree/master/example/lib) + +1. Default (Some Emoji might not be displayed correctly e.g. Frowning Face 🚨 Will be fixed with 3.17) + + + +2. Custom Font (Display all emoji correctly in the style of the font, additional ~15mb e.g. with Google Fonts) + + + +3. WhatsApp like customization + + + +\*All screenshots from Android. iOS displays by default most emoji correctly. ## Config -| property | description | default | -| --------------- | ------------------------------------------------------------------ |------------| -| columns | Number of emojis per row |7 | -| emojiSizeMax | Width and height the emoji will be maximal displayed |32.0 | -| verticalSpacing | Vertical spacing between emojis | 0 | -| horizontalSpacing | Horizontal spacing between emojis | 0 | -| gridPadding | The padding of GridView | EdgeInsets.zero | -| initCategory | The initial Category that will be selected |Category.RECENT | -| bgColor | The background color of the Widget |Color(0xFFF2F2F2) | -| indicatorColor | The color of the category indicator | Colors.blue | -| iconColor | The color of the category icons | Colors.grey | -| iconColorSelected | The color of the category icon when selected | Colors.blue | -| backspaceColor | The color of the backspace icon button | Colors.blue | -| skinToneDialogBgColor | The background color of the skin tone dialog | Colors.white | -| skinToneIndicatorColor | Color of the small triangle next to multiple skin tone emoji | Colors.grey | -| enableSkinTones | Enable feature to select a skin tone of certain emoji's | true | -| recentTabBehavior | Show extra tab with recently / popular used emoji | RecentTabBehavior.RECENT | -| recentsLimit | Limit of recently used emoji that will be saved | 28 | -| replaceEmojiOnLimitExceed | Replace latest emoji on recents list on limit exceed | false -| noRecents | A widget (usually [Text]) to be displayed if no recent emojis to display. Needs to be `const` Widget! | Text('No Recents', style: TextStyle(fontSize: 20, color: Colors.black26), textAlign: TextAlign.center) | -| loadingIndicator | A widget to display while emoji picker is initializing. Needs to be `const` Widget! | SizedBox.shrink() | -| tabIndicatorAnimDuration | Duration of tab indicator to animate to next category | Duration(milliseconds: 300) | -| categoryIcons | Determines the icon to display for each Category. You can change icons by setting them in the constructor. | CategoryIcons() | -| buttonMode | Choose between Material and Cupertino button style | ButtonMode.MATERIAL | -| checkPlatformCompatibility | Whether to filter out glyphs that platform cannot render with the default font (Android). | true | -| emojiSet | Custom emoji set, can be built based on `defaultEmojiSet` provided by the library. | null | -| emojiTextStyle | Text style to apply to individual emoji icons. Can be used to define custom emoji font either with GoogleFonts library or bundled with the app. | null | -| customSkinColorOverlayHorizontalOffset | Custom horizontal offset for SkinColor Overlay. Try to assign `0.0` when overlay is not at the correct position | null | +| property | description | default | +| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | +| height | Height of Emoji Picker | 256 | +| swapCategoryAndBottomBar | Swap the category view and bottom bar (category bottom and bottom bar top) | false | +| checkPlatformCompatibility | Whether to filter out glyphs that platform cannot render with the default font (Android). | true | +| emojiSet | Custom emoji set, can be built based on `defaultEmojiSet` provided by the library. | null | +| emojiTextStyle | Text style to apply to individual emoji icons. Can be used to define custom emoji font either with GoogleFonts library or bundled with the app. | null | +| emojiViewConfig | Emoji view config | const EmojiViewConfig() | +| skinToneConfig | Skin tone config | const SkinToneConfig | +| categoryViewConfig | Category view config | const CategoryViewConfig | +| bottomActionBarConfig | Bottom action bar config | const BottomActionBarConfig() | +| searchViewConfig | Search View config | const SearchViewConfig | + +## Emoji View Config + +| property | description | default | +| ------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| columns | Number of emojis per row | 7 | +| emojiSizeMax | Width and height the emoji will be maximal displayed | 32.0 | +| backgroundColor | The background color of the emoji view | const Color(0xFFEBEFF2) | +| verticalSpacing | Vertical spacing between emojis | 0 | +| horizontalSpacing | Horizontal spacing between emojis | 0 | +| gridPadding | The padding of GridView | EdgeInsets.zero | +| recentsLimit | Limit of recently used emoji that will be saved | 28 | +| replaceEmojiOnLimitExceed | Replace latest emoji on recents list on limit exceed | false | +| noRecents | A widget (usually [Text]) to be displayed if no recent emojis to display. Needs to be `const` Widget! | Text('No Recents', style: TextStyle(fontSize: 20, color: Colors.black26), textAlign: TextAlign.center) | +| loadingIndicator | A widget to display while emoji picker is initializing. Needs to be `const` Widget! | SizedBox.shrink() | +| buttonMode | Choose between Material and Cupertino button style | ButtonMode.MATERIAL | + +## SkinTone Config + +| property | description | default | +| --------------------- | ------------------------------------------------------------ | ------------ | +| enableSkinTones | Enable feature to select a skin tone of certain emoji's | true | +| dialogBackgroundColor | The background color of the skin tone dialog | Colors.white | +| indicatorColor | Color of the small triangle next to multiple skin tone emoji | Colors.grey | + +## Category View Config + +| property | description | default | +| ------------------------ | ---------------------------------------------------------------------------------------------------------- | --------------------------- | +| tabBarHeight | Height of category tab bar | 46.0 | +| tabIndicatorAnimDuration | Duration of tab indicator to animate to next category | Duration(milliseconds: 300) | +| initCategory | The initial Category that will be selected | Category.RECENT | +| recentTabBehavior | Show extra tab with recently / popular used emoji | RecentTabBehavior.RECENT | +| showBackspaceButton | Show backspace button in category view | false | +| backgroundColor | Background color of category tab bar | const Color(0xFFEBEFF2) | +| indicatorColor | The color of the category indicator | Colors.blue | +| iconColor | The color of the category icons | Colors.grey | +| iconColorSelected | The color of the category icon when selected | Colors.blue | +| backspaceColor | The color of the backspace icon button | Colors.blue | +| categoryIcons | Determines the icon to display for each Category. You can change icons by setting them in the constructor. | CategoryIcons() | +| customCategoryView | Customize the category widget | null | + +## Bottom Action Bar Config + +| property | description | default | +| --------------------- | ------------------------------------------ | ------------ | +| showBackspaceButton | Show backspace button in bottom action bar | true | +| backgroundColor | Background color of bottom action bar | Colors.blue | +| buttonColor | Fill color of buttons in bottom action bar | Colors.blue | +| buttonIconColor | Icon color of buttons | Colors.white | +| customBottomActionBar | Customize the bottom action bar widget | null | + +## Search View Config + +| property | description | default | +| ---------------- | ------------------------------------- | ----------------------- | +| backgroundColor | Background color of search view | const Color(0xFFEBEFF2) | +| buttonColor | Fill color of hide search view button | Colors.transparent | +| buttonIconColor | Icon color of hide search view button | Colors.black26 | +| customSearchView | Customize search view widget | null | ## Backspace-Button -You can add a Backspace-Button to the end category list by adding the callback method `onBackspacePressed: () { }` to the EmojiPicker-Widget. This will make it easier for your user to remove an added Emoji without showing the keyboard. Check out the example for more details about usage. Set it to null to hide the Backspace-Button. + +Backspace button is enabled by default on the bottom action bar. If you prefer to have the backspace button inside the category, you can enable it inside the `CategoryViewConfig`. +You can listen to the Backspace tap event by registering a callback inside `onBackspacePressed: () { }`. This will make it easier for your user to remove an added Emoji without showing the keyboard. Check out the example for more details about usage. + +Bottom Backspace Button + + + +Top Backspace Button ## Custom view -The appearance is completely customizable by setting `customWidget` property. If properties in Config are not enough you can inherit from `EmojiPickerBuilder` (recommended but not necessary) to make further adjustments. + +The appearance is completely customizable by setting `customWidget` property. If properties in Config are not enough you can inherit from `EmojiPickerView` (recommended but not necessary) to make further adjustments. + ```dart -class CustomView extends EmojiPickerBuilder { - CustomView(Config config, EmojiViewState state) : super(config, state); - @override - _CustomViewState createState() => _CustomViewState(); +class CustomView extends EmojiPickerView { + CustomView(Config config, EmojiViewState state, VoidCallback showSearchBar, + {super.key}) + : super( + config, + state, + showSearchBar, + ); + + @override + _CustomViewState createState() => _CustomViewState(); } class _CustomViewState extends State { - @override - Widget build(BuildContext context) { - // TODO: implement build - // Access widget.config and widget.state - return Container(); - } + @override + Widget build(BuildContext context) { + // TODO: implement build + // Access widget.config, widget.state and widget.showSearchBar + return Container(); + } } + + EmojiPicker( - onEmojiSelected: (Category category, Emoji emoji) { /* ...*/ }, - config: Config( /* ...*/ ), - customWidget: (config, state) => CustomView(config, state), + onEmojiSelected: (category, emoji) {/* ...*/}, + config: Config(/* ...*/), + customWidget: (config, state, showSearchView) => CustomView( + config, + state, + showSearchView, + ), ) + ``` +Each component can also be completely customized individually: + +- `SearchViewConfig` -> `customSearchView` + +- `CategoryViewConfig` -> `customCategoryView` + +- `BottomActionBarConfig` -> `customBottomActionBar` + ## Extended usage with EmojiPickerUtils + Find usage example [here](https://github.com/Fintasys/emoji_picker_flutter/blob/master/example/lib/main_key.dart) ```dart + // Get recently used emoji final recentEmojis = await EmojiPickerUtils().getRecentEmojis(); @@ -147,19 +239,17 @@ final textSpans = EmojiPickerUtils().setEmojiTextStyle('text', emojiStyle: style // Clear list of recent Emojis EmojiPickerUtils().clearRecentEmojis(key: key); + ``` ## Feel free to contribute to this package!! 🙇‍♂️ + Always happy if anyone wants to help to improve this package! ## If you need any features Please open an issue so that we can discuss your feature request 🙏 -## Support me 🙏 - -[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/fintasys) - ---
Made with 💙 in Tokyo
diff --git a/analysis_options.yaml b/analysis_options.yaml index 399b878..a057350 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -16,7 +16,6 @@ linter: - avoid_relative_lib_imports - avoid_renaming_method_parameters - avoid_return_types_on_setters - - avoid_returning_null - avoid_types_as_parameter_names - avoid_unused_constructor_parameters - await_only_futures diff --git a/android/build.gradle b/android/build.gradle index b57c814..065c6d8 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -2,7 +2,7 @@ group 'com.fintasys.emoji_picker_flutter' version '1.0-SNAPSHOT' buildscript { - ext.kotlin_version = '1.6.21' + ext.kotlin_version = '1.9.22' repositories { google() mavenCentral() @@ -25,13 +25,13 @@ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' android { - compileSdkVersion 33 + compileSdkVersion 34 sourceSets { main.java.srcDirs += 'src/main/kotlin' } defaultConfig { - minSdkVersion 19 + minSdkVersion 21 } if (project.android.hasProperty("namespace")) { namespace 'com.fintasys.emoji_picker_flutter' } diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 4a3b843..1474f31 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -26,7 +26,7 @@ apply plugin: 'kotlin-android' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { - compileSdkVersion 33 + compileSdkVersion 34 sourceSets { main.java.srcDirs += 'src/main/kotlin' @@ -35,8 +35,8 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.fintasys.emoji_picker_flutter_example" - minSdkVersion 19 - targetSdkVersion 33 + minSdkVersion 21 + targetSdkVersion 34 versionCode flutterVersionCode.toInteger() versionName flutterVersionName } diff --git a/example/android/build.gradle b/example/android/build.gradle index 3e36b9b..fd59c3d 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -1,5 +1,5 @@ buildscript { - ext.kotlin_version = '1.6.21' + ext.kotlin_version = '1.9.22' repositories { google() mavenCentral() diff --git a/example/assets/whatsapp_bg.png b/example/assets/whatsapp_bg.png new file mode 100644 index 0000000..12be842 Binary files /dev/null and b/example/assets/whatsapp_bg.png differ diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist index 9625e10..7c56964 100644 --- a/example/ios/Flutter/AppFrameworkInfo.plist +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 11.0 + 12.0 diff --git a/example/ios/Podfile b/example/ios/Podfile index fdcc671..7267d5a 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '11.0' +# platform :ios, '12.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -40,5 +40,12 @@ end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) + target.build_configurations.each do |config| + # Here you may have other things + if config.build_settings['WRAPPER_EXTENSION'] == 'bundle' + config.build_settings['DEVELOPMENT_TEAM'] = 'YOUR_TEAM_ID' + end + end end end + diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 56095c0..b22869b 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -4,17 +4,19 @@ PODS: - Flutter (1.0.0) - integration_test (0.0.1): - Flutter - - path_provider_ios (0.0.1): + - path_provider_foundation (0.0.1): - Flutter - - shared_preferences_ios (0.0.1): + - FlutterMacOS + - shared_preferences_foundation (0.0.1): - Flutter + - FlutterMacOS DEPENDENCIES: - emoji_picker_flutter (from `.symlinks/plugins/emoji_picker_flutter/ios`) - Flutter (from `Flutter`) - integration_test (from `.symlinks/plugins/integration_test/ios`) - - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) - - shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) EXTERNAL SOURCES: emoji_picker_flutter: @@ -23,18 +25,18 @@ EXTERNAL SOURCES: :path: Flutter integration_test: :path: ".symlinks/plugins/integration_test/ios" - path_provider_ios: - :path: ".symlinks/plugins/path_provider_ios/ios" - shared_preferences_ios: - :path: ".symlinks/plugins/shared_preferences_ios/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" SPEC CHECKSUMS: emoji_picker_flutter: fe2e6151c5b548e975d546e6eeb567daf0962a58 - Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 + Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 integration_test: 13825b8a9334a850581300559b8839134b124670 - path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 - shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad + path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 + shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 -PODFILE CHECKSUM: 70d9d25280d0dd177a5f637cdb0f0b0b12c6a189 +PODFILE CHECKSUM: 02cd373d6569789200c1010c74f86088d8761163 -COCOAPODS: 1.13.0 +COCOAPODS: 1.14.3 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 6ac1a64..dbd937c 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -105,7 +105,6 @@ 1B0526F3DF1423FABC8D53E5 /* Pods-RunnerTests.release.xcconfig */, FCA971EEC6C07796D7A192A5 /* Pods-RunnerTests.profile.xcconfig */, ); - name = Pods; path = Pods; sourceTree = ""; }; @@ -453,7 +452,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -468,7 +467,10 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = Y2HZAT3U5T; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -477,6 +479,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = com.fintasys.emojiPickerFlutterExample; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; @@ -580,7 +583,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -629,7 +632,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -646,7 +649,10 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = Y2HZAT3U5T; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -655,6 +661,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = com.fintasys.emojiPickerFlutterExample; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -668,7 +675,10 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = Y2HZAT3U5T; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -677,6 +687,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = com.fintasys.emojiPickerFlutterExample; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; diff --git a/example/ios/Runner/Base.lproj/Main.storyboard b/example/ios/Runner/Base.lproj/Main.storyboard index f3c2851..7739775 100644 --- a/example/ios/Runner/Base.lproj/Main.storyboard +++ b/example/ios/Runner/Base.lproj/Main.storyboard @@ -1,8 +1,10 @@ - - + + + - + + @@ -14,13 +16,14 @@ - + - + + diff --git a/example/lib/main.dart b/example/lib/main.dart index ad9e959..b7300be 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -6,7 +6,7 @@ void main() { runApp(const MyApp()); } -/// Example for EmojiPickerFlutter +/// Example for EmojiPicker class MyApp extends StatefulWidget { const MyApp({super.key}); @@ -15,21 +15,9 @@ class MyApp extends StatefulWidget { } class MyAppState extends State { - final TextEditingController _controller = TextEditingController(); - bool emojiShowing = false; - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - _onBackspacePressed() { - _controller - ..text = _controller.text.characters.toString() - ..selection = TextSelection.fromPosition( - TextPosition(offset: _controller.text.length)); - } + final _controller = TextEditingController(); + final _scrollController = ScrollController(); + bool _emojiShowing = false; @override Widget build(BuildContext context) { @@ -39,109 +27,114 @@ class MyAppState extends State { appBar: AppBar( title: const Text('Emoji Picker Example App'), ), - body: Column( - children: [ - const Spacer(), - Container( - height: 66.0, - color: Colors.blue, - child: Row( - children: [ - Material( - color: Colors.transparent, - child: IconButton( - onPressed: () { - setState(() { - emojiShowing = !emojiShowing; - }); - }, - icon: const Icon( - Icons.emoji_emotions, - color: Colors.white, + body: SafeArea( + child: Column( + children: [ + Expanded( + child: Center( + child: ValueListenableBuilder( + valueListenable: _controller, + builder: (context, text, child) { + return Text( + _controller.text, + ); + }, + ), + ), + ), + Container( + height: 66.0, + color: Colors.blue, + child: Row( + children: [ + Material( + color: Colors.transparent, + child: IconButton( + onPressed: () { + setState(() { + _emojiShowing = !_emojiShowing; + }); + }, + icon: const Icon( + Icons.emoji_emotions, + color: Colors.white, + ), ), ), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: TextField( - controller: _controller, - style: const TextStyle( - fontSize: 20.0, color: Colors.black87), - decoration: InputDecoration( - hintText: 'Type a message', - filled: true, - fillColor: Colors.white, - contentPadding: const EdgeInsets.only( + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: TextField( + controller: _controller, + scrollController: _scrollController, + style: const TextStyle( + fontSize: 20.0, + color: Colors.black87, + ), + maxLines: 1, + decoration: InputDecoration( + hintText: 'Type a message', + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.only( left: 16.0, bottom: 8.0, top: 8.0, - right: 16.0), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(50.0), - ), - )), + right: 16.0, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(50.0), + ), + )), + ), ), - ), - Material( - color: Colors.transparent, - child: IconButton( - onPressed: () { - // send message - }, - icon: const Icon( - Icons.send, - color: Colors.white, - )), - ) - ], - )), - Offstage( - offstage: !emojiShowing, - child: SizedBox( - height: 250, - child: EmojiPicker( - textEditingController: _controller, - onBackspacePressed: _onBackspacePressed, - config: Config( - columns: 7, + Material( + color: Colors.transparent, + child: IconButton( + onPressed: () { + // send message + }, + icon: const Icon( + Icons.send, + color: Colors.white, + )), + ) + ], + )), + Offstage( + offstage: !_emojiShowing, + child: EmojiPicker( + textEditingController: _controller, + scrollController: _scrollController, + config: Config( + height: 256, + checkPlatformCompatibility: true, + emojiViewConfig: EmojiViewConfig( // Issue: https://github.com/flutter/flutter/issues/28894 - emojiSizeMax: 32 * + emojiSizeMax: 28 * (foundation.defaultTargetPlatform == TargetPlatform.iOS - ? 1.30 + ? 1.2 : 1.0), - verticalSpacing: 0, - horizontalSpacing: 0, - gridPadding: EdgeInsets.zero, - initCategory: Category.RECENT, - bgColor: const Color(0xFFF2F2F2), - indicatorColor: Colors.blue, - iconColor: Colors.grey, - iconColorSelected: Colors.blue, - backspaceColor: Colors.blue, - skinToneDialogBgColor: Colors.white, - skinToneIndicatorColor: Colors.grey, - enableSkinTones: true, - recentTabBehavior: RecentTabBehavior.RECENT, - recentsLimit: 28, - replaceEmojiOnLimitExceed: false, - noRecents: const Text( - 'No Recents', - style: TextStyle(fontSize: 20, color: Colors.black26), - textAlign: TextAlign.center, - ), - loadingIndicator: const SizedBox.shrink(), - tabIndicatorAnimDuration: kTabScrollDuration, - categoryIcons: const CategoryIcons(), - buttonMode: ButtonMode.MATERIAL, - checkPlatformCompatibility: true, ), - )), - ), - ], + swapCategoryAndBottomBar: false, + skinToneConfig: const SkinToneConfig(), + categoryViewConfig: const CategoryViewConfig(), + bottomActionBarConfig: const BottomActionBarConfig(), + searchViewConfig: const SearchViewConfig(), + ), + ), + ), + ], + ), ), ), ); } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } } diff --git a/example/lib/main_custom_font.dart b/example/lib/main_custom_font.dart index aca19a1..fec9888 100644 --- a/example/lib/main_custom_font.dart +++ b/example/lib/main_custom_font.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; import 'package:flutter/foundation.dart' as foundation; import 'package:flutter/material.dart'; @@ -9,9 +7,7 @@ void main() { runApp(const MyApp()); } -/// This example covers some advanced topics, like custom emoji font, -/// inserting emojis in [TextField] with [EditableTextState], -/// altering the default emoji set, etc. +/// Example for EmojiPicker with Google Emoji Fonts class MyApp extends StatefulWidget { const MyApp({super.key}); @@ -20,74 +16,25 @@ class MyApp extends StatefulWidget { } class MyAppState extends State { - final _editKey = GlobalKey(); - final _focusNode = FocusNode(); - final TextEditingController _controller = - EmojiTextEditingController(emojiStyle: GoogleFonts.notoEmoji()); - bool emojiShowing = false; - - void _updateTextEditingValue(TextEditingValue value) { - (_editKey.currentState as TextSelectionGestureDetectorBuilderDelegate) - .editableTextKey - .currentState - ?.userUpdateTextEditingValue(value, SelectionChangedCause.keyboard); - } - - /// This demostrates advanced handling of the seleted emoji. - /// Updating TextEditingValue this way ensures that the underlying - /// EditableText will scroll to display caret position if necessary. - /// Simply updating controller text and selection properties does not achieve - /// that. - /// One of the limitations of this approach is that it cannot be used with - /// [TextFormField] widgets since they don't provide a way to reach their - /// internal TextField widget state. - /// - /// You can always fall back to basic integration by just setting - /// [textEditingController] parameter in the [EmojiPicker] constructor - /// (see basic example). - _onEmojiSelected(Emoji emoji) { - debugPrint('_onEmojiSelected: ${emoji.emoji}'); - - if (_controller.selection.base.offset < 0) { - _updateTextEditingValue(TextEditingValue( - text: _controller.text + emoji.emoji, - )); - return; - } - - final selection = _controller.selection; - final newText = _controller.text - .replaceRange(selection.start, selection.end, emoji.emoji); - final emojiLength = emoji.emoji.length; - _updateTextEditingValue(TextEditingValue( - text: newText, - selection: selection.copyWith( - baseOffset: selection.start + emojiLength, - extentOffset: selection.start + emojiLength, - ))); - } - - _onBackspacePressed() { - debugPrint('_onBackspacePressed'); - if (_controller.selection.base.offset < 0) { - return; - } - - final selection = _controller.value.selection; - final text = _controller.value.text; - final newTextBeforeCursor = - selection.textBefore(text).characters.skipLast(1).toString(); - _updateTextEditingValue(TextEditingValue( - text: newTextBeforeCursor + selection.textAfter(text), - selection: TextSelection.fromPosition( - TextPosition(offset: newTextBeforeCursor.length)))); - } + final _utils = EmojiPickerUtils(); + late final EmojiTextEditingController _controller; + late final TextStyle _textStyle; + final bool isApple = [TargetPlatform.iOS, TargetPlatform.macOS] + .contains(foundation.defaultTargetPlatform); + bool _emojiShowing = false; @override - void dispose() { - _controller.dispose(); - _focusNode.dispose(); - super.dispose(); + void initState() { + final fontSize = 24 * (isApple ? 1.2 : 1.0); + // 1. Define Custom Font & Text Style + _textStyle = DefaultEmojiTextStyle.copyWith( + fontFamily: GoogleFonts.notoEmoji().fontFamily, + fontSize: fontSize, + ); + + // 2. Use EmojiTextEditingController + _controller = EmojiTextEditingController(emojiTextStyle: _textStyle); + super.initState(); } @override @@ -98,142 +45,115 @@ class MyAppState extends State { appBar: AppBar( title: const Text('Emoji Picker Example App'), ), - body: Column( - children: [ - const Spacer(), - Padding( - padding: const EdgeInsets.all(12.0), - child: RichText( - textAlign: TextAlign.center, - text: TextSpan( - style: - const TextStyle(color: Colors.black, fontSize: 18.0), - children: EmojiPickerUtils().setEmojiTextStyle( - '⌨ This text demonstrates how you can include ' - 'custom-font-based emojis 😁 ' - 'in your static texts 🎉👏', - emojiStyle: GoogleFonts.notoEmoji( - color: Colors.blueAccent)))), - ), - Container( - height: 66.0, - color: Colors.blue, - child: Row( - children: [ - Material( - color: Colors.transparent, - child: IconButton( - onPressed: () { - setState(() { - emojiShowing = !emojiShowing; - if (emojiShowing) { - // If TextField remains focused, the keyboard - // will pop up on every emoji insert done with - // EditableTextState manipulation. - - // In a production app you might want to detect - // keyboard opens and hide emoji picker - // for more consistent experience. - _focusNode.unfocus(); - } else { - _focusNode.requestFocus(); - } - }); - }, - icon: const Icon( - Icons.emoji_emotions, - color: Colors.white, + body: SafeArea( + child: Column( + children: [ + Expanded( + child: Center( + child: ValueListenableBuilder( + valueListenable: _controller, + builder: (context, text, child) { + return RichText( + text: TextSpan( + style: const TextStyle( + color: Colors.black, + fontSize: 18.0, + ), + children: _utils.setEmojiTextStyle( + _controller.text, + emojiStyle: _textStyle, + ), ), - ), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: TextField( - key: _editKey, - controller: _controller, - focusNode: _focusNode, - style: const TextStyle( - fontSize: 20.0, color: Colors.black87), - decoration: InputDecoration( - hintText: 'Type a message', - filled: true, - fillColor: Colors.white, - contentPadding: const EdgeInsets.only( - left: 16.0, - bottom: 8.0, - top: 8.0, - right: 16.0), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(50.0), - ), - )), - ), - ), - Material( - color: Colors.transparent, - child: IconButton( + ); + }, + ), + ), + ), + Container( + height: 66.0, + color: Colors.blue, + child: Row( + children: [ + Material( + color: Colors.transparent, + child: IconButton( onPressed: () { - // send message + setState(() { + _emojiShowing = !_emojiShowing; + }); }, icon: const Icon( - Icons.send, + Icons.emoji_emotions, color: Colors.white, - )), - ) - ], - )), - Offstage( - offstage: !emojiShowing, - child: SizedBox( - height: 250, - child: EmojiPicker( - onEmojiSelected: (Category? category, Emoji emoji) { - _onEmojiSelected(emoji); - }, - onBackspacePressed: _onBackspacePressed, - config: Config( - columns: 7, - // Issue: https://github.com/flutter/flutter/issues/28894 - emojiSizeMax: 32 * - (!foundation.kIsWeb && Platform.isIOS ? 1.30 : 1.0), - verticalSpacing: 0, - horizontalSpacing: 0, - gridPadding: EdgeInsets.zero, - initCategory: Category.RECENT, - bgColor: const Color(0xFFF2F2F2), - indicatorColor: Colors.blue, - iconColor: Colors.grey, - iconColorSelected: Colors.blue, - backspaceColor: Colors.blue, - skinToneDialogBgColor: Colors.white, - skinToneIndicatorColor: Colors.grey, - enableSkinTones: false, - recentTabBehavior: RecentTabBehavior.RECENT, - recentsLimit: 28, - replaceEmojiOnLimitExceed: false, - noRecents: const Text( - 'No Recents', - style: TextStyle(fontSize: 20, color: Colors.black26), - textAlign: TextAlign.center, + ), + ), ), - loadingIndicator: - const Center(child: CircularProgressIndicator()), - tabIndicatorAnimDuration: kTabScrollDuration, - categoryIcons: const CategoryIcons(), - buttonMode: ButtonMode.MATERIAL, - checkPlatformCompatibility: false, - emojiTextStyle: GoogleFonts.notoColorEmoji(), - // or for single colored Emoji use: - // GoogleFonts.notoEmoji(color: Colors.black), - // or TextStyle(fontFamily: 'NotoColorEmoji', - // color: Colors.blueAccent)), - ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: TextField( + controller: _controller, + style: const TextStyle( + fontSize: 20.0, + color: Colors.black87, + ), + maxLines: 1, + decoration: InputDecoration( + hintText: 'Type a message', + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.only( + left: 16.0, + bottom: 8.0, + top: 8.0, + right: 16.0, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(50.0), + ), + )), + ), + ), + Material( + color: Colors.transparent, + child: IconButton( + onPressed: () { + // send message + }, + icon: const Icon( + Icons.send, + color: Colors.white, + )), + ) + ], )), - ), - ], + Offstage( + offstage: !_emojiShowing, + child: EmojiPicker( + textEditingController: _controller, + config: Config( + height: 256, + checkPlatformCompatibility: true, + emojiTextStyle: _textStyle, + emojiViewConfig: const EmojiViewConfig(), + swapCategoryAndBottomBar: false, + skinToneConfig: const SkinToneConfig(), + categoryViewConfig: const CategoryViewConfig(), + bottomActionBarConfig: const BottomActionBarConfig(), + searchViewConfig: const SearchViewConfig(), + ), + ), + ), + ], + ), ), ), ); } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } } diff --git a/example/lib/main_dynamic_columns.dart b/example/lib/main_dynamic_columns.dart index 0bb163e..197741b 100644 --- a/example/lib/main_dynamic_columns.dart +++ b/example/lib/main_dynamic_columns.dart @@ -16,20 +16,14 @@ class MyApp extends StatefulWidget { class MyAppState extends State { final TextEditingController _controller = TextEditingController(); - bool emojiShowing = false; - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } + bool _emojiShowing = false; @override Widget build(BuildContext context) { final screenSize = MediaQuery.of(context).size; const emojiPadding = 9 * 2; // 9 pixels on both left and right sides of each emoji. - final emojiSize = 32 * + final emojiSize = 28 * (foundation.defaultTargetPlatform == TargetPlatform .iOS // Issue: https://github.com/flutter/flutter/issues/28894 @@ -56,7 +50,7 @@ class MyAppState extends State { child: IconButton( onPressed: () { setState(() { - emojiShowing = !emojiShowing; + _emojiShowing = !_emojiShowing; }); }, icon: const Icon( @@ -101,39 +95,16 @@ class MyAppState extends State { ], )), Offstage( - offstage: !emojiShowing, + offstage: !_emojiShowing, child: SizedBox( height: 250, child: EmojiPicker( textEditingController: _controller, config: Config( - columns: numEmojiColumns, - emojiSizeMax: emojiSize, - verticalSpacing: 0, - horizontalSpacing: 0, - gridPadding: EdgeInsets.zero, - initCategory: Category.RECENT, - bgColor: const Color(0xFFF2F2F2), - indicatorColor: Colors.blue, - iconColor: Colors.grey, - iconColorSelected: Colors.blue, - backspaceColor: Colors.blue, - skinToneDialogBgColor: Colors.white, - skinToneIndicatorColor: Colors.grey, - enableSkinTones: true, - recentTabBehavior: RecentTabBehavior.RECENT, - recentsLimit: 28, - replaceEmojiOnLimitExceed: false, - noRecents: const Text( - 'No Recents', - style: TextStyle(fontSize: 20, color: Colors.black26), - textAlign: TextAlign.center, + emojiViewConfig: EmojiViewConfig( + emojiSizeMax: emojiSize, + columns: numEmojiColumns, ), - loadingIndicator: const SizedBox.shrink(), - tabIndicatorAnimDuration: kTabScrollDuration, - categoryIcons: const CategoryIcons(), - buttonMode: ButtonMode.MATERIAL, - checkPlatformCompatibility: true, ), )), ), @@ -142,4 +113,10 @@ class MyAppState extends State { ), ); } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } } diff --git a/example/lib/main_key.dart b/example/lib/main_key.dart index 235a45b..5447cd7 100644 --- a/example/lib/main_key.dart +++ b/example/lib/main_key.dart @@ -15,17 +15,11 @@ class MyApp extends StatefulWidget { class MyAppState extends State { final TextEditingController _controller = TextEditingController(); - bool emojiShowing = false; + bool _emojiShowing = false; // 1. Create GlobalKey for EmojiPickerState final key = GlobalKey(); - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { return MaterialApp( @@ -49,7 +43,7 @@ class MyAppState extends State { ), _buildTextInputField(), Offstage( - offstage: !emojiShowing, + offstage: !_emojiShowing, child: SizedBox( height: 250, child: EmojiPicker( @@ -76,7 +70,7 @@ class MyAppState extends State { child: IconButton( onPressed: () { setState(() { - emojiShowing = !emojiShowing; + _emojiShowing = !_emojiShowing; }); }, icon: const Icon( @@ -119,4 +113,10 @@ class MyAppState extends State { ), ); } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } } diff --git a/example/lib/main_search.dart b/example/lib/main_search.dart deleted file mode 100644 index 77f4a18..0000000 --- a/example/lib/main_search.dart +++ /dev/null @@ -1,373 +0,0 @@ -import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; -import 'package:flutter/material.dart'; - -void main() { - runApp(const MyApp()); -} - -/// Example for EmojiPickerFlutter that demonstrates possible search feature -/// implementation. -class MyApp extends StatefulWidget { - const MyApp({super.key}); - - @override - MyAppState createState() => MyAppState(); -} - -class MyAppState extends State { - final TextEditingController _controller = TextEditingController(); - final TextEditingController _searchController = TextEditingController(); - final ScrollController _searchScrollController = ScrollController(); - final FocusNode _searchFocusNode = FocusNode(); - List _searchResults = List.empty(); - OverlayEntry? _overlay; - final Config _config = const Config( - buttonMode: ButtonMode.MATERIAL, - ); - bool _isSearchFocused = false; - bool _emojiShowing = false; - - @override - void dispose() { - _closeSkinToneDialog(); - _controller.dispose(); - _searchController.dispose(); - _searchScrollController.dispose(); - _searchFocusNode.dispose(); - super.dispose(); - } - - void _onEmojiSelected(Category? category, Emoji emoji) { - final text = _controller.text; - final selection = _controller.selection; - final cursorPosition = _controller.selection.base.offset; - - if (cursorPosition < 0) { - _controller.text += emoji.emoji; - return; - } - final newText = - text.replaceRange(selection.start, selection.end, emoji.emoji); - final emojiLength = emoji.emoji.length; - _controller - ..text = newText - ..selection = selection.copyWith( - baseOffset: selection.start + emojiLength, - extentOffset: selection.start + emojiLength, - ); - } - - void _openSkinToneDialog( - BuildContext context, - Emoji emoji, - double emojiSize, - CategoryEmoji? categoryEmoji, - int index, - ) { - _closeSkinToneDialog(); - if (!emoji.hasSkinTone || !_config.enableSkinTones) { - return; - } - _overlay = _buildSkinToneOverlay( - context, - emoji, - emojiSize, - index, - ); - Overlay.of(context).insert(_overlay!); - } - - void _closeSkinToneDialog() { - _overlay?.remove(); - _overlay = null; - } - - /// Overlay for SkinTone - OverlayEntry _buildSkinToneOverlay( - BuildContext context, - Emoji emoji, - double emojiSize, - int index, - ) { - // Calculate position for skin tone dialog - final renderBox = context.findRenderObject() as RenderBox; - final offset = renderBox.localToGlobal(Offset.zero); - final emojiSpace = renderBox.size.width / _config.columns; - final leftOffset = _getLeftOffset(emojiSpace, index); - final left = offset.dx + index * emojiSpace + leftOffset; - final top = offset.dy; - - // Generate other skintone options - final skinTonesEmoji = SkinTone.values - .map((skinTone) => EmojiPickerUtils().applySkinTone(emoji, skinTone)) - .toList(); - - return OverlayEntry( - builder: (context) => Positioned( - left: left, - top: top, - child: Material( - elevation: 4.0, - child: EmojiContainer( - padding: const EdgeInsets.symmetric(vertical: 4.0), - color: _config.skinToneDialogBgColor, - buttonMode: _config.buttonMode, - child: Row( - children: [ - _buildSkinToneEmoji(emoji, emojiSpace, emojiSize), - _buildSkinToneEmoji(skinTonesEmoji[0], emojiSpace, emojiSize), - _buildSkinToneEmoji(skinTonesEmoji[1], emojiSpace, emojiSize), - _buildSkinToneEmoji(skinTonesEmoji[2], emojiSpace, emojiSize), - _buildSkinToneEmoji(skinTonesEmoji[3], emojiSpace, emojiSize), - _buildSkinToneEmoji(skinTonesEmoji[4], emojiSpace, emojiSize), - ], - ), - ), - ), - ), - ); - } - - // Build Emoji inside skin tone dialog - Widget _buildSkinToneEmoji( - Emoji emoji, - double width, - double emojiSize, - ) { - return SizedBox( - width: width, - height: width, - child: EmojiCell.fromConfig( - emoji: emoji, - emojiSize: emojiSize, - onEmojiSelected: (category, emoji) { - _onEmojiSelected(category, emoji); - _closeSkinToneDialog(); - }, - config: _config, - )); - } - - // Calucates the offset from the middle of selected emoji to the left side - // of the skin tone dialog - // Case 1: Selected Emoji is close to left border and offset needs to be - // reduced - // Case 2: Selected Emoji is close to right border and offset needs to be - // larger than half of the whole width - // Case 3: Enough space to left and right border and offset can be half - // of whole width - double _getLeftOffset(double emojiWidth, int column) { - var remainingColumns = - _config.columns - (column + 1 + (kSkinToneCount ~/ 2)); - if (column >= 0 && column < 3) { - return -1 * column * emojiWidth; - } else if (remainingColumns < 0) { - return -1 * - ((kSkinToneCount ~/ 2 - 1) + -1 * remainingColumns) * - emojiWidth; - } - return -1 * ((kSkinToneCount ~/ 2) * emojiWidth) + emojiWidth / 2; - } - - Widget _buildSearchBar(BuildContext context, bool isEmpty) { - return ColoredBox( - color: _config.bgColor, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (!_isSearchFocused) - IconButton( - onPressed: _searchFocusNode.requestFocus, - icon: const Icon(Icons.search), - visualDensity: VisualDensity.compact, - ) - else - IconButton( - onPressed: () { - _searchController.text = ''; - _searchFocusNode.unfocus(); - }, - icon: const Icon(Icons.arrow_back), - visualDensity: VisualDensity.compact, - ), - Expanded( - child: Focus( - onFocusChange: (hasFocus) { - setState(() { - _isSearchFocused = hasFocus; - }); - }, - child: TextField( - controller: _searchController, - focusNode: _searchFocusNode, - maxLines: 1, - onChanged: (text) async { - _searchResults = await EmojiPickerUtils() - .searchEmoji(text, defaultEmojiSet); - setState(() {}); - }, - decoration: InputDecoration( - border: InputBorder.none, - contentPadding: const EdgeInsets.symmetric( - horizontal: 4.0, vertical: 10.0), - isDense: true, - suffixIconConstraints: const BoxConstraints(), - suffixIcon: isEmpty - ? null - : IconButton( - onPressed: () { - _searchController.text = ''; - }, - icon: const Icon(Icons.clear), - visualDensity: VisualDensity.compact, - ), - ), - ), - ), - ) - ], - ), - ); - } - - Widget _buildSearchResults( - BuildContext context, double emojiSize, double cellSize) { - if (_searchResults.isEmpty) { - return SizedBox( - height: cellSize, - child: Center( - child: Text(_searchController.text.isEmpty - ? 'Type your search phrase' - : 'No matches'))); - } - return SizedBox( - height: cellSize, - child: ListView( - controller: _searchScrollController, - scrollDirection: Axis.horizontal, - children: [ - for (int i = 0; i < _searchResults.length; i++) - SizedBox( - width: cellSize, - child: EmojiCell.fromConfig( - emoji: _searchResults[i], - emojiSize: emojiSize, - index: i, - onEmojiSelected: (category, emoji) { - _closeSkinToneDialog(); - _onEmojiSelected(category, emoji); - }, - onSkinToneDialogRequested: - (emoji, emojiSize, categoryEmoji, index) => - _openSkinToneDialog( - context, - emoji, - emojiSize, - categoryEmoji, - index, - ), - config: _config, - ), - ) - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - return MaterialApp( - home: Scaffold( - backgroundColor: Colors.white, - appBar: AppBar( - title: const Text('Emoji Picker Search Example App'), - ), - body: LayoutBuilder(builder: (context, constraints) { - final emojiSize = _config.getEmojiSize(constraints.maxWidth); - // emojiSize is the size of the font, need some paddings around - final cellSize = emojiSize + 20.0; - return Column( - children: [ - const Spacer(), - Container( - height: 66.0, - color: Colors.blue, - child: Row( - children: [ - Material( - color: Colors.transparent, - child: IconButton( - onPressed: () { - setState(() { - _emojiShowing = !_emojiShowing; - }); - }, - icon: const Icon( - Icons.emoji_emotions, - color: Colors.white, - ), - ), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: TextField( - controller: _controller, - style: const TextStyle( - fontSize: 20.0, color: Colors.black87), - decoration: InputDecoration( - hintText: 'Type a message', - filled: true, - fillColor: Colors.white, - contentPadding: const EdgeInsets.only( - left: 16.0, - bottom: 8.0, - top: 8.0, - right: 16.0), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(50.0), - ), - )), - ), - ), - Material( - color: Colors.transparent, - child: IconButton( - onPressed: () { - // send message - }, - icon: const Icon( - Icons.send, - color: Colors.white, - )), - ) - ], - )), - Offstage( - offstage: !_emojiShowing, - child: ValueListenableBuilder( - valueListenable: _searchController, - builder: (context, value, _) { - return Column( - children: [ - if (value.text.isEmpty && !_isSearchFocused) - SizedBox( - height: 250, - child: EmojiPicker( - textEditingController: _controller, - config: _config, - )) - else - _buildSearchResults(context, emojiSize, cellSize), - _buildSearchBar(context, value.text.isEmpty) - ], - ); - }), - ) - ], - ); - }), - ), - ); - } -} diff --git a/example/lib/main_whatsapp.dart b/example/lib/main_whatsapp.dart new file mode 100644 index 0000000..6a537a8 --- /dev/null +++ b/example/lib/main_whatsapp.dart @@ -0,0 +1,456 @@ +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:flutter/foundation.dart' as foundation; + +const accentColor = Color(0xFF4BA586); +const accentColorDark = Color(0xFF377E6A); +const backgroundColor = Color(0xFFEEE7DF); +const secondaryColor = Color(0xFF8B98A0); +const systemBackgroundColor = Color(0xFFF7F8FA); + +void main() { + runApp(const MyApp()); +} + +/// Example for EmojiPicker with Google Emoji Fonts +class MyApp extends StatefulWidget { + const MyApp({super.key}); + + @override + MyAppState createState() => MyAppState(); +} + +class MyAppState extends State { + final _utils = EmojiPickerUtils(); + late final EmojiTextEditingController _controller; + late final ScrollController _scrollController; + late final FocusNode _focusNode; + late final TextStyle _textStyle; + final bool isApple = [TargetPlatform.iOS, TargetPlatform.macOS] + .contains(foundation.defaultTargetPlatform); + bool _emojiShowing = false; + + @override + void initState() { + final fontSize = 24 * (isApple ? 1.2 : 1.0); + // Define Custom Emoji Font & Text Style + _textStyle = DefaultEmojiTextStyle.copyWith( + fontFamily: GoogleFonts.notoColorEmoji().fontFamily, + fontSize: fontSize, + ); + + _controller = EmojiTextEditingController(emojiTextStyle: _textStyle); + _scrollController = ScrollController(); + _focusNode = FocusNode(); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: AnnotatedRegion( + value: const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + systemNavigationBarColor: systemBackgroundColor, + ), + child: Scaffold( + backgroundColor: backgroundColor, + appBar: AppBar( + backgroundColor: accentColorDark, + title: const Text( + 'Emoji Picker Example App', + style: TextStyle(color: Colors.white), + ), + ), + body: SafeArea( + child: Stack( + children: [ + Image.asset( + 'assets/whatsapp_bg.png', + width: MediaQuery.of(context).size.width, + height: MediaQuery.of(context).size.height, + opacity: const AlwaysStoppedAnimation(0.7), + fit: BoxFit.fill, + ), + Column( + children: [ + Expanded( + child: Center( + child: ValueListenableBuilder( + valueListenable: _controller, + builder: (context, text, child) { + return RichText( + textAlign: TextAlign.center, + text: TextSpan( + style: const TextStyle( + color: Colors.black, + fontSize: 18.0, + ), + children: _utils.setEmojiTextStyle( + _controller.text, + emojiStyle: _textStyle, + ), + ), + ); + }, + ), + ), + ), + Container( + height: 48.0, + margin: const EdgeInsets.symmetric( + vertical: 4.0, + ), + child: Row( + children: [ + Expanded( + child: Container( + margin: const EdgeInsets.symmetric( + horizontal: 4.0, + ), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.all( + Radius.circular(20), + ), + ), + child: Row( + children: [ + IconButton( + onPressed: () { + setState(() { + _emojiShowing = !_emojiShowing; + if (!_emojiShowing) { + WidgetsBinding.instance + .addPostFrameCallback((_) { + _focusNode.requestFocus(); + }); + } else { + _focusNode.unfocus(); + } + }); + }, + icon: Icon( + _emojiShowing + ? Icons.keyboard + : Icons.emoji_emotions_outlined, + color: secondaryColor, + ), + ), + Expanded( + child: TextField( + controller: _controller, + scrollController: _scrollController, + focusNode: _focusNode, + style: const TextStyle( + fontSize: 20.0, + color: Colors.black87, + ), + maxLines: 1, + decoration: const InputDecoration( + hintText: 'Type a message', + hintStyle: TextStyle( + color: secondaryColor, + fontWeight: FontWeight.normal), + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + ), + ), + ), + ], + ), + ), + ), + Container( + margin: const EdgeInsets.only(right: 4.0), + child: CircleAvatar( + backgroundColor: accentColor, + child: IconButton( + icon: const Icon( + Icons.send, + size: 20.0, + color: Colors.white, + ), + onPressed: () { + // send message + }, + ), + ), + ), + ], + ), + ), + Offstage( + offstage: !_emojiShowing, + child: EmojiPicker( + textEditingController: _controller, + scrollController: _scrollController, + config: Config( + height: 256, + checkPlatformCompatibility: true, + emojiTextStyle: _textStyle, + emojiViewConfig: const EmojiViewConfig( + backgroundColor: Colors.white, + ), + swapCategoryAndBottomBar: true, + skinToneConfig: const SkinToneConfig(), + categoryViewConfig: CategoryViewConfig( + backgroundColor: Colors.white, + dividerColor: Colors.white, + indicatorColor: accentColor, + iconColorSelected: Colors.black, + iconColor: secondaryColor, + customCategoryView: ( + config, + state, + tabController, + pageController, + ) { + return WhatsAppCategoryView( + config, + state, + tabController, + pageController, + ); + }, + categoryIcons: const CategoryIcons( + recentIcon: Icons.access_time_outlined, + smileyIcon: Icons.emoji_emotions_outlined, + animalIcon: Icons.cruelty_free_outlined, + foodIcon: Icons.coffee_outlined, + activityIcon: Icons.sports_soccer_outlined, + travelIcon: Icons.directions_car_filled_outlined, + objectIcon: Icons.lightbulb_outline, + symbolIcon: Icons.emoji_symbols_outlined, + flagIcon: Icons.flag_outlined, + ), + ), + bottomActionBarConfig: const BottomActionBarConfig( + backgroundColor: Colors.white, + buttonColor: Colors.white, + buttonIconColor: secondaryColor, + ), + searchViewConfig: SearchViewConfig( + backgroundColor: Colors.white, + customSearchView: ( + config, + state, + showEmojiView, + ) { + return WhatsAppSearchView( + config, + state, + showEmojiView, + ); + }, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} + +/// Customized Whatsapp category view +class WhatsAppCategoryView extends CategoryView { + const WhatsAppCategoryView( + super.config, + super.state, + super.tabController, + super.pageController, { + super.key, + }); + + @override + WhatsAppCategoryViewState createState() => WhatsAppCategoryViewState(); +} + +class WhatsAppCategoryViewState extends State + with SkinToneOverlayStateMixin { + @override + Widget build(BuildContext context) { + return Container( + color: widget.config.categoryViewConfig.backgroundColor, + child: Row( + children: [ + Expanded( + child: WhatsAppTabBar( + widget.config, + widget.tabController, + widget.pageController, + widget.state.categoryEmoji, + closeSkinToneOverlay, + ), + ), + _buildBackspaceButton(), + ], + ), + ); + } + + Widget _buildBackspaceButton() { + if (widget.config.categoryViewConfig.showBackspaceButton) { + return BackspaceButton( + widget.config, + widget.state.onBackspacePressed, + widget.state.onBackspaceLongPressed, + widget.config.categoryViewConfig.backspaceColor, + ); + } + return const SizedBox.shrink(); + } +} + +class WhatsAppTabBar extends StatelessWidget { + const WhatsAppTabBar( + this.config, + this.tabController, + this.pageController, + this.categoryEmojis, + this.closeSkinToneOverlay, { + super.key, + }); + + final Config config; + + final TabController tabController; + + final PageController pageController; + + final List categoryEmojis; + + final VoidCallback closeSkinToneOverlay; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: config.categoryViewConfig.tabBarHeight, + child: TabBar( + labelColor: config.categoryViewConfig.iconColorSelected, + indicatorColor: config.categoryViewConfig.indicatorColor, + unselectedLabelColor: config.categoryViewConfig.iconColor, + dividerColor: config.categoryViewConfig.dividerColor, + controller: tabController, + labelPadding: const EdgeInsets.only(top: 1.0), + indicatorSize: TabBarIndicatorSize.label, + indicator: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.black12, + ), + onTap: (index) { + closeSkinToneOverlay(); + pageController.jumpToPage(index); + }, + tabs: categoryEmojis + .asMap() + .entries + .map( + (item) => _buildCategory(item.key, item.value.category)) + .toList(), + ), + ); + } + + Widget _buildCategory(int index, Category category) { + return Tab( + child: Padding( + padding: const EdgeInsets.all(6.0), + child: Icon( + getIconForCategory( + config.categoryViewConfig.categoryIcons, + category, + ), + size: 20, + ), + ), + ); + } +} + +/// Custom Whatsapp Search view implementation +class WhatsAppSearchView extends SearchView { + const WhatsAppSearchView(super.config, super.state, super.showEmojiView, + {super.key}); + + @override + WhatsAppSearchViewState createState() => WhatsAppSearchViewState(); +} + +class WhatsAppSearchViewState extends SearchViewState { + @override + Widget build(BuildContext context) { + return LayoutBuilder(builder: (context, constraints) { + final emojiSize = + widget.config.emojiViewConfig.getEmojiSize(constraints.maxWidth); + final emojiBoxSize = + widget.config.emojiViewConfig.getEmojiBoxSize(constraints.maxWidth); + return Container( + color: widget.config.searchViewConfig.backgroundColor, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: emojiBoxSize + 8.0, + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 4.0), + scrollDirection: Axis.horizontal, + itemCount: results.length, + itemBuilder: (context, index) { + return buildEmoji( + results[index], + emojiSize, + emojiBoxSize, + ); + }, + ), + ), + Row( + children: [ + IconButton( + onPressed: widget.showEmojiView, + color: widget.config.searchViewConfig.buttonColor, + icon: Icon( + Icons.arrow_back, + color: widget.config.searchViewConfig.buttonIconColor, + size: 20.0, + ), + ), + Expanded( + child: TextField( + onChanged: onTextInputChanged, + focusNode: focusNode, + decoration: const InputDecoration( + border: InputBorder.none, + hintText: 'Search', + hintStyle: TextStyle( + color: secondaryColor, + fontWeight: FontWeight.normal, + ), + contentPadding: EdgeInsets.zero, + ), + ), + ), + ], + ), + ], + ), + ); + }); + } +} diff --git a/example/macos/Podfile b/example/macos/Podfile index dade8df..049abe2 100644 --- a/example/macos/Podfile +++ b/example/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.11' +platform :osx, '10.14' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/example/macos/Podfile.lock b/example/macos/Podfile.lock index 5d9a550..670b47f 100644 --- a/example/macos/Podfile.lock +++ b/example/macos/Podfile.lock @@ -2,27 +2,35 @@ PODS: - emoji_picker_flutter (0.0.1): - FlutterMacOS - FlutterMacOS (1.0.0) - - shared_preferences_macos (0.0.1): + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter - FlutterMacOS DEPENDENCIES: - emoji_picker_flutter (from `Flutter/ephemeral/.symlinks/plugins/emoji_picker_flutter/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - - shared_preferences_macos (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_macos/macos`) + - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) EXTERNAL SOURCES: emoji_picker_flutter: :path: Flutter/ephemeral/.symlinks/plugins/emoji_picker_flutter/macos FlutterMacOS: :path: Flutter/ephemeral - shared_preferences_macos: - :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_macos/macos + path_provider_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin SPEC CHECKSUMS: emoji_picker_flutter: 533634326b1c5de9a181ba14b9758e6dfe967a20 - FlutterMacOS: 57701585bf7de1b3fc2bb61f6378d73bbdea8424 - shared_preferences_macos: 480ce071d0666e37cef23fe6c702293a3d21799e + FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 + shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 -PODFILE CHECKSUM: 6eac6b3292e5142cfc23bdeb71848a40ec51c14c +PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7 -COCOAPODS: 1.11.2 +COCOAPODS: 1.14.3 diff --git a/example/macos/Runner.xcodeproj/project.pbxproj b/example/macos/Runner.xcodeproj/project.pbxproj index fb2338a..14c1dde 100644 --- a/example/macos/Runner.xcodeproj/project.pbxproj +++ b/example/macos/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 54; objects = { /* Begin PBXAggregateTarget section */ @@ -203,7 +203,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 0930; + LastUpgradeCheck = 1430; ORGANIZATIONNAME = ""; TargetAttributes = { 33CC10EC2044A3C60003C045 = { @@ -273,6 +273,7 @@ }; 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -404,7 +405,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -483,7 +484,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -530,7 +531,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index ae8ff59..e495d20 100644 --- a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ =3.2.0-0 <4.0.0" - flutter: ">=3.7.0" + dart: ">=3.2.0 <4.0.0" + flutter: ">=3.16.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 1e1ca20..2461b36 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -10,14 +10,16 @@ dependencies: flutter: sdk: flutter - google_fonts: ^4.0.4 + google_fonts: ^6.1.0 emoji_picker_flutter: path: ../ dev_dependencies: - flutter_lints: ^2.0.3 + flutter_lints: ^3.0.1 integration_test: sdk: flutter flutter: uses-material-design: true + assets: + - assets/ diff --git a/lib/emoji_picker_flutter.dart b/lib/emoji_picker_flutter.dart index 8de0a7b..8c20df3 100644 --- a/lib/emoji_picker_flutter.dart +++ b/lib/emoji_picker_flutter.dart @@ -1,16 +1,35 @@ library emoji_picker_flutter; -export 'package:emoji_picker_flutter/src/category_emoji.dart'; -export 'package:emoji_picker_flutter/src/category_icons.dart'; +export 'package:emoji_picker_flutter/src/bottom_action_bar/bottom_action_bar.dart'; +export 'package:emoji_picker_flutter/src/bottom_action_bar/bottom_action_bar_config.dart'; +export 'package:emoji_picker_flutter/src/bottom_action_bar/default_bottom_action_bar.dart'; +export 'package:emoji_picker_flutter/src/category_view/category_emoji.dart'; +export 'package:emoji_picker_flutter/src/category_view/category_icon.dart'; +export 'package:emoji_picker_flutter/src/category_view/category_icons.dart'; +export 'package:emoji_picker_flutter/src/category_view/category_view.dart'; +export 'package:emoji_picker_flutter/src/category_view/category_view_config.dart'; +export 'package:emoji_picker_flutter/src/category_view/default_category_tab_bar.dart'; +export 'package:emoji_picker_flutter/src/category_view/default_category_view.dart'; +export 'package:emoji_picker_flutter/src/category_view/recent_tab_behavior.dart'; export 'package:emoji_picker_flutter/src/config.dart'; export 'package:emoji_picker_flutter/src/default_emoji_set.dart'; export 'package:emoji_picker_flutter/src/emoji.dart'; -export 'package:emoji_picker_flutter/src/emoji_cell.dart'; -export 'package:emoji_picker_flutter/src/emoji_container.dart'; export 'package:emoji_picker_flutter/src/emoji_picker.dart'; -export 'package:emoji_picker_flutter/src/emoji_picker_builder.dart'; export 'package:emoji_picker_flutter/src/emoji_picker_utils.dart'; -export 'package:emoji_picker_flutter/src/emoji_skin_tones.dart'; export 'package:emoji_picker_flutter/src/emoji_text_editing_controller.dart'; +export 'package:emoji_picker_flutter/src/emoji_text_style.dart'; +export 'package:emoji_picker_flutter/src/emoji_view/default_emoji_picker_view.dart'; +export 'package:emoji_picker_flutter/src/emoji_view/emoji_container.dart'; +export 'package:emoji_picker_flutter/src/emoji_view/emoji_picker_view.dart'; +export 'package:emoji_picker_flutter/src/emoji_view/emoji_view_config.dart'; export 'package:emoji_picker_flutter/src/emoji_view_state.dart'; -export 'package:emoji_picker_flutter/src/recent_tab_behavior.dart'; +export 'package:emoji_picker_flutter/src/recent_emoji.dart'; +export 'package:emoji_picker_flutter/src/search_view/default_search_view.dart'; +export 'package:emoji_picker_flutter/src/search_view/search_view.dart'; +export 'package:emoji_picker_flutter/src/search_view/search_view_config.dart'; +export 'package:emoji_picker_flutter/src/skin_tones/emoji_skin_tones.dart'; +export 'package:emoji_picker_flutter/src/skin_tones/skin_tone_config.dart'; +export 'package:emoji_picker_flutter/src/skin_tones/skin_tone_overlay.dart'; +export 'package:emoji_picker_flutter/src/skin_tones/triangle_decoration.dart'; +export 'package:emoji_picker_flutter/src/widgets/backspace_button.dart'; +export 'package:emoji_picker_flutter/src/widgets/emoji_cell.dart'; diff --git a/lib/src/bottom_action_bar/bottom_action_bar.dart b/lib/src/bottom_action_bar/bottom_action_bar.dart new file mode 100644 index 0000000..50ca718 --- /dev/null +++ b/lib/src/bottom_action_bar/bottom_action_bar.dart @@ -0,0 +1,22 @@ +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; +import 'package:flutter/material.dart'; + +/// Template class for custom implementation +abstract class BottomActionBar extends StatefulWidget { + /// Constructor + const BottomActionBar( + this.config, + this.state, + this.showSearchView, { + Key? key, + }) : super(key: key); + + /// Config for customizations + final Config config; + + /// State that holds current emoji data + final EmojiViewState state; + + /// Show Search Bar + final VoidCallback showSearchView; +} diff --git a/lib/src/bottom_action_bar/bottom_action_bar_config.dart b/lib/src/bottom_action_bar/bottom_action_bar_config.dart new file mode 100644 index 0000000..ef914b2 --- /dev/null +++ b/lib/src/bottom_action_bar/bottom_action_bar_config.dart @@ -0,0 +1,59 @@ +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; +import 'package:flutter/material.dart'; + +/// Callback function for custom bottom action bar +typedef BottomActionBarBuilder = Widget Function( + Config config, + EmojiViewState state, + VoidCallback showSearchView, +); + +/// Bottom Action Bar Config +class BottomActionBarConfig { + /// Constructor + const BottomActionBarConfig({ + this.enabled = true, + this.showBackspaceButton = true, + this.backgroundColor = Colors.blue, + this.buttonColor = Colors.blue, + this.buttonIconColor = Colors.white, + this.customBottomActionBar, + }); + + /// Enable Bottom Action Bar + final bool enabled; + + /// Show Backspace button + final bool showBackspaceButton; + + /// Background color of search bar + final Color? backgroundColor; + + /// Search Button color + final Color buttonColor; + + /// Search Button Icon color + final Color buttonIconColor; + + /// Custom search bar + /// Hot reload is not supported + final BottomActionBarBuilder? customBottomActionBar; + + @override + bool operator ==(other) { + return (other is BottomActionBarConfig) && + other.enabled == enabled && + other.showBackspaceButton == showBackspaceButton && + other.backgroundColor == backgroundColor && + other.buttonColor == buttonColor && + other.buttonIconColor == buttonIconColor; + } + + @override + int get hashCode => + enabled.hashCode ^ + showBackspaceButton.hashCode ^ + backgroundColor.hashCode ^ + buttonColor.hashCode ^ + buttonIconColor.hashCode; +} diff --git a/lib/src/bottom_action_bar/default_bottom_action_bar.dart b/lib/src/bottom_action_bar/default_bottom_action_bar.dart new file mode 100644 index 0000000..6f8b5ed --- /dev/null +++ b/lib/src/bottom_action_bar/default_bottom_action_bar.dart @@ -0,0 +1,50 @@ +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; +import 'package:flutter/material.dart'; + +/// Default Bottom Action Bar implementation +class DefaultBottomActionBar extends BottomActionBar { + /// Constructor + DefaultBottomActionBar( + Config config, EmojiViewState state, VoidCallback showSearchView) + : super(config, state, showSearchView); + + @override + State createState() => _DefaultBottomActionBarState(); +} + +class _DefaultBottomActionBarState extends State { + @override + Widget build(BuildContext context) { + return Container( + color: widget.config.bottomActionBarConfig.backgroundColor, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + CircleAvatar( + backgroundColor: widget.config.bottomActionBarConfig.buttonColor, + child: IconButton( + onPressed: widget.showSearchView, + icon: Icon( + Icons.search, + color: widget.config.bottomActionBarConfig.buttonIconColor, + ), + ), + ), + _buildBackspaceButton(), + ], + ), + ); + } + + Widget _buildBackspaceButton() { + if (widget.config.bottomActionBarConfig.showBackspaceButton) { + return BackspaceButton( + widget.config, + widget.state.onBackspacePressed, + widget.state.onBackspaceLongPressed, + widget.config.bottomActionBarConfig.buttonIconColor, + ); + } + return const SizedBox.shrink(); + } +} diff --git a/lib/src/category_emoji.dart b/lib/src/category_view/category_emoji.dart similarity index 100% rename from lib/src/category_emoji.dart rename to lib/src/category_view/category_emoji.dart diff --git a/lib/src/category_icon.dart b/lib/src/category_view/category_icon.dart similarity index 100% rename from lib/src/category_icon.dart rename to lib/src/category_view/category_icon.dart diff --git a/lib/src/category_icons.dart b/lib/src/category_view/category_icons.dart similarity index 95% rename from lib/src/category_icons.dart rename to lib/src/category_view/category_icons.dart index 852eafb..e0dbe46 100644 --- a/lib/src/category_icons.dart +++ b/lib/src/category_view/category_icons.dart @@ -1,4 +1,3 @@ -import 'package:emoji_picker_flutter/src/category_icon.dart'; import 'package:flutter/material.dart'; /// Class used to define all the [CategoryIcon] shown for each [Category] diff --git a/lib/src/category_view/category_view.dart b/lib/src/category_view/category_view.dart new file mode 100644 index 0000000..74c41e6 --- /dev/null +++ b/lib/src/category_view/category_view.dart @@ -0,0 +1,63 @@ +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; +import 'package:flutter/material.dart'; + +/// Template class for custom implementation +/// Inhert this class to create your own Category view +abstract class CategoryView extends StatefulWidget { + /// Constructor + const CategoryView( + this.config, + this.state, + this.tabController, + this.pageController, { + Key? key, + }) : super(key: key); + + /// Config for customizations + final Config config; + + /// State that holds current emoji data + final EmojiViewState state; + + /// TabController for Category view + final TabController tabController; + + /// Page Controller of Emoji view + final PageController pageController; +} + +/// Returns the icon for the category +IconData getIconForCategory(CategoryIcons categoryIcons, Category category) { + switch (category) { + case Category.RECENT: + return categoryIcons.recentIcon; + case Category.SMILEYS: + return categoryIcons.smileyIcon; + case Category.ANIMALS: + return categoryIcons.animalIcon; + case Category.FOODS: + return categoryIcons.foodIcon; + case Category.TRAVEL: + return categoryIcons.travelIcon; + case Category.ACTIVITIES: + return categoryIcons.activityIcon; + case Category.OBJECTS: + return categoryIcons.objectIcon; + case Category.SYMBOLS: + return categoryIcons.symbolIcon; + case Category.FLAGS: + return categoryIcons.flagIcon; + default: + throw Exception('Unsupported Category'); + } +} + +/// Template class for custom implementation +/// Inhert this class to create your own category view state +class CategoryViewState extends State + with SkinToneOverlayStateMixin { + @override + Widget build(BuildContext context) { + throw UnimplementedError('Category View implementation missing'); + } +} diff --git a/lib/src/category_view/category_view_config.dart b/lib/src/category_view/category_view_config.dart new file mode 100644 index 0000000..70d3f51 --- /dev/null +++ b/lib/src/category_view/category_view_config.dart @@ -0,0 +1,103 @@ +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; +import 'package:flutter/material.dart'; + +/// Callback function for custom category view +typedef CategoryViewBuilder = Widget Function( + Config config, + EmojiViewState state, + TabController tabController, + PageController pageController, +); + +/// Category view Config +class CategoryViewConfig { + /// Constructor + const CategoryViewConfig({ + this.tabBarHeight = 46.0, + this.tabIndicatorAnimDuration = kTabScrollDuration, + this.initCategory = Category.RECENT, + this.recentTabBehavior = RecentTabBehavior.RECENT, + this.showBackspaceButton = false, + this.backgroundColor = const Color(0xFFEBEFF2), + this.indicatorColor = Colors.blue, + this.iconColor = Colors.grey, + this.iconColorSelected = Colors.blue, + this.backspaceColor = Colors.blue, + this.dividerColor, + this.categoryIcons = const CategoryIcons(), + this.customCategoryView, + }); + + /// Tab bar height + final double tabBarHeight; + + /// Duration of tab indicator to animate to next category + final Duration tabIndicatorAnimDuration; + + /// The initial [Category] that will be selected + /// This [Category] will have its button in the bottombar darkened + final Category initCategory; + + /// Behavior of Recent Tab (Recent, Popular) + final RecentTabBehavior recentTabBehavior; + + /// Show Backspace button + final bool showBackspaceButton; + + /// Background color of TabBar + final Color backgroundColor; + + /// The color of the category indicator + final Color indicatorColor; + + /// The color of the category icons + final Color iconColor; + + /// The color of the category icon when selected + final Color iconColorSelected; + + /// The color of the backspace icon button + final Color backspaceColor; + + /// Divider color between TabBar and emoji's + final Color? dividerColor; + + /// Determines the icon to display for each [Category] + final CategoryIcons categoryIcons; + + /// Custom search bar + /// Hot reload is not supported + final CategoryViewBuilder? customCategoryView; + + @override + bool operator ==(other) { + return (other is CategoryViewConfig) && + other.tabBarHeight == tabBarHeight && + other.tabIndicatorAnimDuration == tabIndicatorAnimDuration && + other.initCategory == initCategory && + other.recentTabBehavior == recentTabBehavior && + other.showBackspaceButton == showBackspaceButton && + other.backgroundColor == backgroundColor && + other.indicatorColor == indicatorColor && + other.iconColor == iconColor && + other.iconColorSelected == iconColorSelected && + other.backspaceColor == backspaceColor && + other.dividerColor == dividerColor && + other.categoryIcons == categoryIcons; + } + + @override + int get hashCode => + tabBarHeight.hashCode ^ + tabIndicatorAnimDuration.hashCode ^ + initCategory.hashCode ^ + recentTabBehavior.hashCode ^ + showBackspaceButton.hashCode ^ + backgroundColor.hashCode ^ + indicatorColor.hashCode ^ + iconColor.hashCode ^ + iconColorSelected.hashCode ^ + backspaceColor.hashCode ^ + dividerColor.hashCode ^ + categoryIcons.hashCode; +} diff --git a/lib/src/category_view/default_category_tab_bar.dart b/lib/src/category_view/default_category_tab_bar.dart new file mode 100644 index 0000000..42a9d2b --- /dev/null +++ b/lib/src/category_view/default_category_tab_bar.dart @@ -0,0 +1,63 @@ +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; +import 'package:flutter/material.dart'; + +/// Default category tab bar +class DefaultCategoryTabBar extends StatelessWidget { + /// Constructor + const DefaultCategoryTabBar( + this.config, + this.tabController, + this.pageController, + this.categoryEmojis, + this.closeSkinToneOverlay, { + Key? key, + }) : super(key: key); + + /// Config + final Config config; + + /// Tab controller + final TabController tabController; + + /// Page controller + final PageController pageController; + + /// Category emojis + final List categoryEmojis; + + /// Close skin tone overlay callback + final VoidCallback closeSkinToneOverlay; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: config.categoryViewConfig.tabBarHeight, + child: TabBar( + labelColor: config.categoryViewConfig.iconColorSelected, + indicatorColor: config.categoryViewConfig.indicatorColor, + unselectedLabelColor: config.categoryViewConfig.iconColor, + dividerColor: config.categoryViewConfig.dividerColor, + controller: tabController, + labelPadding: EdgeInsets.zero, + onTap: (index) { + closeSkinToneOverlay(); + pageController.jumpToPage(index); + }, + tabs: categoryEmojis + .asMap() + .entries + .map( + (item) => _buildCategoryTab(item.key, item.value.category)) + .toList(), + ), + ); + } + + Widget _buildCategoryTab(int index, Category category) { + return Tab( + icon: Icon( + getIconForCategory(config.categoryViewConfig.categoryIcons, category), + ), + ); + } +} diff --git a/lib/src/category_view/default_category_view.dart b/lib/src/category_view/default_category_view.dart new file mode 100644 index 0000000..c9308cf --- /dev/null +++ b/lib/src/category_view/default_category_view.dart @@ -0,0 +1,53 @@ +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; +import 'package:flutter/material.dart'; + +/// Default category view +class DefaultCategoryView extends CategoryView { + /// Constructor + DefaultCategoryView( + Config config, + EmojiViewState state, + TabController tabController, + PageController pageController, { + Key? key, + }) : super(config, state, tabController, pageController, key: key); + + @override + DefaultCategoryViewState createState() => DefaultCategoryViewState(); +} + +/// Default Category View State +class DefaultCategoryViewState extends CategoryViewState { + @override + Widget build(BuildContext context) { + return Container( + color: widget.config.categoryViewConfig.backgroundColor, + child: Row( + children: [ + Expanded( + child: DefaultCategoryTabBar( + widget.config, + widget.tabController, + widget.pageController, + widget.state.categoryEmoji, + closeSkinToneOverlay, + ), + ), + _buildBackspaceButton(), + ], + ), + ); + } + + Widget _buildBackspaceButton() { + if (widget.config.categoryViewConfig.showBackspaceButton) { + return BackspaceButton( + widget.config, + widget.state.onBackspacePressed, + widget.state.onBackspaceLongPressed, + widget.config.categoryViewConfig.backspaceColor, + ); + } + return const SizedBox.shrink(); + } +} diff --git a/lib/src/recent_tab_behavior.dart b/lib/src/category_view/recent_tab_behavior.dart similarity index 100% rename from lib/src/recent_tab_behavior.dart rename to lib/src/category_view/recent_tab_behavior.dart diff --git a/lib/src/config.dart b/lib/src/config.dart index 1809025..efad031 100644 --- a/lib/src/config.dart +++ b/lib/src/config.dart @@ -1,228 +1,84 @@ -import 'dart:math'; - -import 'package:emoji_picker_flutter/src/category_emoji.dart'; -import 'package:emoji_picker_flutter/src/category_icons.dart'; -import 'package:emoji_picker_flutter/src/emoji_picker.dart'; -import 'package:emoji_picker_flutter/src/recent_tab_behavior.dart'; +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; import 'package:flutter/material.dart'; -/// Default Widget if no recent is available -const DefaultNoRecentsWidget = Text( - 'No Recents', - style: TextStyle(fontSize: 20, color: Colors.black26), - textAlign: TextAlign.center, -); +/// Number of skin tone icons +const kSkinToneCount = 6; /// Config for customizations class Config { /// Constructor const Config({ - this.columns = 7, - this.emojiSizeMax = 32.0, - this.verticalSpacing = 0, - this.horizontalSpacing = 0, - this.gridPadding = EdgeInsets.zero, - this.initCategory = Category.RECENT, - this.bgColor = const Color(0xFFEBEFF2), - this.indicatorColor = Colors.blue, - this.iconColor = Colors.grey, - this.iconColorSelected = Colors.blue, - this.backspaceColor = Colors.blue, - this.skinToneDialogBgColor = Colors.white, - this.skinToneIndicatorColor = Colors.grey, - this.enableSkinTones = true, - this.recentTabBehavior = RecentTabBehavior.RECENT, - this.recentsLimit = 28, - this.replaceEmojiOnLimitExceed = false, - this.noRecents = DefaultNoRecentsWidget, - this.loadingIndicator = const SizedBox.shrink(), - this.tabIndicatorAnimDuration = kTabScrollDuration, - this.categoryIcons = const CategoryIcons(), - this.buttonMode = ButtonMode.MATERIAL, + this.height = 256, + this.swapCategoryAndBottomBar = false, this.checkPlatformCompatibility = true, - this.emojiSet, + this.emojiSet = defaultEmojiSet, this.emojiTextStyle, - this.customSkinColorOverlayHorizontalOffset, + this.emojiViewConfig = const EmojiViewConfig(), + this.skinToneConfig = const SkinToneConfig(), + this.categoryViewConfig = const CategoryViewConfig(), + this.bottomActionBarConfig = const BottomActionBarConfig(), + this.searchViewConfig = const SearchViewConfig(), }); - /// Number of emojis per row - final int columns; - - /// Width and height the emoji will be maximal displayed - /// Can be smaller due to screen size and amount of columns - final double emojiSizeMax; - - /// Verical spacing between emojis - final double verticalSpacing; - - /// Horizontal spacing between emojis - final double horizontalSpacing; - - /// The initial [Category] that will be selected - /// This [Category] will have its button in the bottombar darkened - final Category initCategory; - - /// The background color of the Widget - final Color bgColor; - - /// The color of the category indicator - final Color indicatorColor; - - /// The color of the category icons - final Color iconColor; - - /// The color of the category icon when selected - final Color iconColorSelected; - - /// The color of the backspace icon button - final Color backspaceColor; - - /// The background color of the skin tone dialog - final Color skinToneDialogBgColor; - - /// Color of the small triangle next to multiple skin tone emoji - final Color skinToneIndicatorColor; - - /// Enable feature to select a skin tone of certain emoji's - final bool enableSkinTones; + /// Max Height of the Emoji's view + final double height; - /// Behavior of Recent Tab (Recent, Popular) - final RecentTabBehavior recentTabBehavior; - - /// Limit of recently used emoji that will be saved - final int recentsLimit; - - /// A widget (usually [Text]) to be displayed if no recent emojis to display - final Widget noRecents; - - /// A widget to display while emoji picker is initializing - final Widget loadingIndicator; - - /// Duration of tab indicator to animate to next category - final Duration tabIndicatorAnimDuration; - - /// Determines the icon to display for each [Category] - final CategoryIcons categoryIcons; - - /// Choose visual response for tapping on an emoji cell - final ButtonMode buttonMode; - - /// The padding of GridView, default is [EdgeInsets.zero] - final EdgeInsets gridPadding; - - /// Replace latest emoji on recents list on limit exceed - final bool replaceEmojiOnLimitExceed; + /// Swap the category view and bottom bar (category bottom and bottom bar top) + final bool swapCategoryAndBottomBar; /// Verify that emoji glyph is supported by the platform (Android only) final bool checkPlatformCompatibility; /// Custom emojis; if set, overrides default emojis provided by the library - final List? emojiSet; + final List emojiSet; /// Custom emoji text style to apply to emoji characters in the grid /// /// If you define a custom fontFamily or use GoogleFonts to set this property - /// be sure to set [checkPlatformCompatibility] to false. It will improve - /// initalization performance and prevent technically supported glyphs from - /// being filtered out. + /// you can consider to set [checkPlatformCompatibility] to false. It will + /// improve initalization performance and prevent technically supported glyphs + /// from being filtered out. + /// + /// This has priority over [EmojiViewConfig.emojiSizeMax] if font size is set. final TextStyle? emojiTextStyle; - /// Customize skin color overlay horizontal offset in case of ShellRoute or - /// other cases, when EmojiPicker is not aligned to the left border of the - /// screen. - /// Reference: https://github.com/Fintasys/emoji_picker_flutter/issues/148 - final double? customSkinColorOverlayHorizontalOffset; + /// Emoji view config + final EmojiViewConfig emojiViewConfig; - /// Get Emoji size based on properties and screen width - double getEmojiSize(double width) { - final maxSize = width / columns; - return min(maxSize, emojiSizeMax); - } + /// Skin tone config + final SkinToneConfig skinToneConfig; - /// Returns the icon for the category - IconData getIconForCategory(Category category) { - switch (category) { - case Category.RECENT: - return categoryIcons.recentIcon; - case Category.SMILEYS: - return categoryIcons.smileyIcon; - case Category.ANIMALS: - return categoryIcons.animalIcon; - case Category.FOODS: - return categoryIcons.foodIcon; - case Category.TRAVEL: - return categoryIcons.travelIcon; - case Category.ACTIVITIES: - return categoryIcons.activityIcon; - case Category.OBJECTS: - return categoryIcons.objectIcon; - case Category.SYMBOLS: - return categoryIcons.symbolIcon; - case Category.FLAGS: - return categoryIcons.flagIcon; - default: - throw Exception('Unsupported Category'); - } - } + /// Category view config + final CategoryViewConfig categoryViewConfig; + + /// Search bar config + final BottomActionBarConfig bottomActionBarConfig; + + /// Search View config + final SearchViewConfig searchViewConfig; @override bool operator ==(other) { return (other is Config) && - other.columns == columns && - other.emojiSizeMax == emojiSizeMax && - other.verticalSpacing == verticalSpacing && - other.horizontalSpacing == horizontalSpacing && - other.initCategory == initCategory && - other.bgColor == bgColor && - other.indicatorColor == indicatorColor && - other.iconColor == iconColor && - other.iconColorSelected == iconColorSelected && - other.backspaceColor == backspaceColor && - other.skinToneDialogBgColor == skinToneDialogBgColor && - other.skinToneIndicatorColor == skinToneIndicatorColor && - other.enableSkinTones == enableSkinTones && - other.recentTabBehavior == recentTabBehavior && - other.recentsLimit == recentsLimit && - other.noRecents == noRecents && - other.loadingIndicator == loadingIndicator && - other.tabIndicatorAnimDuration == tabIndicatorAnimDuration && - other.categoryIcons == categoryIcons && - other.buttonMode == buttonMode && - other.gridPadding == gridPadding && - other.replaceEmojiOnLimitExceed == replaceEmojiOnLimitExceed && + other.swapCategoryAndBottomBar == swapCategoryAndBottomBar && other.checkPlatformCompatibility == checkPlatformCompatibility && other.emojiSet == emojiSet && other.emojiTextStyle == emojiTextStyle && - other.customSkinColorOverlayHorizontalOffset == - customSkinColorOverlayHorizontalOffset; + other.emojiViewConfig == emojiViewConfig && + other.skinToneConfig == skinToneConfig && + other.bottomActionBarConfig == bottomActionBarConfig && + other.searchViewConfig == searchViewConfig; } @override int get hashCode => - columns.hashCode ^ - emojiSizeMax.hashCode ^ - verticalSpacing.hashCode ^ - horizontalSpacing.hashCode ^ - initCategory.hashCode ^ - bgColor.hashCode ^ - indicatorColor.hashCode ^ - iconColor.hashCode ^ - iconColorSelected.hashCode ^ - backspaceColor.hashCode ^ - skinToneDialogBgColor.hashCode ^ - skinToneIndicatorColor.hashCode ^ - enableSkinTones.hashCode ^ - recentTabBehavior.hashCode ^ - recentsLimit.hashCode ^ - noRecents.hashCode ^ - loadingIndicator.hashCode ^ - tabIndicatorAnimDuration.hashCode ^ - categoryIcons.hashCode ^ - buttonMode.hashCode ^ - gridPadding.hashCode ^ - replaceEmojiOnLimitExceed.hashCode ^ + swapCategoryAndBottomBar.hashCode ^ checkPlatformCompatibility.hashCode ^ - (emojiSet?.hashCode ?? 0) ^ + emojiSet.hashCode ^ (emojiTextStyle?.hashCode ?? 0) ^ - (customSkinColorOverlayHorizontalOffset?.hashCode ?? 0); + categoryViewConfig.hashCode ^ + emojiViewConfig.hashCode ^ + skinToneConfig.hashCode ^ + bottomActionBarConfig.hashCode ^ + searchViewConfig.hashCode; } diff --git a/lib/src/default_emoji_picker_view.dart b/lib/src/default_emoji_picker_view.dart deleted file mode 100644 index 71c7ef6..0000000 --- a/lib/src/default_emoji_picker_view.dart +++ /dev/null @@ -1,258 +0,0 @@ -import 'dart:async'; - -import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; -import 'package:emoji_picker_flutter/src/skin_tone_overlay.dart'; -import 'package:flutter/material.dart'; - -/// Default EmojiPicker Implementation -class DefaultEmojiPickerView extends EmojiPickerBuilder { - /// Constructor - DefaultEmojiPickerView(Config config, EmojiViewState state) - : super(config, state); - - @override - _DefaultEmojiPickerViewState createState() => _DefaultEmojiPickerViewState(); -} - -class _DefaultEmojiPickerViewState extends State - with SingleTickerProviderStateMixin, SkinToneOverlayStateMixin { - final double _tabBarHeight = 46; - Timer? _onBackspacePressedCallbackTimer; - - late PageController _pageController; - late TabController _tabController; - late final _scrollController = ScrollController(); - - late final _utils = EmojiPickerUtils(); - - @override - void initState() { - var initCategory = widget.state.categoryEmoji.indexWhere( - (element) => element.category == widget.config.initCategory); - if (initCategory == -1) { - initCategory = 0; - } - _tabController = TabController( - initialIndex: initCategory, - length: widget.state.categoryEmoji.length, - vsync: this); - _pageController = PageController(initialPage: initCategory) - ..addListener(closeSkinToneOverlay); - _scrollController.addListener(closeSkinToneOverlay); - super.initState(); - } - - @override - void dispose() { - closeSkinToneOverlay(); - _pageController.dispose(); - _tabController.dispose(); - _scrollController.dispose(); - _onBackspacePressedCallbackTimer?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - final emojiSize = widget.config.getEmojiSize(constraints.maxWidth); - return EmojiContainer( - color: widget.config.bgColor, - buttonMode: widget.config.buttonMode, - child: Column( - children: [ - Row( - children: [ - Expanded( - child: _buildTabBar(context), - ), - _buildBackspaceButton(), - ], - ), - Flexible( - child: PageView.builder( - itemCount: widget.state.categoryEmoji.length, - controller: _pageController, - onPageChanged: (index) { - _tabController.animateTo( - index, - duration: widget.config.tabIndicatorAnimDuration, - ); - }, - itemBuilder: (context, index) => - _buildPage(emojiSize, widget.state.categoryEmoji[index]), - ), - ), - ], - ), - ); - }, - ); - } - - Widget _buildTabBar(BuildContext context) => SizedBox( - height: _tabBarHeight, - child: TabBar( - labelColor: widget.config.iconColorSelected, - indicatorColor: widget.config.indicatorColor, - unselectedLabelColor: widget.config.iconColor, - controller: _tabController, - labelPadding: EdgeInsets.zero, - onTap: (index) { - closeSkinToneOverlay(); - _pageController.jumpToPage(index); - }, - tabs: widget.state.categoryEmoji - .asMap() - .entries - .map( - (item) => _buildCategory(item.key, item.value.category)) - .toList(), - ), - ); - - Widget _buildBackspaceButton() { - if (widget.state.onBackspacePressed != null) { - return Material( - type: MaterialType.transparency, - child: GestureDetector( - onLongPressStart: (_) => _startOnBackspacePressedCallback(), - onLongPressEnd: (_) => _stopOnBackspacePressedCallback(), - child: IconButton( - padding: const EdgeInsets.only(bottom: 2), - icon: Icon( - Icons.backspace, - color: widget.config.backspaceColor, - ), - onPressed: () => widget.state.onBackspacePressed!(), - ), - ), - ); - } - return const SizedBox.shrink(); - } - - Widget _buildCategory(int index, Category category) { - return Tab( - icon: Icon( - widget.config.getIconForCategory(category), - ), - ); - } - - Widget _buildPage(double emojiSize, CategoryEmoji categoryEmoji) { - // Display notice if recent has no entries yet - if (categoryEmoji.category == Category.RECENT && - categoryEmoji.emoji.isEmpty) { - return _buildNoRecent(); - } - // Build page normally - return GestureDetector( - onTap: closeSkinToneOverlay, - child: GridView.count( - scrollDirection: Axis.vertical, - controller: _scrollController, - primary: false, - padding: widget.config.gridPadding, - crossAxisCount: widget.config.columns, - mainAxisSpacing: widget.config.verticalSpacing, - crossAxisSpacing: widget.config.horizontalSpacing, - children: [ - for (int i = 0; i < categoryEmoji.emoji.length; i++) - EmojiCell.fromConfig( - emoji: categoryEmoji.emoji[i], - emojiSize: emojiSize, - categoryEmoji: categoryEmoji, - index: i, - onEmojiSelected: (category, emoji) { - closeSkinToneOverlay(); - widget.state.onEmojiSelected(category, emoji); - }, - onSkinToneDialogRequested: _openSkinToneDialog, - config: widget.config, - ) - ]), - ); - } - - /// Build Widget for when no recent emoji are available - Widget _buildNoRecent() { - return Center( - child: widget.config.noRecents, - ); - } - - void _openSkinToneDialog( - Emoji emoji, - double emojiSize, - CategoryEmoji? categoryEmoji, - int index, - ) { - closeSkinToneOverlay(); - if (!emoji.hasSkinTone || !widget.config.enableSkinTones) { - return; - } - showSkinToneOverlay( - emoji, - emojiSize, - categoryEmoji, - index, - kSkinToneCount, - widget.config, - _scrollController.offset, - _tabBarHeight, - _utils, - _onSkinTonedEmojiSelected); - } - - void _onSkinTonedEmojiSelected(Category? category, Emoji emoji) { - widget.state.onEmojiSelected(category, emoji); - closeSkinToneOverlay(); - } - - /// Start the callback for long-pressing the backspace button. - void _startOnBackspacePressedCallback() { - // Initial callback interval for short presses - var callbackInterval = const Duration(milliseconds: 75); - var millisecondsSincePressed = 0; - - // Callback function executed on each timer tick - void _callback(Timer timer) { - // Accumulate elapsed time since the last tick - millisecondsSincePressed += callbackInterval.inMilliseconds; - - // If the long-press duration exceeds 3 seconds - if (millisecondsSincePressed > 3000 && - callbackInterval == const Duration(milliseconds: 75)) { - // Switch to a longer callback interval for word-by-word deletion - callbackInterval = const Duration(milliseconds: 300); - - // Restart the timer with the updated interval - _onBackspacePressedCallbackTimer?.cancel(); - _onBackspacePressedCallbackTimer = - Timer.periodic(callbackInterval, _callback); - - // Reset the elapsed time for the new interval - millisecondsSincePressed = 0; - } - - // Trigger the appropriate callback based on the interval - if (callbackInterval == const Duration(milliseconds: 75)) { - widget.state.onBackspacePressed!(); // Short-press callback - } else { - widget.state.onBackspaceLongPressed(); // Long-press callback - } - } - - // Start the initial timer with the short-press interval - _onBackspacePressedCallbackTimer = - Timer.periodic(callbackInterval, _callback); - } - - /// Stop the callback for long-pressing the backspace button. - void _stopOnBackspacePressedCallback() { - // Cancel the active timer - _onBackspacePressedCallbackTimer?.cancel(); - } -} diff --git a/lib/src/default_emoji_set.dart b/lib/src/default_emoji_set.dart index e17f89b..05f81c5 100644 --- a/lib/src/default_emoji_set.dart +++ b/lib/src/default_emoji_set.dart @@ -1,5 +1,3 @@ -// Copyright information -// File originally from https://github.com/JeffG05/emoji_picker import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; /// List of categories of emojis diff --git a/lib/src/emoji_picker.dart b/lib/src/emoji_picker.dart index 20a4a57..b2f7164 100644 --- a/lib/src/emoji_picker.dart +++ b/lib/src/emoji_picker.dart @@ -1,12 +1,5 @@ -import 'package:emoji_picker_flutter/src/category_emoji.dart'; -import 'package:emoji_picker_flutter/src/config.dart'; -import 'package:emoji_picker_flutter/src/default_emoji_picker_view.dart'; -import 'package:emoji_picker_flutter/src/default_emoji_set.dart'; -import 'package:emoji_picker_flutter/src/emoji.dart'; +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; import 'package:emoji_picker_flutter/src/emoji_picker_internal_utils.dart'; -import 'package:emoji_picker_flutter/src/emoji_view_state.dart'; -import 'package:emoji_picker_flutter/src/recent_emoji.dart'; -import 'package:emoji_picker_flutter/src/recent_tab_behavior.dart'; import 'package:flutter/material.dart'; /// All the possible categories that [Emoji] can be put into @@ -81,9 +74,6 @@ enum ButtonMode { CUPERTINO } -/// Number of skin tone icons -const kSkinToneCount = 6; - /// Callback function for when emoji is selected /// /// The function returns the selected [Emoji] as well @@ -92,8 +82,8 @@ const kSkinToneCount = 6; typedef void OnEmojiSelected(Category? category, Emoji emoji); /// Callback from emoji cell to show a skin tone selection overlay -typedef void OnSkinToneDialogRequested( - Emoji emoji, double emojiSize, CategoryEmoji? categoryEmoji, int index); +typedef void OnSkinToneDialogRequested(Offset emojiBoxPosition, Emoji emoji, + double emojiSize, CategoryEmoji? categoryEmoji); /// Callback function for backspace button typedef void OnBackspacePressed(); @@ -101,9 +91,6 @@ typedef void OnBackspacePressed(); /// Callback function for backspace button when long pressed typedef void OnBackspaceLongPressed(); -/// Callback function for custom view -typedef EmojiViewBuilder = Widget Function(Config config, EmojiViewState state); - /// The Emoji Keyboard widget /// /// This widget displays a grid of [Emoji] sorted by [Category] @@ -157,6 +144,9 @@ class EmojiPickerState extends State { // Prevent emojis to be reloaded with every build bool _loaded = false; + // Display Search bar + bool _isSearchBarVisible = false; + // Internal helper final _emojiPickerInternalUtils = EmojiPickerInternalUtils(); @@ -164,10 +154,14 @@ class EmojiPickerState extends State { void updateRecentEmoji(List recentEmoji, {bool refresh = false}) { _recentEmoji = recentEmoji; - _categoryEmoji[0] = _categoryEmoji[0] - .copyWith(emoji: _recentEmoji.map((e) => e.emoji).toList()); - if (mounted && refresh) { - setState(() {}); + final recentTabIndex = _categoryEmoji + .indexWhere((element) => element.category == Category.RECENT); + if (recentTabIndex != -1) { + _categoryEmoji[recentTabIndex] = _categoryEmoji[recentTabIndex] + .copyWith(emoji: _recentEmoji.map((e) => e.emoji).toList()); + if (mounted && refresh) { + setState(() {}); + } } } @@ -185,17 +179,30 @@ class EmojiPickerState extends State { _loaded = false; _updateEmojis(); } + _resetStateWhenOffstage(); super.didUpdateWidget(oldWidget); } @override Widget build(BuildContext context) { if (!_loaded) { - return widget.config.loadingIndicator; + return widget.config.emojiViewConfig.loadingIndicator; + } + if (_isSearchBarVisible) { + return _buildSearchBar(); + } + return _buildEmojiView(); + } + + void _resetStateWhenOffstage() { + final offstageParent = context.findAncestorWidgetOfExactType(); + if (offstageParent != null && + offstageParent.offstage == true && + _isSearchBarVisible) { + setState(() { + _isSearchBarVisible = false; + }); } - return widget.customWidget == null - ? DefaultEmojiPickerView(widget.config, _state) - : widget.customWidget!(widget.config, _state); } void _onBackspacePressed() { @@ -218,11 +225,14 @@ class EmojiPickerState extends State { final selection = controller.value.selection; final newTextBeforeCursor = selection.textBefore(text).characters.skipLast(1).toString(); - print(newTextBeforeCursor); - controller - ..text = newTextBeforeCursor + selection.textAfter(text) - ..selection = TextSelection.fromPosition( - TextPosition(offset: newTextBeforeCursor.length)); + + controller.value = controller.value.copyWith( + text: newTextBeforeCursor + selection.textAfter(text), + selection: TextSelection.fromPosition( + TextPosition(offset: newTextBeforeCursor.length), + ), + composing: TextRange.collapsed(newTextBeforeCursor.length), + ); } } @@ -233,36 +243,36 @@ class EmojiPickerState extends State { } } - OnBackspaceLongPressed _onBackspaceLongPressed() { - return () { - if (widget.textEditingController != null) { - final controller = widget.textEditingController!; + void _onBackspaceLongPressed() { + if (widget.textEditingController != null) { + final controller = widget.textEditingController!; - final text = controller.value.text; - var cursorPosition = controller.selection.base.offset; + final text = controller.value.text; + var cursorPosition = controller.selection.base.offset; - // If cursor is not set, then place it at the end of the textfield - if (cursorPosition < 0) { - controller.selection = TextSelection( - baseOffset: controller.text.length, - extentOffset: controller.text.length, - ); - cursorPosition = controller.selection.base.offset; - } + // If cursor is not set, then place it at the end of the textfield + if (cursorPosition < 0) { + controller.selection = TextSelection( + baseOffset: controller.text.length, + extentOffset: controller.text.length, + ); + cursorPosition = controller.selection.base.offset; + } - if (cursorPosition >= 0) { - final selection = controller.value.selection; - final newTextBeforeCursor = _deleteWordByWord( - selection.textBefore(text).toString(), - ); - controller - ..text = newTextBeforeCursor + selection.textAfter(text) - ..selection = TextSelection.fromPosition( - TextPosition(offset: newTextBeforeCursor.length), - ); - } + if (cursorPosition >= 0) { + final selection = controller.value.selection; + final newTextBeforeCursor = _deleteWordByWord( + selection.textBefore(text).toString(), + ); + controller.value = controller.value.copyWith( + text: newTextBeforeCursor + selection.textAfter(text), + selection: TextSelection.fromPosition( + TextPosition(offset: newTextBeforeCursor.length), + ), + composing: TextRange.collapsed(newTextBeforeCursor.length), + ); } - }; + } } String _deleteWordByWord(String text) { @@ -282,81 +292,85 @@ class EmojiPickerState extends State { } // Add recent emoji handling to tap listener - OnEmojiSelected _getOnEmojiListener() { - return (category, emoji) { - if (widget.config.recentTabBehavior == RecentTabBehavior.POPULAR) { - _emojiPickerInternalUtils - .addEmojiToPopularUsed(emoji: emoji, config: widget.config) - .then((newRecentEmoji) => { - // we don't want to rebuild the widget if user is currently on - // the RECENT tab, it will make emojis jump since sorting - // is based on the use frequency - updateRecentEmoji(newRecentEmoji, - refresh: category != Category.RECENT), - }); - } else if (widget.config.recentTabBehavior == RecentTabBehavior.RECENT) { - _emojiPickerInternalUtils - .addEmojiToRecentlyUsed(emoji: emoji, config: widget.config) - .then((newRecentEmoji) => { - // we don't want to rebuild the widget if user is currently on - // the RECENT tab, it will make emojis jump since sorting - // is based on the use frequency - updateRecentEmoji(newRecentEmoji, - refresh: category != Category.RECENT), - }); - } + void _onEmojiSelected(Category? category, Emoji emoji) { + if (widget.config.categoryViewConfig.recentTabBehavior == + RecentTabBehavior.POPULAR) { + _emojiPickerInternalUtils + .addEmojiToPopularUsed(emoji: emoji, config: widget.config) + .then((newRecentEmoji) => { + // we don't want to rebuild the widget if user is currently on + // the RECENT tab, it will make emojis jump since sorting + // is based on the use frequency + updateRecentEmoji(newRecentEmoji, + refresh: category != Category.RECENT), + }); + } else if (widget.config.categoryViewConfig.recentTabBehavior == + RecentTabBehavior.RECENT) { + _emojiPickerInternalUtils + .addEmojiToRecentlyUsed(emoji: emoji, config: widget.config) + .then((newRecentEmoji) => { + // we don't want to rebuild the widget if user is currently on + // the RECENT tab, it will make emojis jump since sorting + // is based on the use frequency + updateRecentEmoji(newRecentEmoji, + refresh: category != Category.RECENT), + }); + } - if (widget.textEditingController != null) { - // based on https://stackoverflow.com/a/60058972/10975692 - final controller = widget.textEditingController!; - final text = controller.text; - final selection = controller.selection; - final cursorPosition = controller.selection.base.offset; - - if (cursorPosition < 0) { - controller.text += emoji.emoji; - widget.onEmojiSelected?.call(category, emoji); - return; - } - - final newText = - text.replaceRange(selection.start, selection.end, emoji.emoji); - final emojiLength = emoji.emoji.length; - controller.value = controller.value.copyWith( - text: newText, - selection: selection.copyWith( - baseOffset: selection.start + emojiLength, - extentOffset: selection.start + emojiLength, - ), - ); + if (widget.textEditingController != null) { + // based on https://stackoverflow.com/a/60058972/10975692 + final controller = widget.textEditingController!; + final text = controller.text; + final selection = controller.selection; + final cursorPosition = controller.selection.base.offset; + + if (cursorPosition < 0) { + controller.text += emoji.emoji; + widget.onEmojiSelected?.call(category, emoji); + return; } - widget.onEmojiSelected?.call(category, emoji); + final newText = text.replaceRange( + selection.start, + selection.end, + emoji.emoji, + ); + final emojiLength = emoji.emoji.length; + controller.value = controller.value.copyWith( + text: newText, + selection: selection.copyWith( + baseOffset: selection.start + emojiLength, + extentOffset: selection.start + emojiLength, + ), + composing: TextRange.collapsed(newText.length), + ); + } + + widget.onEmojiSelected?.call(category, emoji); - if (widget.textEditingController == null) { - _scrollToCursorAfterTextChange(); - } - }; + if (widget.textEditingController == null) { + _scrollToCursorAfterTextChange(); + } } // Initialize emoji data Future _updateEmojis() async { _categoryEmoji.clear(); if ([RecentTabBehavior.RECENT, RecentTabBehavior.POPULAR] - .contains(widget.config.recentTabBehavior)) { + .contains(widget.config.categoryViewConfig.recentTabBehavior)) { _recentEmoji = await _emojiPickerInternalUtils.getRecentEmojis(); final recentEmojiMap = _recentEmoji.map((e) => e.emoji).toList(); _categoryEmoji.add(CategoryEmoji(Category.RECENT, recentEmojiMap)); } - final data = widget.config.emojiSet ?? defaultEmojiSet; + final data = widget.config.emojiSet; _categoryEmoji.addAll(widget.config.checkPlatformCompatibility ? await _emojiPickerInternalUtils.filterUnsupported(data) : data); _state = EmojiViewState( _categoryEmoji, - _getOnEmojiListener(), - widget.onBackspacePressed == null ? null : _onBackspacePressed, - _onBackspaceLongPressed(), + _onEmojiSelected, + _onBackspacePressed, + _onBackspaceLongPressed, ); if (mounted) { setState(() { @@ -365,6 +379,49 @@ class EmojiPickerState extends State { } } + Widget _buildSearchBar() { + return widget.config.searchViewConfig.customSearchView == null + ? DefaultSearchView( + widget.config, + _state, + _hideSearchView, + ) + : widget.config.searchViewConfig.customSearchView!( + widget.config, + _state, + _hideSearchView, + ); + } + + Widget _buildEmojiView() { + return SizedBox( + height: widget.config.height, + child: widget.customWidget == null + ? DefaultEmojiPickerView( + widget.config, + _state, + _showSearchView, + ) + : widget.customWidget!( + widget.config, + _state, + _showSearchView, + ), + ); + } + + void _showSearchView() { + setState(() { + _isSearchBarVisible = true; + }); + } + + void _hideSearchView() { + setState(() { + _isSearchBarVisible = false; + }); + } + void _scrollToCursorAfterTextChange() { if (widget.scrollController != null) { final scrollController = widget.scrollController!; diff --git a/lib/src/emoji_picker_internal_utils.dart b/lib/src/emoji_picker_internal_utils.dart index 705ba4e..049b329 100644 --- a/lib/src/emoji_picker_internal_utils.dart +++ b/lib/src/emoji_picker_internal_utils.dart @@ -6,7 +6,6 @@ import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'recent_emoji.dart'; /// Initial value for RecentEmoji const initVal = 1; @@ -69,8 +68,8 @@ class EmojiPickerInternalUtils { recentEmoji.insert(0, RecentEmoji(emoji, initVal)); // Limit entries to recentsLimit - recentEmoji = - recentEmoji.sublist(0, min(config.recentsLimit, recentEmoji.length)); + recentEmoji = recentEmoji.sublist( + 0, min(config.emojiViewConfig.recentsLimit, recentEmoji.length)); // save locally final prefs = await SharedPreferences.getInstance(); @@ -93,8 +92,8 @@ class EmojiPickerInternalUtils { // Already exist in recent list // Just update counter recentEmoji[recentEmojiIndex].counter++; - } else if (recentEmoji.length == config.recentsLimit && - config.replaceEmojiOnLimitExceed) { + } else if (recentEmoji.length == config.emojiViewConfig.recentsLimit && + config.emojiViewConfig.replaceEmojiOnLimitExceed) { // Replace latest emoji with the fresh one recentEmoji[recentEmoji.length - 1] = RecentEmoji(emoji, initVal); } else { @@ -105,8 +104,8 @@ class EmojiPickerInternalUtils { recentEmoji.sort((a, b) => b.counter - a.counter); // Limit entries to recentsLimit - recentEmoji = - recentEmoji.sublist(0, min(config.recentsLimit, recentEmoji.length)); + recentEmoji = recentEmoji.sublist( + 0, min(config.emojiViewConfig.recentsLimit, recentEmoji.length)); // save locally final prefs = await SharedPreferences.getInstance(); diff --git a/lib/src/emoji_picker_utils.dart b/lib/src/emoji_picker_utils.dart index 6334583..35212b8 100644 --- a/lib/src/emoji_picker_utils.dart +++ b/lib/src/emoji_picker_utils.dart @@ -2,9 +2,14 @@ import 'dart:math'; import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; import 'package:emoji_picker_flutter/src/emoji_picker_internal_utils.dart'; -import 'package:emoji_picker_flutter/src/recent_emoji.dart'; import 'package:flutter/material.dart'; +/// Emoji Regex +/// Keycap Sequence '((\u0023|\u002a|[\u0030-\u0039])\ufe0f\u20e3){1}' +/// Issue: https://github.com/flutter/flutter/issues/36062 +const EmojiRegex = + r'((\u0023|\u002a|[\u0030-\u0039])\ufe0f\u20e3){1}|\p{Emoji}|\u200D|\uFE0F'; + /// Helper class that provides extended usage class EmojiPickerUtils { /// Singleton Constructor @@ -16,7 +21,7 @@ class EmojiPickerUtils { static final EmojiPickerUtils _singleton = EmojiPickerUtils._internal(); final List _allAvailableEmojiEntities = []; - final _emojiRegExp = RegExp(r'(\p{So})', unicode: true); + RegExp? _emojiRegExp; /// Returns list of recently used emoji from cache Future> getRecentEmojis() async { @@ -24,13 +29,15 @@ class EmojiPickerUtils { } /// Search for related emoticons based on keywords - Future> searchEmoji(String keyword, List data, + Future> searchEmoji(String keyword, List emojiSet, {bool checkPlatformCompatibility = true}) async { if (keyword.isEmpty) return []; if (_allAvailableEmojiEntities.isEmpty) { final emojiPickerInternalUtils = EmojiPickerInternalUtils(); + final data = [...emojiSet] + ..removeWhere((e) => e.category == Category.RECENT); final availableCategoryEmoji = checkPlatformCompatibility ? await emojiPickerInternalUtils.filterUnsupported(data) : data; @@ -42,9 +49,10 @@ class EmojiPickerUtils { } return _allAvailableEmojiEntities + .toSet() .where( - (emoji) => emoji.name.toLowerCase().contains(keyword.toLowerCase()), - ) + (emoji) => emoji.name.toLowerCase().contains(keyword.toLowerCase())) + .toSet() .toList(); } @@ -64,25 +72,54 @@ class EmojiPickerUtils { /// Spans enclosing emojis will have [parentStyle] combined with [emojiStyle]. /// Other spans will not have an explicit style (this method does not set /// [parentStyle] to the whole text. - List setEmojiTextStyle(String text, - {required TextStyle emojiStyle, TextStyle? parentStyle}) { - final finalEmojiStyle = - parentStyle == null ? emojiStyle : parentStyle.merge(emojiStyle); - final matches = _emojiRegExp.allMatches(text).toList(); - final spans = []; + List setEmojiTextStyle( + String text, { + required TextStyle emojiStyle, + TextStyle? parentStyle, + }) { + final composedEmojiStyle = (parentStyle ?? const TextStyle()) + .merge(DefaultEmojiTextStyle) + .merge(emojiStyle); + + final spans = []; + final matches = getEmojiRegex().allMatches(text).toList(); var cursor = 0; for (final match in matches) { - spans - ..add(TextSpan(text: text.substring(cursor, match.start))) - ..add( - TextSpan( + if (cursor != match.start) { + // Non emoji text + following emoji + spans + ..add(TextSpan( + text: text.substring(cursor, match.start), style: parentStyle)) + ..add(TextSpan( text: text.substring(match.start, match.end), - style: finalEmojiStyle, - ), - ); + style: composedEmojiStyle, + )); + } else { + if (spans.isEmpty) { + // Create new span if no previous emoji TextSpan exists + spans.add(TextSpan( + text: text.substring(match.start, match.end), + style: composedEmojiStyle, + )); + } else { + // Update last span if current text is still emoji + final lastIndex = spans.length - 1; + final lastText = spans[lastIndex].text ?? ''; + final currentText = text.substring(match.start, match.end); + spans[lastIndex] = TextSpan( + text: '$lastText$currentText', + style: composedEmojiStyle, + ); + } + } + // Update cursor cursor = match.end; } - spans.add(TextSpan(text: text.substring(cursor, text.length))); + // Add remaining text + if (cursor != text.length) { + spans.add(TextSpan( + text: text.substring(cursor, text.length), style: parentStyle)); + } return spans; } @@ -90,8 +127,11 @@ class EmojiPickerUtils { Emoji applySkinTone(Emoji emoji, String color) { final codeUnits = emoji.emoji.codeUnits; var result = List.empty(growable: true) + // Basic emoji without gender (until char 2) ..addAll(codeUnits.sublist(0, min(codeUnits.length, 2))) + // Skin tone ..addAll(color.codeUnits); + // add the rest of the emoji (gender, etc.) again if (codeUnits.length >= 2) { result.addAll(codeUnits.sublist(2)); } @@ -105,4 +145,10 @@ class EmojiPickerUtils { .clearRecentEmojisInLocalStorage() .then((_) => key.currentState?.updateRecentEmoji([], refresh: true)); } + + /// Returns the emoji regex + /// Based on https://unicode.org/reports/tr51/ + RegExp getEmojiRegex() { + return _emojiRegExp ?? RegExp(EmojiRegex, unicode: true); + } } diff --git a/lib/src/emoji_text_editing_controller.dart b/lib/src/emoji_text_editing_controller.dart index f91e075..775a49e 100644 --- a/lib/src/emoji_text_editing_controller.dart +++ b/lib/src/emoji_text_editing_controller.dart @@ -1,50 +1,76 @@ import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; import 'package:flutter/widgets.dart'; +/// Default delimiter for regex +const delimiter = '|'; + /// Text editing controller that produces text spans on the fly for setting -/// a particular style to emoji characters. Offloads the main magic to -/// [EmojiPickerUtils.setEmojiTextStyle] method. +/// a particular style to emoji characters. class EmojiTextEditingController extends TextEditingController { /// Constructor, requres emojiStyle, since otherwise this class has no effect - EmojiTextEditingController({String? text, required this.emojiStyle}) + EmojiTextEditingController({String? text, required this.emojiTextStyle}) : super(text: text); /// The style used for the emoji characters - final TextStyle emojiStyle; - final _utils = EmojiPickerUtils(); + final TextStyle emojiTextStyle; + + /// Emoji Picker Utils + final EmojiPickerUtils utils = EmojiPickerUtils(); @override - TextSpan buildTextSpan( - {required BuildContext context, - TextStyle? style, - required bool withComposing}) { - if (!value.isComposingRangeValid || !withComposing) { - return TextSpan( - style: style, - children: _utils.setEmojiTextStyle(text, - emojiStyle: emojiStyle, parentStyle: style)); + TextSpan buildTextSpan({ + required BuildContext context, + TextStyle? style, + required bool withComposing, + }) { + assert(!value.composing.isValid || + !withComposing || + value.isComposingRangeValid); + // If the composing range is out of range for the current text, ignore it to + // preserve the tree integrity, otherwise in release mode a RangeError will + // be thrown and this EditableText will be built with a broken subtree. + final composingRegionOutOfRange = + !value.isComposingRangeValid || !withComposing; + + // Style when no cursor or selection is set + if (composingRegionOutOfRange) { + final textSpanChildren = utils.setEmojiTextStyle( + text, + emojiStyle: emojiTextStyle, + parentStyle: style, + ); + return TextSpan(style: style, children: textSpanChildren); } - final composingStyle = + + // Cursor will automatically highlight current word underlined + final underlineStyle = style?.merge(const TextStyle(decoration: TextDecoration.underline)) ?? const TextStyle(decoration: TextDecoration.underline); + return TextSpan( style: style, children: [ TextSpan( - children: _utils.setEmojiTextStyle( - value.composing.textBefore(value.text), - emojiStyle: emojiStyle)), + children: utils.setEmojiTextStyle( + value.composing.textBefore(value.text), + emojiStyle: emojiTextStyle, + parentStyle: style, + ), + ), TextSpan( - style: composingStyle, - children: _utils.setEmojiTextStyle( - value.composing.textInside(value.text), - emojiStyle: emojiStyle, - parentStyle: composingStyle), + children: utils.setEmojiTextStyle( + value.composing.textInside(value.text), + emojiStyle: emojiTextStyle, + parentStyle: underlineStyle, + ), ), TextSpan( - children: _utils.setEmojiTextStyle( - value.composing.textAfter(value.text), - emojiStyle: emojiStyle)), + children: utils.setEmojiTextStyle( + value.composing.textAfter(value.text), + emojiStyle: emojiTextStyle, + parentStyle: style, + ), + ), ], ); } diff --git a/lib/src/emoji_text_style.dart b/lib/src/emoji_text_style.dart new file mode 100644 index 0000000..d02a30c --- /dev/null +++ b/lib/src/emoji_text_style.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +/// Emoji text style providing commonly available fallback fonts +const DefaultEmojiTextStyle = TextStyle( + inherit: true, + // Commonly available fallback fonts. + fontFamilyFallback: [ + // iOS and MacOs. + 'Apple Color Emoji', + // Android, ChromeOS, Ubuntu and some other Linux distros. + 'Noto Color Emoji', + // Windows. + 'Segoe UI Emoji', + ], +); diff --git a/lib/src/emoji_view/default_emoji_picker_view.dart b/lib/src/emoji_view/default_emoji_picker_view.dart new file mode 100644 index 0000000..855ab4c --- /dev/null +++ b/lib/src/emoji_view/default_emoji_picker_view.dart @@ -0,0 +1,205 @@ +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; +import 'package:flutter/material.dart'; + +/// Default EmojiPicker Implementation +class DefaultEmojiPickerView extends EmojiPickerView { + /// Constructor + DefaultEmojiPickerView( + Config config, + EmojiViewState state, + VoidCallback showSearchBar, + ) : super(config, state, showSearchBar); + + @override + _DefaultEmojiPickerViewState createState() => _DefaultEmojiPickerViewState(); +} + +class _DefaultEmojiPickerViewState extends State + with SingleTickerProviderStateMixin, SkinToneOverlayStateMixin { + late TabController _tabController; + late PageController _pageController; + final _scrollController = ScrollController(); + + @override + void initState() { + var initCategory = widget.state.categoryEmoji.indexWhere((element) => + element.category == widget.config.categoryViewConfig.initCategory); + if (initCategory == -1) { + initCategory = 0; + } + _tabController = TabController( + initialIndex: initCategory, + length: widget.state.categoryEmoji.length, + vsync: this); + _pageController = PageController(initialPage: initCategory) + ..addListener(closeSkinToneOverlay); + _scrollController.addListener(closeSkinToneOverlay); + super.initState(); + } + + @override + void dispose() { + closeSkinToneOverlay(); + _pageController.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final emojiSize = + widget.config.emojiViewConfig.getEmojiSize(constraints.maxWidth); + final emojiBoxSize = + widget.config.emojiViewConfig.getEmojiBoxSize(constraints.maxWidth); + return EmojiContainer( + color: widget.config.emojiViewConfig.backgroundColor, + buttonMode: widget.config.emojiViewConfig.buttonMode, + child: Column( + children: [ + // Category view or bottom search bar + widget.config.swapCategoryAndBottomBar + ? _buildBottomSearchBar() + : _buildCategoryView(), + + // Emoji view + _buildEmojiView(emojiSize, emojiBoxSize), + + // Bottom Search Bar or Category view + widget.config.swapCategoryAndBottomBar + ? _buildCategoryView() + : _buildBottomSearchBar(), + ], + ), + ); + }, + ); + } + + Widget _buildCategoryView() { + return widget.config.categoryViewConfig.customCategoryView != null + ? widget.config.categoryViewConfig.customCategoryView!( + widget.config, + widget.state, + _tabController, + _pageController, + ) + : DefaultCategoryView( + widget.config, + widget.state, + _tabController, + _pageController, + ); + } + + Widget _buildEmojiView(double emojiSize, double emojiBoxSize) { + return Flexible( + child: PageView.builder( + itemCount: widget.state.categoryEmoji.length, + controller: _pageController, + onPageChanged: (index) { + _tabController.animateTo( + index, + duration: widget.config.categoryViewConfig.tabIndicatorAnimDuration, + ); + }, + itemBuilder: (context, index) => _buildPage( + emojiSize, + emojiBoxSize, + widget.state.categoryEmoji[index], + ), + ), + ); + } + + Widget _buildBottomSearchBar() { + if (!widget.config.bottomActionBarConfig.enabled) { + return const SizedBox.shrink(); + } + return widget.config.bottomActionBarConfig.customBottomActionBar != null + ? widget.config.bottomActionBarConfig.customBottomActionBar!( + widget.config, + widget.state, + widget.showSearchBar, + ) + : DefaultBottomActionBar( + widget.config, + widget.state, + widget.showSearchBar, + ); + } + + Widget _buildPage( + double emojiSize, double emojiBoxSize, CategoryEmoji categoryEmoji) { + // Display notice if recent has no entries yet + if (categoryEmoji.category == Category.RECENT && + categoryEmoji.emoji.isEmpty) { + return _buildNoRecent(); + } + // Build page normally + return GridView.builder( + key: const Key('emojiScrollView'), + scrollDirection: Axis.vertical, + controller: _scrollController, + primary: false, + padding: widget.config.emojiViewConfig.gridPadding, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + childAspectRatio: 1, + crossAxisCount: widget.config.emojiViewConfig.columns, + mainAxisSpacing: widget.config.emojiViewConfig.verticalSpacing, + crossAxisSpacing: widget.config.emojiViewConfig.horizontalSpacing, + ), + itemCount: categoryEmoji.emoji.length, + itemBuilder: (context, index) { + return addSkinToneTargetIfAvailable( + hasSkinTone: categoryEmoji.emoji[index].hasSkinTone, + linkKey: + categoryEmoji.category.name + categoryEmoji.emoji[index].emoji, + child: EmojiCell.fromConfig( + emoji: categoryEmoji.emoji[index], + emojiSize: emojiSize, + emojiBoxSize: emojiBoxSize, + categoryEmoji: categoryEmoji, + onEmojiSelected: _onSkinTonedEmojiSelected, + onSkinToneDialogRequested: _openSkinToneDialog, + config: widget.config, + ), + ); + }, + ); + } + + /// Build Widget for when no recent emoji are available + Widget _buildNoRecent() { + return Center( + child: widget.config.emojiViewConfig.noRecents, + ); + } + + void _openSkinToneDialog( + Offset emojiBoxPosition, + Emoji emoji, + double emojiSize, + CategoryEmoji? categoryEmoji, + ) { + closeSkinToneOverlay(); + if (!emoji.hasSkinTone || !widget.config.skinToneConfig.enabled) { + return; + } + showSkinToneOverlay( + emojiBoxPosition, + emoji, + emojiSize, + categoryEmoji, + widget.config, + _onSkinTonedEmojiSelected, + links[categoryEmoji!.category.name + emoji.emoji]!, + ); + } + + void _onSkinTonedEmojiSelected(Category? category, Emoji emoji) { + widget.state.onEmojiSelected(category, emoji); + closeSkinToneOverlay(); + } +} diff --git a/lib/src/emoji_container.dart b/lib/src/emoji_view/emoji_container.dart similarity index 100% rename from lib/src/emoji_container.dart rename to lib/src/emoji_view/emoji_container.dart diff --git a/lib/src/emoji_picker_builder.dart b/lib/src/emoji_view/emoji_picker_view.dart similarity index 71% rename from lib/src/emoji_picker_builder.dart rename to lib/src/emoji_view/emoji_picker_view.dart index 0805ea9..92cfb07 100644 --- a/lib/src/emoji_picker_builder.dart +++ b/lib/src/emoji_view/emoji_picker_view.dart @@ -4,11 +4,12 @@ import 'package:flutter/material.dart'; /// Template class for custom implementation /// Inhert this class to create your own EmojiPicker -abstract class EmojiPickerBuilder extends StatefulWidget { +abstract class EmojiPickerView extends StatefulWidget { /// Constructor - EmojiPickerBuilder( + const EmojiPickerView( this.config, - this.state, { + this.state, + this.showSearchBar, { Key? key, }) : super(key: key); @@ -17,4 +18,7 @@ abstract class EmojiPickerBuilder extends StatefulWidget { /// State that holds current emoji data final EmojiViewState state; + + /// Show Search Bar + final VoidCallback showSearchBar; } diff --git a/lib/src/emoji_view/emoji_view_config.dart b/lib/src/emoji_view/emoji_view_config.dart new file mode 100644 index 0000000..3f5771f --- /dev/null +++ b/lib/src/emoji_view/emoji_view_config.dart @@ -0,0 +1,109 @@ +import 'dart:math'; + +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; +import 'package:flutter/material.dart'; + +/// Callback function for custom view +typedef EmojiViewBuilder = Widget Function( + Config config, + EmojiViewState state, + VoidCallback showSearchBar, +); + +/// Default Widget if no recent is available +const DefaultNoRecentsWidget = Text( + 'No Recents', + style: TextStyle(fontSize: 20, color: Colors.black26), + textAlign: TextAlign.center, +); + +/// Emoji View Config +class EmojiViewConfig { + /// Constructor + const EmojiViewConfig({ + this.columns = 10, + this.emojiSizeMax = 28.0, + this.backgroundColor = const Color(0xFFEBEFF2), + this.verticalSpacing = 0, + this.horizontalSpacing = 0, + this.gridPadding = EdgeInsets.zero, + this.recentsLimit = 28, + this.replaceEmojiOnLimitExceed = false, + this.noRecents = DefaultNoRecentsWidget, + this.loadingIndicator = const SizedBox.shrink(), + this.buttonMode = ButtonMode.MATERIAL, + }); + + /// Number of emojis per row + final int columns; + + /// Width and height the emoji will be maximal displayed + /// Can be smaller due to screen size and amount of columns + final double emojiSizeMax; + + /// The background color of the emoji view + final Color backgroundColor; + + /// Verical spacing between emojis + final double verticalSpacing; + + /// Horizontal spacing between emojis + final double horizontalSpacing; + + /// Limit of recently used emoji that will be saved + final int recentsLimit; + + /// A widget (usually [Text]) to be displayed if no recent emojis to display + /// Hot reload is not supported + final Widget noRecents; + + /// A widget to display while emoji picker is initializing + /// Hot reload is not supported + final Widget loadingIndicator; + + /// Choose visual response for tapping on an emoji cell + final ButtonMode buttonMode; + + /// The padding of GridView, default is [EdgeInsets.zero] + final EdgeInsets gridPadding; + + /// Replace latest emoji on recents list on limit exceed + final bool replaceEmojiOnLimitExceed; + + /// Get Emoji size based on properties and screen width + double getEmojiSize(double width) { + final maxSize = width / columns; + return min(maxSize, emojiSizeMax); + } + + /// Get Emoji hitbox size based on properties and screen width + double getEmojiBoxSize(double width) { + return width / columns; + } + + @override + bool operator ==(other) { + return (other is EmojiViewConfig) && + other.columns == columns && + other.emojiSizeMax == emojiSizeMax && + other.backgroundColor == backgroundColor && + other.verticalSpacing == verticalSpacing && + other.horizontalSpacing == horizontalSpacing && + other.recentsLimit == recentsLimit && + other.buttonMode == buttonMode && + other.gridPadding == gridPadding && + other.replaceEmojiOnLimitExceed == replaceEmojiOnLimitExceed; + } + + @override + int get hashCode => + columns.hashCode ^ + emojiSizeMax.hashCode ^ + backgroundColor.hashCode ^ + verticalSpacing.hashCode ^ + horizontalSpacing.hashCode ^ + recentsLimit.hashCode ^ + buttonMode.hashCode ^ + gridPadding.hashCode ^ + replaceEmojiOnLimitExceed.hashCode; +} diff --git a/lib/src/search_view/default_search_view.dart b/lib/src/search_view/default_search_view.dart new file mode 100644 index 0000000..ba0c596 --- /dev/null +++ b/lib/src/search_view/default_search_view.dart @@ -0,0 +1,79 @@ +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; +import 'package:flutter/material.dart'; + +/// Default Search implementation +class DefaultSearchView extends SearchView { + /// Constructor + const DefaultSearchView( + Config config, + EmojiViewState state, + VoidCallback showEmojiView, + ) : super(config, state, showEmojiView); + + @override + DefaultSearchViewState createState() => DefaultSearchViewState(); +} + +/// Default Search View State +class DefaultSearchViewState extends SearchViewState { + @override + Widget build(BuildContext context) { + return LayoutBuilder(builder: (context, constraints) { + final emojiSize = + widget.config.emojiViewConfig.getEmojiSize(constraints.maxWidth); + final emojiBoxSize = + widget.config.emojiViewConfig.getEmojiBoxSize(constraints.maxWidth); + + return Container( + color: widget.config.searchViewConfig.backgroundColor, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Material( + child: SizedBox( + height: emojiBoxSize + 8.0, + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 4.0), + scrollDirection: Axis.horizontal, + itemCount: results.length, + itemBuilder: (context, index) { + return buildEmoji( + results[index], + emojiSize, + emojiBoxSize, + ); + }, + ), + ), + ), + Row( + children: [ + IconButton( + onPressed: () { + widget.showEmojiView(); + }, + color: widget.config.searchViewConfig.buttonColor, + icon: Icon( + Icons.arrow_back, + color: widget.config.searchViewConfig.buttonIconColor, + ), + ), + Expanded( + child: TextField( + onChanged: onTextInputChanged, + focusNode: focusNode, + decoration: const InputDecoration( + border: InputBorder.none, + hintText: 'Search', + contentPadding: EdgeInsets.symmetric(horizontal: 16), + ), + ), + ), + ], + ), + ], + ), + ); + }); + } +} diff --git a/lib/src/search_view/search_view.dart b/lib/src/search_view/search_view.dart new file mode 100644 index 0000000..c3aa6a7 --- /dev/null +++ b/lib/src/search_view/search_view.dart @@ -0,0 +1,113 @@ +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; +import 'package:flutter/material.dart'; + +/// Template class for custom implementation +/// Inhert this class to create your own search view +abstract class SearchView extends StatefulWidget { + /// Constructor + const SearchView( + this.config, + this.state, + this.showEmojiView, { + Key? key, + }) : super(key: key); + + /// Config for customizations + final Config config; + + /// State that holds current emoji data + final EmojiViewState state; + + /// Return to emoji view + final VoidCallback showEmojiView; +} + +/// Template class for custom implementation +/// Inhert this class to create your own search view state +class SearchViewState extends State + with SkinToneOverlayStateMixin { + /// Emoji picker utils + final utils = EmojiPickerUtils(); + + /// Focus node for textfield + final focusNode = FocusNode(); + + /// Search results + final results = List.empty(growable: true); + + @override + void initState() { + WidgetsBinding.instance.addPostFrameCallback((_) { + // Auto focus textfield + FocusScope.of(context).requestFocus(focusNode); + // Load recent emojis initially + utils.getRecentEmojis().then( + (value) => setState( + () => _updateResults(value.map((e) => e.emoji).toList()), + ), + ); + }); + super.initState(); + } + + /// On text input changed callback + void onTextInputChanged(String text) { + links.clear(); + results.clear(); + utils.searchEmoji(text, widget.state.categoryEmoji).then( + (value) => setState( + () => _updateResults(value), + ), + ); + } + + void _updateResults(List emojis) { + results + ..clear() + ..addAll(emojis); + results.asMap().entries.forEach((e) { + links[e.value.emoji] = LayerLink(); + }); + } + + /// Build emoji cell + Widget buildEmoji(Emoji emoji, double emojiSize, double emojiBoxSize) { + return addSkinToneTargetIfAvailable( + hasSkinTone: emoji.hasSkinTone, + linkKey: emoji.emoji, + child: EmojiCell.fromConfig( + emoji: emoji, + emojiSize: emojiSize, + emojiBoxSize: emojiBoxSize, + onEmojiSelected: widget.state.onEmojiSelected, + config: widget.config, + onSkinToneDialogRequested: + (emojiBoxPosition, emoji, emojiSize, category) { + closeSkinToneOverlay(); + if (!emoji.hasSkinTone || !widget.config.skinToneConfig.enabled) { + return; + } + showSkinToneOverlay( + emojiBoxPosition, + emoji, + emojiSize, + null, // Todo: check if we can provide the category + widget.config, + _onSkinTonedEmojiSelected, + links[emoji.emoji]!, + ); + }, + ), + ); + } + + void _onSkinTonedEmojiSelected(Category? category, Emoji emoji) { + widget.state.onEmojiSelected(category, emoji); + closeSkinToneOverlay(); + } + + @override + Widget build(BuildContext context) { + throw UnimplementedError('Search View implementation missing'); + } +} diff --git a/lib/src/search_view/search_view_config.dart b/lib/src/search_view/search_view_config.dart new file mode 100644 index 0000000..f849cd2 --- /dev/null +++ b/lib/src/search_view/search_view_config.dart @@ -0,0 +1,47 @@ +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; +import 'package:flutter/material.dart'; + +/// Callback function for custom search view +typedef SearchViewBuilder = Widget Function( + Config config, + EmojiViewState state, + VoidCallback showEmojiView, +); + +/// Search view Config +class SearchViewConfig { + /// Constructor + const SearchViewConfig({ + this.backgroundColor = const Color(0xFFEBEFF2), + this.buttonColor = Colors.transparent, + this.buttonIconColor = Colors.black26, + this.customSearchView, + }); + + /// Background color of search bar + final Color backgroundColor; + + /// Fill color of hide search view button + final Color buttonColor; + + /// Icon color of hide search view button + final Color buttonIconColor; + + /// Custom search bar + /// Hot reload is not supported + final SearchViewBuilder? customSearchView; + + @override + bool operator ==(other) { + return (other is SearchViewConfig) && + other.backgroundColor == backgroundColor && + other.buttonColor == buttonColor && + other.buttonIconColor == buttonIconColor; + } + + @override + int get hashCode => + backgroundColor.hashCode ^ + buttonColor.hashCode ^ + buttonIconColor.hashCode; +} diff --git a/lib/src/skin_tone_overlay.dart b/lib/src/skin_tone_overlay.dart deleted file mode 100644 index c7043c2..0000000 --- a/lib/src/skin_tone_overlay.dart +++ /dev/null @@ -1,155 +0,0 @@ -import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; -import 'package:flutter/material.dart'; - -/// Skin tone overlay mixin -mixin SkinToneOverlayStateMixin on State { - OverlayEntry? _overlay; - - /// Overlay close & resources disposal - void closeSkinToneOverlay() { - _overlay?.remove(); - _overlay = null; - } - - /// Overlay for SkinTone - void showSkinToneOverlay( - Emoji emoji, - double emojiSize, - CategoryEmoji? categoryEmoji, - int index, - int skinToneCount, - Config config, - double scrollControllerOffset, - double tabBarHeight, - EmojiPickerUtils utils, - OnEmojiSelected onEmojiSelected, - ) { - // Generate other skintone options - final skinTonesEmoji = SkinTone.values - .map((skinTone) => utils.applySkinTone(emoji, skinTone)) - .toList(); - - final positionRect = _calculateEmojiPosition( - context, - index, - config.columns, - skinToneCount, - scrollControllerOffset, - tabBarHeight, - config.customSkinColorOverlayHorizontalOffset, - ); - - _overlay = OverlayEntry( - builder: (context) => Positioned( - left: positionRect.left, - top: positionRect.top, - child: Material( - elevation: 4.0, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 4.0), - color: config.skinToneDialogBgColor, - child: Row( - children: [ - _buildSkinToneEmoji( - emoji, - categoryEmoji, - positionRect.width, - emojiSize, - onEmojiSelected, - config, - ), - ...List.generate( - SkinTone.values.length, - (index) => _buildSkinToneEmoji( - skinTonesEmoji[index], - categoryEmoji, - positionRect.width, - emojiSize, - onEmojiSelected, - config), - ), - ], - ), - ), - ), - ), - ); - - if (_overlay != null) { - Overlay.of(context).insert(_overlay!); - } else { - throw Exception('Nullable skin tone overlay insert attempt'); - } - } - - Widget _buildSkinToneEmoji( - Emoji emoji, - CategoryEmoji? categoryEmoji, - double width, - double emojiSize, - OnEmojiSelected onEmojiSelected, - Config config, - ) => - SizedBox( - width: width, - height: width, - child: EmojiCell.fromConfig( - emoji: emoji, - emojiSize: emojiSize, - categoryEmoji: categoryEmoji, - onEmojiSelected: onEmojiSelected, - config: config, - ), - ); - - Rect _calculateEmojiPosition( - BuildContext context, - int index, - int columns, - int skinToneCount, - double scrollControllerOffset, - double tabBarHeight, - double? customSkinColorOverlayHorizontalOffset, - ) { - // Calculate position of emoji in the grid - final row = index ~/ columns; - final column = index % columns; - // Calculate position for skin tone dialog - final renderBox = context.findRenderObject() as RenderBox; - final offset = renderBox.localToGlobal(Offset.zero); - final emojiSpace = renderBox.size.width / columns; - final topOffset = emojiSpace; - final leftOffset = - _getLeftOffset(emojiSpace, column, skinToneCount, columns); - final dx = customSkinColorOverlayHorizontalOffset ?? offset.dx; - final left = dx + column * emojiSpace + leftOffset; - final top = tabBarHeight + - offset.dy + - row * emojiSpace - - scrollControllerOffset - - topOffset; - - return Rect.fromLTWH(left, top, emojiSpace, .0); - } - -// Calucates the offset from the middle of selected emoji to the left side -// of the skin tone dialog -// Case 1: Selected Emoji is close to left border and offset needs to be -// reduced -// Case 2: Selected Emoji is close to right border and offset needs to be -// larger than half of the whole width -// Case 3: Enough space to left and right border and offset can be half -// of whole width - double _getLeftOffset( - double emojiWidth, int column, int skinToneCount, int columns) { - var remainingColumns = columns - (column + 1 + (skinToneCount ~/ 2)); - if (column >= 0 && column < 3) { - return -1 * column * emojiWidth; - } else if (remainingColumns < 0) { - return -1 * - ((skinToneCount ~/ 2 - 1) + -1 * remainingColumns) * - emojiWidth; - } - return -1 * ((skinToneCount ~/ 2) * emojiWidth) + emojiWidth / 2; - } -} diff --git a/lib/src/emoji_skin_tones.dart b/lib/src/skin_tones/emoji_skin_tones.dart similarity index 100% rename from lib/src/emoji_skin_tones.dart rename to lib/src/skin_tones/emoji_skin_tones.dart diff --git a/lib/src/skin_tones/skin_tone_config.dart b/lib/src/skin_tones/skin_tone_config.dart new file mode 100644 index 0000000..20d0d3f --- /dev/null +++ b/lib/src/skin_tones/skin_tone_config.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +/// Skin tone config Config +class SkinToneConfig { + /// Constructor + const SkinToneConfig({ + this.enabled = true, + this.dialogBackgroundColor = Colors.white, + this.indicatorColor = Colors.grey, + }); + + /// Enable feature to select a skin tone of certain emoji's + final bool enabled; + + /// The background color of the skin tone dialog + final Color dialogBackgroundColor; + + /// Color of the small triangle next to multiple skin tone emoji + final Color indicatorColor; + + @override + bool operator ==(other) { + return (other is SkinToneConfig) && + other.enabled == enabled && + other.dialogBackgroundColor == dialogBackgroundColor && + other.indicatorColor == indicatorColor; + } + + @override + int get hashCode => + enabled.hashCode ^ + dialogBackgroundColor.hashCode ^ + indicatorColor.hashCode; +} diff --git a/lib/src/skin_tones/skin_tone_overlay.dart b/lib/src/skin_tones/skin_tone_overlay.dart new file mode 100644 index 0000000..c1d9fe5 --- /dev/null +++ b/lib/src/skin_tones/skin_tone_overlay.dart @@ -0,0 +1,149 @@ +import 'dart:collection'; + +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; +import 'package:flutter/material.dart'; + +/// Skin tone overlay mixin +mixin SkinToneOverlayStateMixin on State { + final _utils = EmojiPickerUtils(); + OverlayEntry? _overlay; + + /// Layer links for skin tone overlay + final links = HashMap(); + + /// Add target for skin tone overlay if skin tone is available + Widget addSkinToneTargetIfAvailable({ + required bool hasSkinTone, + required String linkKey, + required Widget child, + }) { + if (hasSkinTone) { + final link = links.putIfAbsent(linkKey, LayerLink.new); + return CompositedTransformTarget( + link: link, + child: child, + ); + } + return child; + } + + /// Overlay close & resources disposal + void closeSkinToneOverlay() { + _overlay?.remove(); + _overlay = null; + } + + /// Overlay for SkinTone + void showSkinToneOverlay( + Offset emojiBoxPosition, + Emoji emoji, + double emojiSize, + CategoryEmoji? categoryEmoji, + Config config, + OnEmojiSelected onEmojiSelected, + LayerLink link, + ) { + // Generate other skintone options + final skinTonesEmoji = SkinTone.values + .map((skinTone) => _utils.applySkinTone(emoji, skinTone)) + .toList(); + + final screenWidth = MediaQuery.of(context).size.width; + final emojiPickerRenderbox = context.findRenderObject() as RenderBox; + final emojiBoxSize = config.emojiViewConfig.getEmojiBoxSize( + emojiPickerRenderbox.size.width, + ); + final left = _calculateLeftOffset( + emojiBoxSize, + emojiBoxPosition, + screenWidth, + ); + final top = _calculateTopOffset(emojiBoxSize); + + _overlay = OverlayEntry( + builder: (context) => Positioned( + top: 0, + left: 0, + child: CompositedTransformFollower( + offset: Offset(left, top), + link: link, + showWhenUnlinked: false, + child: TapRegion( + onTapOutside: (_) => closeSkinToneOverlay(), + child: Material( + elevation: 4.0, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 4.0), + color: config.skinToneConfig.dialogBackgroundColor, + child: Row( + children: [ + EmojiCell.fromConfig( + emoji: emoji, + emojiSize: emojiSize, + emojiBoxSize: emojiBoxSize, + categoryEmoji: categoryEmoji, + onEmojiSelected: onEmojiSelected, + config: config, + ), + ...List.generate( + SkinTone.values.length, + (index) => EmojiCell.fromConfig( + emoji: skinTonesEmoji[index], + emojiSize: emojiSize, + emojiBoxSize: emojiBoxSize, + categoryEmoji: categoryEmoji, + onEmojiSelected: onEmojiSelected, + config: config, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + + if (_overlay != null) { + Overlay.of(context).insert(_overlay!); + } else { + throw Exception('Nullable skin tone overlay insert attempt'); + } + } + + double _calculateTopOffset(double emojiBoxSize) { + final verticalPaddingOverlay = 8.0; + final top = -emojiBoxSize - verticalPaddingOverlay; + return top; + } + + double _calculateLeftOffset( + double emojiBoxSize, + Offset emojiBoxPosition, + double screenWidth, + ) { + var left = -2.5 * emojiBoxSize; + + if (emojiBoxPosition.dx - 1 * emojiBoxSize < 0) { + left += 2.5 * emojiBoxSize; + } else if (emojiBoxPosition.dx - 2 * emojiBoxSize < 0) { + left += 1.5 * emojiBoxSize; + } else if (emojiBoxPosition.dx - 3 * emojiBoxSize < 0) { + left += 0.5 * emojiBoxSize; + } else if (emojiBoxPosition.dx + 2 * emojiBoxSize > screenWidth) { + left -= 2.5 * emojiBoxSize; + } else if (emojiBoxPosition.dx + 3 * emojiBoxSize > screenWidth) { + left -= 1.5 * emojiBoxSize; + } else if (emojiBoxPosition.dx + 4 * emojiBoxSize > screenWidth) { + left -= 0.5 * emojiBoxSize; + } + return left; + } + + @override + void dispose() { + _overlay?.dispose(); + super.dispose(); + } +} diff --git a/lib/src/triangle_decoration.dart b/lib/src/skin_tones/triangle_decoration.dart similarity index 100% rename from lib/src/triangle_decoration.dart rename to lib/src/skin_tones/triangle_decoration.dart diff --git a/lib/src/widgets/backspace_button.dart b/lib/src/widgets/backspace_button.dart new file mode 100644 index 0000000..2ef5883 --- /dev/null +++ b/lib/src/widgets/backspace_button.dart @@ -0,0 +1,104 @@ +import 'dart:async'; + +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; +import 'package:flutter/material.dart'; + +/// Backspace Button Widget +class BackspaceButton extends StatefulWidget { + /// Constructor + const BackspaceButton(this.config, this.onBackspacePressed, + this.onBackspaceLongPressed, this.iconColor, + {super.key}); + + /// Config + final Config config; + + /// Backspace callback + final VoidCallback? onBackspacePressed; + + /// Backspace long press callback + final VoidCallback? onBackspaceLongPressed; + + /// Backspace Icon color + final Color iconColor; + + @override + State createState() => _BackspaceButtonState(); +} + +class _BackspaceButtonState extends State { + Timer? _onBackspacePressedCallbackTimer; + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + child: GestureDetector( + onLongPressStart: (_) => _startOnBackspacePressedCallback(), + onLongPressEnd: (_) => _stopOnBackspacePressedCallback(), + child: IconButton( + padding: const EdgeInsets.only(bottom: 2), + icon: Icon( + Icons.backspace, + color: widget.iconColor, + ), + onPressed: () { + widget.onBackspacePressed?.call(); + }, + ), + ), + ); + } + + @override + void dispose() { + _onBackspacePressedCallbackTimer?.cancel(); + super.dispose(); + } + + /// Start the callback for long-pressing the backspace button. + void _startOnBackspacePressedCallback() { + // Initial callback interval for short presses + var callbackInterval = const Duration(milliseconds: 75); + var millisecondsSincePressed = 0; + + // Callback function executed on each timer tick + void _callback(Timer timer) { + // Accumulate elapsed time since the last tick + millisecondsSincePressed += callbackInterval.inMilliseconds; + + // If the long-press duration exceeds 3 seconds + if (millisecondsSincePressed > 3000 && + callbackInterval == const Duration(milliseconds: 75)) { + // Switch to a longer callback interval for word-by-word deletion + callbackInterval = const Duration(milliseconds: 300); + + // Cancel the existing timer and start a new one with the updated + // interval + _onBackspacePressedCallbackTimer?.cancel(); + _onBackspacePressedCallbackTimer = + Timer.periodic(callbackInterval, _callback); + + // Reset the elapsed time for the new interval + millisecondsSincePressed = 0; + } + + // Trigger the appropriate callback based on the interval + if (callbackInterval == const Duration(milliseconds: 75)) { + widget.onBackspacePressed?.call(); // Short-press callback + } else { + widget.onBackspaceLongPressed?.call(); // Long-press callback + } + } + + // Start the initial timer with the short-press interval + _onBackspacePressedCallbackTimer = + Timer.periodic(callbackInterval, _callback); + } + + /// Stop the callback for long-pressing the backspace button. + void _stopOnBackspacePressedCallback() { + // Cancel the active timer + _onBackspacePressedCallbackTimer?.cancel(); + } +} diff --git a/lib/src/emoji_cell.dart b/lib/src/widgets/emoji_cell.dart similarity index 63% rename from lib/src/emoji_cell.dart rename to lib/src/widgets/emoji_cell.dart index 34f594a..654ebb6 100644 --- a/lib/src/emoji_cell.dart +++ b/lib/src/widgets/emoji_cell.dart @@ -1,5 +1,4 @@ import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; -import 'package:emoji_picker_flutter/src/triangle_decoration.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -11,9 +10,9 @@ class EmojiCell extends StatelessWidget { const EmojiCell({ required this.emoji, required this.emojiSize, + required this.emojiBoxSize, this.categoryEmoji, required this.buttonMode, - this.index = 0, required this.enableSkinTones, required this.textStyle, required this.skinToneIndicatorColor, @@ -26,15 +25,15 @@ class EmojiCell extends StatelessWidget { EmojiCell.fromConfig( {required this.emoji, required this.emojiSize, + required this.emojiBoxSize, this.categoryEmoji, - this.index = 0, required this.onEmojiSelected, this.onSkinToneDialogRequested, required Config config}) - : buttonMode = config.buttonMode, - enableSkinTones = config.enableSkinTones, + : buttonMode = config.emojiViewConfig.buttonMode, + enableSkinTones = config.skinToneConfig.enabled, textStyle = config.emojiTextStyle, - skinToneIndicatorColor = config.skinToneIndicatorColor; + skinToneIndicatorColor = config.skinToneConfig.indicatorColor; /// Emoji to display as the cell content final Emoji emoji; @@ -42,16 +41,15 @@ class EmojiCell extends StatelessWidget { /// Font size for the emoji final double emojiSize; + /// Hitbox of emoji cell + final double emojiBoxSize; + /// Optinonal category that will be passed through to callbacks final CategoryEmoji? categoryEmoji; /// Visual tap feedback, see [ButtonMode] for options final ButtonMode buttonMode; - /// Optional index that can be used for precise skin dialog position. - /// Will be passed through to [onSkinToneDialogRequested] callback. - final int index; - /// Whether to show skin popup indicator if emoji supports skin colors final bool enableSkinTones; @@ -69,6 +67,34 @@ class EmojiCell extends StatelessWidget { /// Callback for a single tap on the cell. final OnEmojiSelected onEmojiSelected; + @override + Widget build(BuildContext context) { + final onPressed = () { + onEmojiSelected(categoryEmoji?.category, emoji); + }; + + final onLongPressed = () { + final renderBox = context.findRenderObject() as RenderBox; + final emojiBoxPosition = renderBox.localToGlobal(Offset.zero); + onSkinToneDialogRequested?.call( + emojiBoxPosition, + emoji, + emojiSize, + categoryEmoji, + ); + }; + + return SizedBox( + width: emojiBoxSize, + height: emojiBoxSize, + child: _buildButtonWidget( + onPressed: onPressed, + onLongPressed: onLongPressed, + child: _buildEmoji(), + ), + ); + } + /// Build different Button based on ButtonMode Widget _buildButtonWidget({ required VoidCallback onPressed, @@ -76,68 +102,63 @@ class EmojiCell extends StatelessWidget { required Widget child, }) { if (buttonMode == ButtonMode.MATERIAL) { - return InkWell( - onTap: onPressed, + return MaterialButton( + onPressed: onPressed, onLongPress: onLongPressed, child: child, + elevation: 0, + highlightElevation: 0, + padding: EdgeInsets.zero, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.zero, + ), ); } if (buttonMode == ButtonMode.CUPERTINO) { return GestureDetector( onLongPress: onLongPressed, child: CupertinoButton( - padding: EdgeInsets.zero, onPressed: onPressed, + padding: EdgeInsets.zero, child: child, + alignment: Alignment.center, ), ); } return GestureDetector( onLongPress: onLongPressed, onTap: onPressed, - child: child, + child: Center(child: child), ); } /// Build and display Emoji centered of its parent Widget _buildEmoji() { - final style = TextStyle( - fontSize: emojiSize, - backgroundColor: Colors.transparent, - ); final emojiText = Text( emoji.emoji, - textScaleFactor: 1.0, - style: textStyle == null ? style : textStyle!.merge(style), + textScaler: const TextScaler.linear(1.0), + style: _getEmojiTextStyle(), ); - return Center( - child: emoji.hasSkinTone && - enableSkinTones && - onSkinToneDialogRequested != null - ? Container( - decoration: - TriangleDecoration(color: skinToneIndicatorColor, size: 8.0), - child: emojiText, - ) - : emojiText, - ); + return emoji.hasSkinTone && + enableSkinTones && + onSkinToneDialogRequested != null + ? Container( + decoration: TriangleDecoration( + color: skinToneIndicatorColor, + size: 8.0, + ), + child: emojiText, + ) + : emojiText; } - @override - Widget build(BuildContext context) { - final onPressed = () { - onEmojiSelected(categoryEmoji?.category, emoji); - }; - - final onLongPressed = () { - onSkinToneDialogRequested?.call(emoji, emojiSize, categoryEmoji, index); - }; - - return _buildButtonWidget( - onPressed: onPressed, - onLongPressed: onLongPressed, - child: _buildEmoji(), + TextStyle _getEmojiTextStyle() { + final defaultStyle = DefaultEmojiTextStyle.copyWith( + fontSize: emojiSize, + inherit: true, ); + // textStyle properties have priority over defaultStyle + return textStyle == null ? defaultStyle : defaultStyle.merge(textStyle); } } diff --git a/pubspec.lock b/pubspec.lock index 90fb09e..1632cc7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -49,6 +49,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" collection: dependency: transitive description: @@ -69,10 +77,10 @@ packages: dependency: transitive description: name: coverage - sha256: "595a29b55ce82d53398e1bcc2cba525d7bd7c59faeb2d2540e9d42c390cfeeeb" + sha256: "8acabb8306b57a409bf4c83522065672ee13179297a6bb0cb9ead73948df7c76" url: "https://pub.dev" source: hosted - version: "1.6.4" + version: "1.7.2" crypto: dependency: transitive description: @@ -81,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" + source: hosted + version: "1.3.1" ffi: dependency: transitive description: @@ -93,10 +109,10 @@ packages: dependency: transitive description: name: file - sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" url: "https://pub.dev" source: hosted - version: "6.1.4" + version: "7.0.0" flutter: dependency: "direct main" description: flutter @@ -106,10 +122,15 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: a25a15ebbdfc33ab1cd26c63a6ee519df92338a9c10f122adda92938253bef04 + sha256: e2a421b7e59244faef694ba7b30562e489c2b489866e505074eb005cd7060db7 url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "3.0.1" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" flutter_web_plugins: dependency: "direct main" description: flutter @@ -167,10 +188,10 @@ packages: dependency: transitive description: name: lints - sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "3.0.0" logging: dependency: transitive description: @@ -191,18 +212,18 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.5.0" meta: dependency: transitive description: name: meta - sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 + sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.10.0" mime: dependency: transitive description: @@ -247,10 +268,10 @@ packages: dependency: transitive description: name: path_provider_platform_interface - sha256: "94b1e0dd80970c1ce43d5d4e050a9918fce4f4a775e6142424c30a29a363265c" + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" path_provider_windows: dependency: transitive description: @@ -263,18 +284,18 @@ packages: dependency: transitive description: name: platform - sha256: ae68c7bfcd7383af3629daafb32fb4e8681c7154428da4febcff06200585f102 + sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "3.1.4" plugin_platform_interface: dependency: "direct main" description: name: plugin_platform_interface - sha256: da3fdfeccc4d4ff2da8f8c556704c08f912542c5fb3cf2233ed75372384a034d + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" url: "https://pub.dev" source: hosted - version: "2.1.6" + version: "2.1.8" pool: dependency: transitive description: @@ -295,10 +316,10 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: b7f41bad7e521d205998772545de63ff4e6c97714775902c199353f8bf1511ac + sha256: "81429e4481e1ccfb51ede496e916348668fd0921627779233bd24cc3ff6abd02" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" shared_preferences_android: dependency: transitive description: @@ -319,34 +340,34 @@ packages: dependency: transitive description: name: shared_preferences_linux - sha256: c2eb5bf57a2fe9ad6988121609e47d3e07bb3bdca5b6f8444e4cf302428a128a + sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" shared_preferences_platform_interface: dependency: transitive description: name: shared_preferences_platform_interface - sha256: d4ec5fc9ebb2f2e056c617112aa75dcf92fc2e4faaf2ae999caa297473f75d8a + sha256: "22e2ecac9419b4246d7c22bfbbda589e3acf5c0351137d87dd2939d984d37c3b" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" shared_preferences_web: dependency: transitive description: name: shared_preferences_web - sha256: d762709c2bbe80626ecc819143013cc820fa49ca5e363620ee20a8b15a3e3daf + sha256: "7b15ffb9387ea3e237bb7a66b8a23d2147663d391cafc5c8f37b2e7b4bde5d21" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" shared_preferences_windows: dependency: transitive description: name: shared_preferences_windows - sha256: f763a101313bd3be87edffe0560037500967de9c394a714cd598d945517f694f + sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" shelf: dependency: transitive description: @@ -444,10 +465,10 @@ packages: dependency: "direct dev" description: name: test - sha256: a20ddc0723556dc6dd56094e58ec1529196d5d7774156604cb14e8445a5a82ff + sha256: a1f7595805820fcc05e5c52e3a231aedd0b72972cb333e8c738a8b1239448b6f url: "https://pub.dev" source: hosted - version: "1.24.7" + version: "1.24.9" test_api: dependency: transitive description: @@ -460,10 +481,10 @@ packages: dependency: transitive description: name: test_core - sha256: "96382d0bc826e260b077bb496259e58bc82e90b603ab16cd5ae95dfe1dfcba8b" + sha256: a757b14fc47507060a162cc2530d9a4a2f92f5100a952c7443b5cad5ef5b106a url: "https://pub.dev" source: hosted - version: "0.5.7" + version: "0.5.9" typed_data: dependency: transitive description: @@ -484,10 +505,10 @@ packages: dependency: transitive description: name: vm_service - sha256: a13d5503b4facefc515c8c587ce3cf69577a7b064a9f1220e005449cf1f64aad + sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 url: "https://pub.dev" source: hosted - version: "12.0.0" + version: "13.0.0" watcher: dependency: transitive description: @@ -496,6 +517,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + web: + dependency: transitive + description: + name: web + sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 + url: "https://pub.dev" + source: hosted + version: "0.3.0" web_socket_channel: dependency: transitive description: @@ -516,18 +545,18 @@ packages: dependency: transitive description: name: win32 - sha256: "350a11abd2d1d97e0cc7a28a81b781c08002aa2864d9e3f192ca0ffa18b06ed3" + sha256: "464f5674532865248444b4c3daca12bd9bf2d7c47f759ce2617986e7229494a8" url: "https://pub.dev" source: hosted - version: "5.0.9" + version: "5.2.0" xdg_directories: dependency: transitive description: name: xdg_directories - sha256: "589ada45ba9e39405c198fe34eb0f607cddb2108527e658136120892beac46d2" + sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.4" yaml: dependency: transitive description: @@ -537,5 +566,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.2.0-0 <4.0.0" - flutter: ">=3.7.0" + dart: ">=3.2.0 <4.0.0" + flutter: ">=3.16.0" diff --git a/pubspec.yaml b/pubspec.yaml index 8ab7548..a90e037 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,11 +1,11 @@ name: emoji_picker_flutter description: A Flutter package that provides an Emoji picker widget with 1500+ emojis in 8 categories. -version: 1.6.4 +version: 2.0.0 homepage: https://github.com/Fintasys/emoji_picker_flutter environment: sdk: '>=2.17.0 <4.0.0' - flutter: '>=3.0.0' + flutter: '>=3.16.0' dependencies: flutter: @@ -16,8 +16,10 @@ dependencies: shared_preferences: ^2.0.15 dev_dependencies: - flutter_lints: ^2.0.3 - test: ^1.21.4 + flutter_test: + sdk: flutter + flutter_lints: ^3.0.1 + test: ^1.24.9 flutter: plugin: diff --git a/screenshot/backspace_bottom.png b/screenshot/backspace_bottom.png new file mode 100644 index 0000000..4a34244 Binary files /dev/null and b/screenshot/backspace_bottom.png differ diff --git a/screenshot/example_custom_font_android.png b/screenshot/example_custom_font_android.png new file mode 100644 index 0000000..ad37ea1 Binary files /dev/null and b/screenshot/example_custom_font_android.png differ diff --git a/screenshot/example_custom_font_android_2.png b/screenshot/example_custom_font_android_2.png new file mode 100644 index 0000000..7efc35c Binary files /dev/null and b/screenshot/example_custom_font_android_2.png differ diff --git a/screenshot/example_default_android.png b/screenshot/example_default_android.png new file mode 100644 index 0000000..51828a6 Binary files /dev/null and b/screenshot/example_default_android.png differ diff --git a/screenshot/example_whatsapp_emoji_view.png b/screenshot/example_whatsapp_emoji_view.png new file mode 100644 index 0000000..b3bc23a Binary files /dev/null and b/screenshot/example_whatsapp_emoji_view.png differ diff --git a/screenshot/example_whatsapp_search_view.png b/screenshot/example_whatsapp_search_view.png new file mode 100644 index 0000000..d700ad0 Binary files /dev/null and b/screenshot/example_whatsapp_search_view.png differ diff --git a/test/emoji_picker_test.dart b/test/emoji_picker_test.dart new file mode 100644 index 0000000..2b2c071 --- /dev/null +++ b/test/emoji_picker_test.dart @@ -0,0 +1,143 @@ +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// Use for golden tests, helpful in debugging +// await expectLater( +// find.byType(MaterialApp), +// matchesGoldenFile('overlay.png'), +// ); + +void main() { + group('EmojiPicker Tests', () { + testWidgets('Should allow user to select an emoji', + (WidgetTester tester) async { + final _controller = TextEditingController(); + Emoji? _emojiSelected; + Category? _categorySelected; + + // Build our app and trigger a frame. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: EmojiPicker( + textEditingController: _controller, + onEmojiSelected: (category, emoji) { + _emojiSelected = emoji; + _categorySelected = category; + }, + config: const Config( + height: 256, + categoryViewConfig: CategoryViewConfig( + recentTabBehavior: RecentTabBehavior.NONE, + )), + ), + ), + ), + ); + + // Wait for the emojis to load if they are being loaded asynchronously + await tester.pumpAndSettle(); + + // Find an emoji in the picker + final emoji = find.text('🙂').hitTestable(); + + // Verify if we can find the emoji + expect(emoji, findsOneWidget); + + // Tap on the emoji, this should trigger the selection action + await tester.tap(emoji); + + // Call pumpAndSettle in case the UI needs to settle after an interaction + await tester.pumpAndSettle(); + + // Check if the emoji is added to the text controller + expect(_controller.text, contains('🙂')); + + // Check if the emoji been passed to the 'onEmojiSelected' callback + expect( + _emojiSelected, equals(const Emoji('🙂', 'Slightly Smiling Face'))); + + // Check if the category been passed to the 'onEmojiSelected' callback + expect(_categorySelected, equals(Category.SMILEYS)); + }); + + testWidgets('Should allow to select an emoji with skintone on longPress', + (WidgetTester tester) async { + final _controller = TextEditingController(); + final _utils = EmojiPickerUtils(); + final emoji = const Emoji('👍', 'Thumbs Up', hasSkinTone: true); + Emoji? _emojiSelected; + Category? _categorySelected; + + // Build our app and trigger a frame. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Padding( + padding: const EdgeInsets.only(top: 64.0), + child: EmojiPicker( + textEditingController: _controller, + onEmojiSelected: (category, emoji) { + _emojiSelected = emoji; + _categorySelected = category; + }, + config: const Config( + height: 500, + categoryViewConfig: CategoryViewConfig( + recentTabBehavior: RecentTabBehavior.NONE, + ), + ), + ), + ), + ), + ), + ); + + // Wait for the emojis to load if they are being loaded asynchronously + await tester.pumpAndSettle(); + + // Find an emoji in the picker + final emojiToFind = find.text(emoji.emoji); + + // Scroll until the emoji to be found appears. + await tester.dragUntilVisible( + emojiToFind, + find.byKey(const Key('emojiScrollView')), + const Offset(0, -300), + ); + + // Verify if we can find the emoji + expect(emojiToFind, findsOneWidget); + + // Tap on the emoji, this should trigger the skintone overlay + await tester.longPress(emojiToFind); + + // Call pumpAndSettle in case the UI needs to settle after an interaction + await tester.pumpAndSettle(); + + /// Check if all skin tones are rendered in overlay + Finder? skinToneVariantToFind; + for (var i = 0; i < SkinTone.values.length; i++) { + skinToneVariantToFind = + find.text(_utils.applySkinTone(emoji, SkinTone.values[i]).emoji); + // Verify if we can find the skintone variant + expect(skinToneVariantToFind, findsOneWidget); + } + + // Tap on the emoji, this should trigger the selection action + await tester.tap(skinToneVariantToFind!); + + // Check if the emoji is added to the text controller + expect(_controller.text, contains('👍🏿')); + + // Check if the emoji been passed to the 'onEmojiSelected' callback + expect(_emojiSelected?.emoji, equals('👍🏿')); + expect(_emojiSelected?.name, equals('Thumbs Up')); + expect(_emojiSelected?.hasSkinTone, equals(true)); + + // Check if the category been passed to the 'onEmojiSelected' callback + expect(_categorySelected, equals(Category.SMILEYS)); + }); + }); +} diff --git a/test/emoji_text_editing_controller_test.dart b/test/emoji_text_editing_controller_test.dart new file mode 100644 index 0000000..a8427a5 --- /dev/null +++ b/test/emoji_text_editing_controller_test.dart @@ -0,0 +1,40 @@ +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('EmojiTextEditingController', () { + testWidgets('should apply emojiTextStyle to emojis', (tester) async { + await tester.pumpWidget( + Builder( + builder: (BuildContext context) { + final emojiStyle = const TextStyle(color: Colors.red); + final regularStyle = const TextStyle(color: Colors.black); + final controller = EmojiTextEditingController( + text: 'Hello 👋 World', + emojiTextStyle: emojiStyle, + ); + + final span = controller.buildTextSpan( + context: context, + style: regularStyle, + withComposing: false, + ); + + expect(span.children?.length, 3); + // Hello + expect(span.children?[0].style?.color, Colors.black); + // Emoji + expect(span.children?[1].style?.color, Colors.red); + expect(span.children?[1].style?.fontFamilyFallback, + DefaultEmojiTextStyle.fontFamilyFallback); + // World + expect(span.children?[2].style?.color, Colors.black); + + return const Placeholder(); + }, + ), + ); + }); + }); +} diff --git a/test/emoji_text_style_test.dart b/test/emoji_text_style_test.dart new file mode 100644 index 0000000..1afcc40 --- /dev/null +++ b/test/emoji_text_style_test.dart @@ -0,0 +1,36 @@ +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('EmojiTextStyle', () { + testWidgets('should apply EmojiTextStyle to emoji in text', (tester) async { + await tester.pumpWidget( + Builder( + builder: (BuildContext context) { + final text = 'Hello 👋 World'; + final result = EmojiPickerUtils().setEmojiTextStyle( + text, + emojiStyle: const TextStyle(color: Colors.red), + parentStyle: const TextStyle( + color: Colors.black, + ), + ); + + expect(result.length, 3); + // Hello + expect(result[0].style?.color, Colors.black); + // Emoji + expect(result[1].style?.color, Colors.red); + expect(result[1].style?.fontFamilyFallback, + DefaultEmojiTextStyle.fontFamilyFallback); + // World + expect(result[2].style?.color, Colors.black); + + return const Placeholder(); + }, + ), + ); + }); + }); +}