mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-10-09 02:54:14 +00:00
move emoji_picker into twonly
This commit is contained in:
parent
8580908536
commit
ce14681276
58 changed files with 51968 additions and 132 deletions
|
|
@ -1,5 +1,6 @@
|
|||
package eu.twonly
|
||||
|
||||
import android.graphics.Paint
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
import android.view.KeyEvent
|
||||
import dev.darttools.flutter_android_volume_keydown.FlutterAndroidVolumeKeydownPlugin.eventSink
|
||||
|
|
@ -10,6 +11,7 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import io.crates.keyring.Keyring
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.core.graphics.PaintCompat
|
||||
import android.os.Bundle
|
||||
import android.net.Uri
|
||||
import java.io.InputStream
|
||||
|
|
@ -25,6 +27,7 @@ import android.content.pm.PackageManager
|
|||
|
||||
class MainActivity : FlutterFragmentActivity() {
|
||||
private val CHANNEL = "eu.twonly/photo_picker"
|
||||
private val emojiPaint = Paint()
|
||||
private var pendingResult: MethodChannel.Result? = null
|
||||
private var pendingLocationPermissionResult: MethodChannel.Result? = null
|
||||
|
||||
|
|
@ -95,6 +98,18 @@ class MainActivity : FlutterFragmentActivity() {
|
|||
WidgetRuntimeChannel.configure(flutterEngine, applicationContext)
|
||||
WebxdcChannel.configure(flutterEngine, this)
|
||||
|
||||
MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
"eu.twonly/emoji_picker",
|
||||
).setMethodCallHandler { call, result ->
|
||||
if (call.method != "getSupportedEmojis") {
|
||||
result.notImplemented()
|
||||
return@setMethodCallHandler
|
||||
}
|
||||
val emoji = call.argument<List<String>>("source")
|
||||
result.success(emoji?.map { PaintCompat.hasGlyph(emojiPaint, it) })
|
||||
}
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"pickImages" -> {
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit 4d22ef849031619d6bb4dcf8ea1709d3c1ff8b3f
|
||||
Subproject commit 8cf0eb53c6955201bea92d4515e0978e383640b3
|
||||
|
|
@ -41,8 +41,7 @@ def get_default_branch(repo_path='.'):
|
|||
return result.stdout.strip().split('/')[-1]
|
||||
return 'main'
|
||||
|
||||
def integrate_package(folder_name, data, cache_dir, out_dir):
|
||||
repo_url = data['git']
|
||||
def integrate_package(folder_name, data, cache_dir, out_dir, cache_only=False):
|
||||
keep_list = ["lib", "LICENSE", "pubspec.yaml", "android", "ios", "darwin"]
|
||||
if "keep" in data:
|
||||
keep_list += [item.rstrip('/') for item in data['keep']]
|
||||
|
|
@ -50,6 +49,15 @@ def integrate_package(folder_name, data, cache_dir, out_dir):
|
|||
print(f"Processing {folder_name}...")
|
||||
|
||||
cache_path = os.path.join(cache_dir, folder_name)
|
||||
if cache_only:
|
||||
if not os.path.isdir(cache_path):
|
||||
raise FileNotFoundError(
|
||||
f"Cached repository not found: {cache_path}. "
|
||||
"Run without --cache-only first to populate the cache."
|
||||
)
|
||||
print_blue(f"Copying {folder_name} from the existing cache only.")
|
||||
else:
|
||||
repo_url = data['git']
|
||||
if not os.path.exists(cache_path):
|
||||
subprocess.run(["git", "clone", repo_url, cache_path], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
else:
|
||||
|
|
@ -83,13 +91,17 @@ def integrate_package(folder_name, data, cache_dir, out_dir):
|
|||
pkg_name = pkg["name"]
|
||||
subpath = pkg.get("path", "")
|
||||
|
||||
package_src_path = os.path.join(cache_path, subpath) if subpath else cache_path
|
||||
if not os.path.isdir(package_src_path):
|
||||
raise FileNotFoundError(
|
||||
f"Cached package source not found: {package_src_path}"
|
||||
)
|
||||
|
||||
out_path = os.path.join(out_dir, pkg_name)
|
||||
if os.path.exists(out_path):
|
||||
shutil.rmtree(out_path)
|
||||
os.makedirs(out_path)
|
||||
|
||||
package_src_path = os.path.join(cache_path, subpath) if subpath else cache_path
|
||||
|
||||
for item in keep_list:
|
||||
src_item = os.path.join(package_src_path, item)
|
||||
dst_item = os.path.join(out_path, item)
|
||||
|
|
@ -119,6 +131,11 @@ def integrate_package(folder_name, data, cache_dir, out_dir):
|
|||
def main():
|
||||
parser = argparse.ArgumentParser(description="Update specific or all repositories.")
|
||||
parser.add_argument('repo_name', nargs='?', default=None, help="Name of the repository to update (optional)")
|
||||
parser.add_argument(
|
||||
'--cache-only',
|
||||
action='store_true',
|
||||
help='Copy from the existing cache without any Git or network operations',
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
with open("dependencies.yaml", "r") as f:
|
||||
|
|
@ -141,7 +158,13 @@ def main():
|
|||
def process_deps_recursive(deps_dict, to_update=None):
|
||||
for name, data in deps_dict.items():
|
||||
if to_update is None or name in to_update:
|
||||
extracted_packages = integrate_package(name, data, cache_dir, out_dir)
|
||||
extracted_packages = integrate_package(
|
||||
name,
|
||||
data,
|
||||
cache_dir,
|
||||
out_dir,
|
||||
cache_only=args.cache_only,
|
||||
)
|
||||
for pkg_name, version in extracted_packages:
|
||||
pubspec_overrides.append(f" {pkg_name}:\n path: {out_dir}/{pkg_name}\n")
|
||||
if version and version != "any":
|
||||
|
|
@ -153,7 +176,13 @@ def main():
|
|||
# Actually, the original logic updated children automatically if parent is updated.
|
||||
process_deps_recursive(data["dependencies"], None if (to_update is None or name in to_update) else [])
|
||||
|
||||
try:
|
||||
process_deps_recursive(deps, repos_to_update if args.repo_name else None)
|
||||
except FileNotFoundError as error:
|
||||
parser.error(str(error))
|
||||
|
||||
if args.cache_only:
|
||||
return
|
||||
|
||||
def sort_dependencies(d):
|
||||
sorted_d = {k: d[k] for k in sorted(d.keys())}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,6 @@ dependencies:
|
|||
blurhash_dart:
|
||||
git: https://github.com/justacid/blurhash-dart.git
|
||||
tag: v1.2.1
|
||||
emoji_picker_flutter:
|
||||
git: https://github.com/otsmr/emoji_picker_flutter.git
|
||||
commit: 47ddf705fb4840d6fbe305b79e896666dda7b0d5
|
||||
exif:
|
||||
git: https://github.com/bigflood/dartexif.git
|
||||
dependencies:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import 'dart:io';
|
||||
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker.dart';
|
||||
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layer_data.dart';
|
||||
|
||||
class EmojiPickerBottom extends StatelessWidget {
|
||||
|
|
@ -54,6 +55,7 @@ class EmojiPickerBottom extends StatelessWidget {
|
|||
height: 400,
|
||||
locale: Localizations.localeOf(context),
|
||||
checkPlatformCompatibility: false,
|
||||
resizeConfig: const ResizeConfig(showDragHandle: false),
|
||||
emojiTextStyle: TextStyle(
|
||||
fontSize: 24 * (Platform.isIOS ? 1.2 : 1),
|
||||
fontFamilyFallback: Platform.isAndroid
|
||||
|
|
|
|||
216
lib/src/visual/components/emoji_picker/bottom_action_bar.dart
Normal file
216
lib/src/visual/components/emoji_picker/bottom_action_bar.dart
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
/*
|
||||
* Modified version of https://github.com/Fintasys/emoji_picker_flutter
|
||||
* MIT License
|
||||
* Copyright (c) 2024 Stefan Humm
|
||||
*/
|
||||
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker.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, {
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Config for customizations
|
||||
final Config config;
|
||||
|
||||
/// State that holds current emoji data
|
||||
final EmojiViewState state;
|
||||
|
||||
/// Show Search Bar
|
||||
final VoidCallback showSearchView;
|
||||
}
|
||||
|
||||
/// Callback for a custom bottom action bar.
|
||||
typedef BottomActionBarBuilder =
|
||||
Widget Function(
|
||||
Config config,
|
||||
EmojiViewState state,
|
||||
VoidCallback showSearchView,
|
||||
);
|
||||
|
||||
/// Configuration for the bottom action bar.
|
||||
class BottomActionBarConfig {
|
||||
/// Constructor.
|
||||
const BottomActionBarConfig({
|
||||
this.enabled = true,
|
||||
this.showBackspaceButton = true,
|
||||
this.showSearchViewButton = true,
|
||||
this.backgroundColor = Colors.blue,
|
||||
this.buttonColor = Colors.blue,
|
||||
this.buttonIconColor = Colors.white,
|
||||
this.showStickerButton = true,
|
||||
this.emojiButtonLabel = 'Emoji',
|
||||
this.stickerButtonLabel = 'Sticker',
|
||||
this.selectedTabColor,
|
||||
this.onStickerButtonPressed,
|
||||
this.customBottomActionBar,
|
||||
});
|
||||
|
||||
final bool enabled;
|
||||
final bool showBackspaceButton;
|
||||
final bool showSearchViewButton;
|
||||
final Color? backgroundColor;
|
||||
final Color buttonColor;
|
||||
final Color buttonIconColor;
|
||||
final bool showStickerButton;
|
||||
final String emojiButtonLabel;
|
||||
final String stickerButtonLabel;
|
||||
final Color? selectedTabColor;
|
||||
final VoidCallback? onStickerButtonPressed;
|
||||
|
||||
/// Custom action bar. Hot reload is not supported.
|
||||
final BottomActionBarBuilder? customBottomActionBar;
|
||||
|
||||
@override
|
||||
bool operator ==(other) {
|
||||
return other is BottomActionBarConfig &&
|
||||
other.enabled == enabled &&
|
||||
other.showBackspaceButton == showBackspaceButton &&
|
||||
other.showSearchViewButton == showSearchViewButton &&
|
||||
other.backgroundColor == backgroundColor &&
|
||||
other.buttonColor == buttonColor &&
|
||||
other.buttonIconColor == buttonIconColor &&
|
||||
other.showStickerButton == showStickerButton &&
|
||||
other.emojiButtonLabel == emojiButtonLabel &&
|
||||
other.stickerButtonLabel == stickerButtonLabel &&
|
||||
other.selectedTabColor == selectedTabColor &&
|
||||
other.onStickerButtonPressed == onStickerButtonPressed &&
|
||||
other.customBottomActionBar == customBottomActionBar;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
enabled,
|
||||
showBackspaceButton,
|
||||
showSearchViewButton,
|
||||
backgroundColor,
|
||||
buttonColor,
|
||||
buttonIconColor,
|
||||
showStickerButton,
|
||||
emojiButtonLabel,
|
||||
stickerButtonLabel,
|
||||
selectedTabColor,
|
||||
onStickerButtonPressed,
|
||||
customBottomActionBar,
|
||||
);
|
||||
}
|
||||
|
||||
/// Default bottom action bar implementation.
|
||||
class DefaultBottomActionBar extends BottomActionBar {
|
||||
/// Constructor.
|
||||
const DefaultBottomActionBar(
|
||||
super.config,
|
||||
super.state,
|
||||
super.showSearchView, {
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _DefaultBottomActionBarState();
|
||||
}
|
||||
|
||||
class _DefaultBottomActionBarState extends State<DefaultBottomActionBar> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: widget.config.bottomActionBarConfig.backgroundColor,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 48, child: _buildSearchViewButton()),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: _buildPickerTabs(),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 48, child: _buildBackspaceButton()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPickerTabs() {
|
||||
final actionBarConfig = widget.config.bottomActionBarConfig;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildPickerTab(
|
||||
label: actionBarConfig.emojiButtonLabel,
|
||||
selected: true,
|
||||
),
|
||||
if (actionBarConfig.showStickerButton) ...[
|
||||
const SizedBox(width: 4),
|
||||
_buildPickerTab(
|
||||
label: actionBarConfig.stickerButtonLabel,
|
||||
onTap: actionBarConfig.onStickerButtonPressed,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPickerTab({
|
||||
required String label,
|
||||
bool selected = false,
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
final actionBarConfig = widget.config.bottomActionBarConfig;
|
||||
final selectedColor =
|
||||
actionBarConfig.selectedTabColor ??
|
||||
actionBarConfig.buttonIconColor.withValues(alpha: 0.14);
|
||||
return Material(
|
||||
color: selected ? selectedColor : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: actionBarConfig.buttonIconColor,
|
||||
fontSize: 13,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchViewButton() {
|
||||
if (!widget.config.bottomActionBarConfig.showSearchViewButton) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return CircleAvatar(
|
||||
backgroundColor: widget.config.bottomActionBarConfig.buttonColor,
|
||||
child: SearchButton(
|
||||
widget.config,
|
||||
widget.showSearchView,
|
||||
widget.config.bottomActionBarConfig.buttonIconColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBackspaceButton() {
|
||||
if (!widget.config.bottomActionBarConfig.showBackspaceButton) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return BackspaceButton(
|
||||
widget.config,
|
||||
widget.state.onBackspacePressed,
|
||||
widget.state.onBackspaceLongPressed,
|
||||
widget.config.bottomActionBarConfig.buttonIconColor,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
* Modified version of https://github.com/Fintasys/emoji_picker_flutter
|
||||
* MIT License
|
||||
* Copyright (c) 2024 Stefan Humm
|
||||
*/
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker.dart';
|
||||
|
||||
/// Container for a category and its emoji.
|
||||
class CategoryEmoji {
|
||||
/// Constructor.
|
||||
const CategoryEmoji(this.category, this.emoji);
|
||||
|
||||
/// Category instance.
|
||||
final Category category;
|
||||
|
||||
/// Emoji in this category.
|
||||
final List<Emoji> emoji;
|
||||
|
||||
/// Returns a copy with selected fields replaced.
|
||||
CategoryEmoji copyWith({Category? category, List<Emoji>? emoji}) {
|
||||
return CategoryEmoji(category ?? this.category, emoji ?? this.emoji);
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional action displayed after the category tabs.
|
||||
enum CategoryExtraTab {
|
||||
/// Do not show an extra tab.
|
||||
NONE,
|
||||
|
||||
/// Display a backspace button.
|
||||
BACKSPACE,
|
||||
|
||||
/// Display a search button.
|
||||
SEARCH,
|
||||
}
|
||||
|
||||
/// Icon and colors representing a category.
|
||||
class CategoryIcon {
|
||||
/// Constructor.
|
||||
const CategoryIcon({
|
||||
required this.icon,
|
||||
this.color = const Color.fromRGBO(211, 211, 211, 1),
|
||||
this.selectedColor = const Color.fromRGBO(178, 178, 178, 1),
|
||||
});
|
||||
|
||||
/// Category icon.
|
||||
final IconData icon;
|
||||
|
||||
/// Default icon color.
|
||||
final Color color;
|
||||
|
||||
/// Selected icon color.
|
||||
final Color selectedColor;
|
||||
}
|
||||
|
||||
/// Icons shown for each emoji category.
|
||||
class CategoryIcons {
|
||||
/// Constructor.
|
||||
const CategoryIcons({
|
||||
this.recentIcon = Icons.access_time,
|
||||
this.smileyIcon = Icons.tag_faces,
|
||||
this.animalIcon = Icons.pets,
|
||||
this.foodIcon = Icons.fastfood,
|
||||
this.activityIcon = Icons.directions_run,
|
||||
this.travelIcon = Icons.location_city,
|
||||
this.objectIcon = Icons.lightbulb_outline,
|
||||
this.symbolIcon = Icons.emoji_symbols,
|
||||
this.flagIcon = Icons.flag,
|
||||
});
|
||||
|
||||
final IconData recentIcon;
|
||||
final IconData smileyIcon;
|
||||
final IconData animalIcon;
|
||||
final IconData foodIcon;
|
||||
final IconData activityIcon;
|
||||
final IconData travelIcon;
|
||||
final IconData objectIcon;
|
||||
final IconData symbolIcon;
|
||||
final IconData flagIcon;
|
||||
}
|
||||
|
||||
/// Behavior of the recent category.
|
||||
enum RecentTabBehavior {
|
||||
/// Do not show the recent category.
|
||||
NONE,
|
||||
|
||||
/// Show the most recently used emoji first.
|
||||
RECENT,
|
||||
|
||||
/// Show the most frequently used emoji first.
|
||||
POPULAR,
|
||||
}
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
/*
|
||||
* Modified version of https://github.com/Fintasys/emoji_picker_flutter
|
||||
* MIT License
|
||||
* Copyright (c) 2024 Stefan Humm
|
||||
*/
|
||||
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker.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, {
|
||||
super.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;
|
||||
}
|
||||
}
|
||||
|
||||
/// Template class for custom implementation
|
||||
/// Inhert this class to create your own category view state
|
||||
class CategoryViewState<T extends CategoryView> extends State<T>
|
||||
with SkinToneOverlayStateMixin {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
throw UnimplementedError('Category View implementation missing');
|
||||
}
|
||||
}
|
||||
|
||||
/// Default category view.
|
||||
class DefaultCategoryView extends CategoryView {
|
||||
/// Constructor.
|
||||
const DefaultCategoryView(
|
||||
super.config,
|
||||
super.state,
|
||||
super.tabController,
|
||||
super.pageController, {
|
||||
this.onCategorySelected,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Callback used to jump inside the continuous emoji list.
|
||||
final ValueChanged<int>? onCategorySelected;
|
||||
|
||||
@override
|
||||
DefaultCategoryViewState createState() => DefaultCategoryViewState();
|
||||
}
|
||||
|
||||
/// State for the default category view.
|
||||
class DefaultCategoryViewState extends CategoryViewState<DefaultCategoryView> {
|
||||
@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,
|
||||
onCategorySelected: widget.onCategorySelected,
|
||||
),
|
||||
),
|
||||
_buildExtraTab(widget.config.categoryViewConfig.extraTab),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildExtraTab(CategoryExtraTab? extraTab) {
|
||||
if (extraTab == CategoryExtraTab.BACKSPACE) {
|
||||
return BackspaceButton(
|
||||
widget.config,
|
||||
widget.state.onBackspacePressed,
|
||||
widget.state.onBackspaceLongPressed,
|
||||
widget.config.categoryViewConfig.backspaceColor,
|
||||
);
|
||||
}
|
||||
if (extraTab == CategoryExtraTab.SEARCH) {
|
||||
return SearchButton(
|
||||
widget.config,
|
||||
widget.state.onShowSearchView,
|
||||
widget.config.categoryViewConfig.iconColor,
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
/// Category navigation for the continuous emoji list.
|
||||
class DefaultCategoryTabBar extends StatelessWidget {
|
||||
/// Constructor.
|
||||
const DefaultCategoryTabBar(
|
||||
this.config,
|
||||
this.tabController,
|
||||
this.pageController,
|
||||
this.categoryEmojis,
|
||||
this.closeSkinToneOverlay, {
|
||||
this.onCategorySelected,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Config config;
|
||||
final TabController tabController;
|
||||
final PageController pageController;
|
||||
final List<CategoryEmoji> categoryEmojis;
|
||||
final VoidCallback closeSkinToneOverlay;
|
||||
|
||||
/// Callback used by continuous emoji lists to navigate to a category.
|
||||
final ValueChanged<int>? onCategorySelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: config.categoryViewConfig.tabBarHeight,
|
||||
child: TabBar(
|
||||
labelColor: config.categoryViewConfig.iconColorSelected,
|
||||
unselectedLabelColor: config.categoryViewConfig.iconColor,
|
||||
dividerColor: Colors.transparent,
|
||||
controller: tabController,
|
||||
labelPadding: EdgeInsets.zero,
|
||||
indicatorSize: TabBarIndicatorSize.tab,
|
||||
indicatorPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 6,
|
||||
),
|
||||
indicator: BoxDecoration(
|
||||
color: config.categoryViewConfig.indicatorColor.withValues(
|
||||
alpha: 0.14,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
onTap: (index) {
|
||||
closeSkinToneOverlay();
|
||||
if (onCategorySelected != null) {
|
||||
onCategorySelected!(index);
|
||||
} else {
|
||||
pageController.jumpToPage(index);
|
||||
}
|
||||
},
|
||||
tabs: categoryEmojis
|
||||
.asMap()
|
||||
.entries
|
||||
.map<Widget>(
|
||||
(item) => Tab(
|
||||
icon: Icon(
|
||||
getIconForCategory(
|
||||
config.categoryViewConfig.categoryIcons,
|
||||
item.value.category,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
/*
|
||||
* Modified version of https://github.com/Fintasys/emoji_picker_flutter
|
||||
* MIT License
|
||||
* Copyright (c) 2024 Stefan Humm
|
||||
*/
|
||||
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker.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.extraTab = CategoryExtraTab.NONE,
|
||||
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;
|
||||
|
||||
/// Extra tab button in category tab bar
|
||||
final CategoryExtraTab? extraTab;
|
||||
|
||||
/// 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, use Colors.transparent to remove
|
||||
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.extraTab == extraTab &&
|
||||
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 ^
|
||||
extraTab.hashCode ^
|
||||
backgroundColor.hashCode ^
|
||||
indicatorColor.hashCode ^
|
||||
iconColor.hashCode ^
|
||||
iconColorSelected.hashCode ^
|
||||
backspaceColor.hashCode ^
|
||||
dividerColor.hashCode ^
|
||||
categoryIcons.hashCode;
|
||||
}
|
||||
230
lib/src/visual/components/emoji_picker/config.dart
Normal file
230
lib/src/visual/components/emoji_picker/config.dart
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
/*
|
||||
* Modified version of https://github.com/Fintasys/emoji_picker_flutter
|
||||
* MIT License
|
||||
* Copyright (c) 2024 Stefan Humm
|
||||
*/
|
||||
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker.dart';
|
||||
import 'package:twonly/src/visual/components/emoji_picker/locales/default_emoji_set_locale.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Number of skin tone icons
|
||||
const kSkinToneCount = 6;
|
||||
|
||||
/// Config for customizations
|
||||
class Config {
|
||||
/// Constructor
|
||||
const Config({
|
||||
this.height = 256,
|
||||
this.checkPlatformCompatibility = true,
|
||||
this.emojiSet = getDefaultEmojiLocale,
|
||||
this.locale = const Locale('en'),
|
||||
this.emojiTextStyle,
|
||||
this.customBackspaceIcon,
|
||||
this.customSearchIcon,
|
||||
this.viewOrderConfig = const ViewOrderConfig(),
|
||||
this.emojiViewConfig = const EmojiViewConfig(),
|
||||
this.skinToneConfig = const SkinToneConfig(),
|
||||
this.categoryViewConfig = const CategoryViewConfig(),
|
||||
this.bottomActionBarConfig = const BottomActionBarConfig(),
|
||||
this.searchViewConfig = const SearchViewConfig(),
|
||||
this.resizeConfig = const ResizeConfig(),
|
||||
});
|
||||
|
||||
/// Initial height of the emoji view
|
||||
/// If explicitly set to null, the emoji view will not be constrained by
|
||||
/// height
|
||||
final double? height;
|
||||
|
||||
/// Verify that emoji glyph is supported by the platform (Android only)
|
||||
final bool checkPlatformCompatibility;
|
||||
|
||||
/// Useful to provide a customized list of Emoji or add/remove the support
|
||||
/// for specific locales (create similar method as in
|
||||
/// default_emoji_set_locale.dart).
|
||||
/// If not provided, the default emoji set will be used based on the
|
||||
/// locales that are available in the package.
|
||||
final List<CategoryEmoji> Function(Locale locale)? emojiSet;
|
||||
|
||||
/// Locale to choose the fitting language for the emoji set
|
||||
/// This will affect the emoji search results
|
||||
final Locale locale;
|
||||
|
||||
/// 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
|
||||
/// 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;
|
||||
|
||||
/// Custom backspace icon
|
||||
final Icon? customBackspaceIcon;
|
||||
|
||||
/// Custom search icon
|
||||
final Icon? customSearchIcon;
|
||||
|
||||
/// Config the order of the views displayed in the UI
|
||||
/// (category bar, emoji view, search bar)
|
||||
final ViewOrderConfig viewOrderConfig;
|
||||
|
||||
/// Emoji view config
|
||||
final EmojiViewConfig emojiViewConfig;
|
||||
|
||||
/// Skin tone config
|
||||
final SkinToneConfig skinToneConfig;
|
||||
|
||||
/// Category view config
|
||||
final CategoryViewConfig categoryViewConfig;
|
||||
|
||||
/// Search bar config
|
||||
final BottomActionBarConfig bottomActionBarConfig;
|
||||
|
||||
/// Search View config
|
||||
final SearchViewConfig searchViewConfig;
|
||||
|
||||
/// Vertical resize behavior and drag handle appearance
|
||||
final ResizeConfig resizeConfig;
|
||||
|
||||
@override
|
||||
bool operator ==(other) {
|
||||
return (other is Config) &&
|
||||
other.height == height &&
|
||||
other.viewOrderConfig == viewOrderConfig &&
|
||||
other.checkPlatformCompatibility == checkPlatformCompatibility &&
|
||||
other.emojiSet == emojiSet &&
|
||||
other.locale == locale &&
|
||||
other.emojiTextStyle == emojiTextStyle &&
|
||||
other.customBackspaceIcon == customBackspaceIcon &&
|
||||
other.customSearchIcon == customSearchIcon &&
|
||||
other.categoryViewConfig == categoryViewConfig &&
|
||||
other.emojiViewConfig == emojiViewConfig &&
|
||||
other.skinToneConfig == skinToneConfig &&
|
||||
other.bottomActionBarConfig == bottomActionBarConfig &&
|
||||
other.searchViewConfig == searchViewConfig &&
|
||||
other.resizeConfig == resizeConfig;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
(height?.hashCode ?? 0) ^
|
||||
viewOrderConfig.hashCode ^
|
||||
checkPlatformCompatibility.hashCode ^
|
||||
emojiSet.hashCode ^
|
||||
locale.hashCode ^
|
||||
(emojiTextStyle?.hashCode ?? 0) ^
|
||||
customBackspaceIcon.hashCode ^
|
||||
customSearchIcon.hashCode ^
|
||||
categoryViewConfig.hashCode ^
|
||||
emojiViewConfig.hashCode ^
|
||||
skinToneConfig.hashCode ^
|
||||
bottomActionBarConfig.hashCode ^
|
||||
searchViewConfig.hashCode ^
|
||||
resizeConfig.hashCode;
|
||||
}
|
||||
|
||||
/// Controls the order of the picker sections.
|
||||
class ViewOrderConfig {
|
||||
/// Constructor.
|
||||
const ViewOrderConfig({
|
||||
this.top = EmojiPickerItem.searchBar,
|
||||
this.middle = EmojiPickerItem.emojiView,
|
||||
this.bottom = EmojiPickerItem.categoryBar,
|
||||
}) : assert(
|
||||
!identical(top, middle) &&
|
||||
!identical(top, bottom) &&
|
||||
!identical(middle, bottom),
|
||||
);
|
||||
|
||||
final EmojiPickerItem top;
|
||||
final EmojiPickerItem middle;
|
||||
final EmojiPickerItem bottom;
|
||||
|
||||
@override
|
||||
bool operator ==(other) {
|
||||
return other is ViewOrderConfig &&
|
||||
other.top == top &&
|
||||
other.middle == middle &&
|
||||
other.bottom == bottom;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(top, middle, bottom);
|
||||
}
|
||||
|
||||
/// Sections shown in the emoji picker.
|
||||
enum EmojiPickerItem {
|
||||
categoryBar,
|
||||
emojiView,
|
||||
searchBar,
|
||||
}
|
||||
|
||||
/// Configuration for resizing the emoji picker vertically.
|
||||
class ResizeConfig {
|
||||
/// Constructor.
|
||||
const ResizeConfig({
|
||||
this.enabled = true,
|
||||
this.showDragHandle = true,
|
||||
this.maxHeight,
|
||||
this.maxHeightFactor = 0.85,
|
||||
this.handleColor,
|
||||
this.handleWidth = 60,
|
||||
this.handleHeight = 3,
|
||||
this.handleHitAreaHeight = 23,
|
||||
this.handleBorderRadius = 32,
|
||||
this.topBorderRadius = 18,
|
||||
}) : assert(maxHeightFactor > 0 && maxHeightFactor <= 1),
|
||||
assert(handleWidth > 0),
|
||||
assert(handleHeight > 0),
|
||||
assert(handleHitAreaHeight >= handleHeight),
|
||||
assert(handleBorderRadius >= 0),
|
||||
assert(topBorderRadius >= 0);
|
||||
|
||||
final bool enabled;
|
||||
|
||||
/// Whether the drag indicator and its resize gesture are shown.
|
||||
final bool showDragHandle;
|
||||
|
||||
final double? maxHeight;
|
||||
final double maxHeightFactor;
|
||||
|
||||
/// Defaults to the current theme's outline color.
|
||||
final Color? handleColor;
|
||||
|
||||
final double handleWidth;
|
||||
final double handleHeight;
|
||||
final double handleHitAreaHeight;
|
||||
final double handleBorderRadius;
|
||||
final double topBorderRadius;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is ResizeConfig &&
|
||||
other.enabled == enabled &&
|
||||
other.showDragHandle == showDragHandle &&
|
||||
other.maxHeight == maxHeight &&
|
||||
other.maxHeightFactor == maxHeightFactor &&
|
||||
other.handleColor == handleColor &&
|
||||
other.handleWidth == handleWidth &&
|
||||
other.handleHeight == handleHeight &&
|
||||
other.handleHitAreaHeight == handleHitAreaHeight &&
|
||||
other.handleBorderRadius == handleBorderRadius &&
|
||||
other.topBorderRadius == topBorderRadius;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
enabled,
|
||||
showDragHandle,
|
||||
maxHeight,
|
||||
maxHeightFactor,
|
||||
handleColor,
|
||||
handleWidth,
|
||||
handleHeight,
|
||||
handleHitAreaHeight,
|
||||
handleBorderRadius,
|
||||
topBorderRadius,
|
||||
);
|
||||
}
|
||||
1899
lib/src/visual/components/emoji_picker/default_emoji_set.dart
Normal file
1899
lib/src/visual/components/emoji_picker/default_emoji_set.dart
Normal file
File diff suppressed because it is too large
Load diff
81
lib/src/visual/components/emoji_picker/emoji.dart
Normal file
81
lib/src/visual/components/emoji_picker/emoji.dart
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/*
|
||||
* Modified version of https://github.com/Fintasys/emoji_picker_flutter
|
||||
* MIT License
|
||||
* Copyright (c) 2024 Stefan Humm
|
||||
*/
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Delimiter for keywords
|
||||
const _keywordDelimiter = ' | ';
|
||||
|
||||
/// A class to store data for each individual emoji
|
||||
@immutable
|
||||
class Emoji {
|
||||
/// Emoji constructor
|
||||
const Emoji(this.emoji, this.name, {this.hasSkinTone = false});
|
||||
|
||||
/// The unicode string for this emoji
|
||||
///
|
||||
/// This is the string that should be displayed to view the emoji
|
||||
final String emoji;
|
||||
|
||||
/// The name or description for this emoji
|
||||
final String name;
|
||||
|
||||
/// Flag if emoji supports multiple skin tones
|
||||
final bool hasSkinTone;
|
||||
|
||||
/// List of keywords that describe the emoji
|
||||
List<String> get keywords => name.split(_keywordDelimiter);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Emoji: $emoji, Name: $name, HasSkinTone: $hasSkinTone';
|
||||
}
|
||||
|
||||
/// Parse Emoji from json
|
||||
static Emoji fromJson(Map<String, dynamic> json) {
|
||||
return Emoji(
|
||||
json['emoji'] as String,
|
||||
json['name'] as String,
|
||||
hasSkinTone: json['hasSkinTone'] != null
|
||||
? json['hasSkinTone'] as bool
|
||||
: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Encode Emoji to json
|
||||
Map<String, dynamic> toJson() {
|
||||
return {'emoji': emoji, 'name': name, 'hasSkinTone': hasSkinTone};
|
||||
}
|
||||
|
||||
/// Copy method
|
||||
Emoji copyWith({String? name, String? emoji, bool? hasSkinTone}) {
|
||||
return Emoji(
|
||||
emoji ?? this.emoji,
|
||||
name ?? this.name,
|
||||
hasSkinTone: hasSkinTone ?? this.hasSkinTone,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An emoji stored in the recent list together with its use count.
|
||||
class RecentEmoji {
|
||||
/// Constructor.
|
||||
RecentEmoji(this.emoji, this.counter);
|
||||
|
||||
final Emoji emoji;
|
||||
int counter;
|
||||
|
||||
/// Parses a recent emoji from JSON.
|
||||
static RecentEmoji fromJson(dynamic json) {
|
||||
return RecentEmoji(
|
||||
Emoji.fromJson(json['emoji'] as Map<String, dynamic>),
|
||||
json['counter'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
/// Encodes this recent emoji as JSON.
|
||||
Map<String, dynamic> toJson() => {'emoji': emoji, 'counter': counter};
|
||||
}
|
||||
28
lib/src/visual/components/emoji_picker/emoji_picker.dart
Normal file
28
lib/src/visual/components/emoji_picker/emoji_picker.dart
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Modified version of https://github.com/Fintasys/emoji_picker_flutter
|
||||
* MIT License
|
||||
* Copyright (c) 2024 Stefan Humm
|
||||
*/
|
||||
|
||||
export 'locales/emoji_set.dart';
|
||||
export 'bottom_action_bar.dart';
|
||||
export 'category_view/category.dart';
|
||||
export 'category_view/category_view.dart';
|
||||
export 'category_view/category_view_config.dart';
|
||||
export 'config.dart';
|
||||
export 'default_emoji_set.dart';
|
||||
export 'emoji.dart';
|
||||
export 'emoji_picker_controller.dart';
|
||||
export 'emoji_picker_utils.dart';
|
||||
export 'emoji_picker_widget.dart';
|
||||
export 'emoji_text_editing_controller.dart';
|
||||
export 'emoji_view/default_emoji_picker_view.dart';
|
||||
export 'emoji_view/emoji_picker_view.dart';
|
||||
export 'emoji_view/emoji_view_config.dart';
|
||||
export 'search_view.dart';
|
||||
export 'skin_tones/emoji_skin_tones.dart';
|
||||
export 'skin_tones/skin_tone_config.dart';
|
||||
export 'skin_tones/skin_tone_overlay.dart';
|
||||
export 'skin_tones/triangle_decoration.dart';
|
||||
export 'widgets/backspace_button.dart';
|
||||
export 'widgets/emoji_cell.dart';
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
/*
|
||||
* Modified version of https://github.com/Fintasys/emoji_picker_flutter
|
||||
* MIT License
|
||||
* Copyright (c) 2024 Stefan Humm
|
||||
*/
|
||||
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker.dart'
|
||||
show Category;
|
||||
import 'package:flutter/foundation.dart' hide Category;
|
||||
|
||||
/// Controller for EmojiPicker widget that allows reading and changing
|
||||
/// the selected category programmatically.
|
||||
///
|
||||
/// Similar to [TextEditingController], this controller provides:
|
||||
/// - Reading the current selected category
|
||||
/// - Setting the category programmatically
|
||||
/// - Listening to category changes
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```dart
|
||||
/// final controller = EmojiPickerController();
|
||||
///
|
||||
/// // Listen to changes
|
||||
/// controller.addListener(() {
|
||||
/// print('Category changed to: ${controller.currentCategory}');
|
||||
/// });
|
||||
///
|
||||
/// // Set category programmatically
|
||||
/// controller.setCategory(Category.SMILEYS);
|
||||
///
|
||||
/// // Use with EmojiPicker
|
||||
/// EmojiPicker(
|
||||
/// controller: controller,
|
||||
/// // ... other config
|
||||
/// )
|
||||
/// ```
|
||||
class EmojiPickerController extends ChangeNotifier {
|
||||
/// Creates an EmojiPickerController with an optional initial category.
|
||||
/// Defaults to [Category.RECENT] if not specified.
|
||||
EmojiPickerController({Category initialCategory = Category.RECENT})
|
||||
: _currentCategory = initialCategory;
|
||||
|
||||
Category _currentCategory;
|
||||
|
||||
/// The currently selected category.
|
||||
Category get currentCategory => _currentCategory;
|
||||
|
||||
/// Sets the selected category and notifies listeners.
|
||||
///
|
||||
/// This will cause the emoji picker to navigate to the specified category
|
||||
/// if it's currently being used by an EmojiPicker widget.
|
||||
void setCategory(Category category) {
|
||||
if (_currentCategory != category) {
|
||||
_currentCategory = category;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal method used by EmojiPicker to update the controller
|
||||
/// when the user manually changes categories.
|
||||
///
|
||||
/// This should not be called directly by user code.
|
||||
void updateCategory(Category category) {
|
||||
if (_currentCategory != category) {
|
||||
_currentCategory = category;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a copy of this controller with the same current category.
|
||||
EmojiPickerController copyWith({Category? category}) {
|
||||
return EmojiPickerController(initialCategory: category ?? _currentCategory);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'EmojiPickerController(currentCategory: $_currentCategory)';
|
||||
}
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
/*
|
||||
* Modified version of https://github.com/Fintasys/emoji_picker_flutter
|
||||
* MIT License
|
||||
* Copyright (c) 2024 Stefan Humm
|
||||
*/
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker.dart';
|
||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Initial value for RecentEmoji
|
||||
const initVal = 1;
|
||||
|
||||
/// Helper class that provides internal usage
|
||||
class EmojiPickerInternalUtils {
|
||||
// Establish communication with native
|
||||
static const _platform = MethodChannel('eu.twonly/emoji_picker');
|
||||
|
||||
static final RegExp _skinToneRegExp = RegExp(SkinTone.values.join('|'));
|
||||
|
||||
/// Caches, per [Category], whether each emoji glyph is supported on the
|
||||
/// platform. Keyed by the emoji string (not the [CategoryEmoji]) so the
|
||||
/// cache stays correct across locales and custom emoji sets, which change
|
||||
/// an emoji's name/keywords but never the glyph or its platform support.
|
||||
static final Map<Category, Map<String, bool>> _emojiSupport = {};
|
||||
|
||||
/// Caches the recently used emojis so that [getRecentEmojis] avoids
|
||||
/// re-reading [SharedPreferences] on subsequent calls. Kept in sync by the
|
||||
/// add/clear methods below.
|
||||
static List<RecentEmoji>? _recentEmojis;
|
||||
|
||||
// Query the native side for which of the given glyphs are supported and
|
||||
// merge the result into the per-category support cache.
|
||||
Future<void> _cacheSupport(CategoryEmoji category, List<Emoji> query) async {
|
||||
final available = (await _platform.invokeListMethod<bool>(
|
||||
'getSupportedEmojis',
|
||||
{'source': query.map((e) => e.emoji).toList(growable: false)},
|
||||
))!;
|
||||
|
||||
final support = _emojiSupport.putIfAbsent(category.category, () => {});
|
||||
for (var i = 0; i < query.length; i++) {
|
||||
support[query[i].emoji] = available[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Filter a category down to the glyphs known to be supported. Every glyph is
|
||||
// expected to be present in the cache by the time this is called.
|
||||
CategoryEmoji _applySupport(CategoryEmoji category) {
|
||||
final support = _emojiSupport[category.category];
|
||||
return category.copyWith(
|
||||
emoji: category.emoji.where((e) => support?[e.emoji] ?? false).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Filters out emojis not supported on the platform
|
||||
///
|
||||
/// Returns synchronously when the support of every requested glyph is
|
||||
/// already cached, so a rebuilt [EmojiPicker] does not have to hit the
|
||||
/// native side again. Glyphs whose support is not yet known (e.g. after a
|
||||
/// locale switch that introduces new glyphs, or a custom emoji set) are
|
||||
/// queried and merged into the cache.
|
||||
FutureOr<List<CategoryEmoji>> filterUnsupported(List<CategoryEmoji> data) {
|
||||
if (!Platform.isAndroid) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Collect, per category, the glyphs whose support is not yet cached.
|
||||
final pending = <CategoryEmoji, List<Emoji>>{};
|
||||
for (final cat in data) {
|
||||
final support = _emojiSupport[cat.category];
|
||||
final unknown = support == null
|
||||
? cat.emoji
|
||||
: cat.emoji.where((e) => !support.containsKey(e.emoji)).toList();
|
||||
if (unknown.isNotEmpty) {
|
||||
pending[cat] = unknown;
|
||||
}
|
||||
}
|
||||
|
||||
if (pending.isEmpty) {
|
||||
return [for (final cat in data) _applySupport(cat)];
|
||||
}
|
||||
|
||||
return Future(() async {
|
||||
await Future.wait([
|
||||
for (final entry in pending.entries)
|
||||
_cacheSupport(entry.key, entry.value),
|
||||
]);
|
||||
return [for (final cat in data) _applySupport(cat)];
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns list of recently used emoji from cache
|
||||
///
|
||||
/// Reads from [SharedPreferences] on the first call and reuses the cached
|
||||
/// result afterwards. The cache is kept in sync by [addEmojiToRecentlyUsed],
|
||||
/// [addEmojiToPopularUsed] and [clearRecentEmojisInLocalStorage].
|
||||
FutureOr<List<RecentEmoji>> getRecentEmojis() {
|
||||
if (_recentEmojis != null) {
|
||||
return _recentEmojis!.toList();
|
||||
}
|
||||
|
||||
return Future(() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
var emojiJson = prefs.getString('recent');
|
||||
if (emojiJson == null) {
|
||||
return _recentEmojis = [];
|
||||
}
|
||||
var json = jsonDecode(emojiJson) as List<dynamic>;
|
||||
return _recentEmojis = json
|
||||
.map<RecentEmoji>(RecentEmoji.fromJson)
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
|
||||
/// Add an emoji to recently used list
|
||||
Future<List<RecentEmoji>> addEmojiToRecentlyUsed({
|
||||
required Emoji emoji,
|
||||
Config config = const Config(),
|
||||
}) async {
|
||||
// Remove emoji's skin tone in Recent-Category
|
||||
if (emoji.hasSkinTone) {
|
||||
emoji = removeSkinTone(emoji);
|
||||
}
|
||||
var recentEmoji = await getRecentEmojis();
|
||||
var recentEmojiIndex = recentEmoji.indexWhere(
|
||||
(element) => element.emoji.emoji == emoji.emoji,
|
||||
);
|
||||
if (recentEmojiIndex != -1) {
|
||||
// Already exist in recent list
|
||||
// Remove it
|
||||
recentEmoji.removeAt(recentEmojiIndex);
|
||||
}
|
||||
// Add it first position
|
||||
recentEmoji.insert(0, RecentEmoji(emoji, initVal));
|
||||
|
||||
// Limit entries to recentsLimit
|
||||
recentEmoji = recentEmoji.sublist(
|
||||
0,
|
||||
min(config.emojiViewConfig.recentsLimit, recentEmoji.length),
|
||||
);
|
||||
|
||||
// save locally
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
prefs.setString('recent', jsonEncode(recentEmoji));
|
||||
|
||||
return _recentEmojis = recentEmoji;
|
||||
}
|
||||
|
||||
/// Add an emoji to popular used list or increase its counter
|
||||
Future<List<RecentEmoji>> addEmojiToPopularUsed({
|
||||
required Emoji emoji,
|
||||
Config config = const Config(),
|
||||
}) async {
|
||||
// Remove emoji's skin tone in Recent-Category
|
||||
if (emoji.hasSkinTone) {
|
||||
emoji = removeSkinTone(emoji);
|
||||
}
|
||||
var recentEmoji = await getRecentEmojis();
|
||||
var recentEmojiIndex = recentEmoji.indexWhere(
|
||||
(element) => element.emoji.emoji == emoji.emoji,
|
||||
);
|
||||
if (recentEmojiIndex != -1) {
|
||||
// Already exist in recent list
|
||||
// Just update counter
|
||||
recentEmoji[recentEmojiIndex].counter++;
|
||||
} 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 {
|
||||
recentEmoji.add(RecentEmoji(emoji, initVal));
|
||||
}
|
||||
|
||||
// Sort by counter desc
|
||||
recentEmoji.sort((a, b) => b.counter - a.counter);
|
||||
|
||||
// Limit entries to recentsLimit
|
||||
recentEmoji = recentEmoji.sublist(
|
||||
0,
|
||||
min(config.emojiViewConfig.recentsLimit, recentEmoji.length),
|
||||
);
|
||||
|
||||
// save locally
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
prefs.setString('recent', jsonEncode(recentEmoji));
|
||||
|
||||
return _recentEmojis = recentEmoji;
|
||||
}
|
||||
|
||||
/// Clears the list of recent emojis in local storage
|
||||
Future<void> clearRecentEmojisInLocalStorage() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
prefs.setString('recent', jsonEncode([]));
|
||||
_recentEmojis = [];
|
||||
}
|
||||
|
||||
/// Returns the last remembered skin tone modifier, or `null` if none stored
|
||||
Future<String?> getRememberedSkinTone() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString('skin_tone');
|
||||
}
|
||||
|
||||
/// Persists the remembered skin tone modifier. Passing `null` clears it.
|
||||
Future<void> setRememberedSkinTone(String? skinTone) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (skinTone == null) {
|
||||
await prefs.remove('skin_tone');
|
||||
} else {
|
||||
await prefs.setString('skin_tone', skinTone);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove skin tone from given emoji
|
||||
Emoji removeSkinTone(Emoji emoji) {
|
||||
return emoji.copyWith(emoji: emoji.emoji.replaceFirst(_skinToneRegExp, ''));
|
||||
}
|
||||
|
||||
/// Clears the in-memory caches. The caches are `static`, so they otherwise
|
||||
/// persist for the lifetime of the isolate and can leak state between test
|
||||
/// cases. Call this in `setUp`/`tearDown` to keep tests isolated.
|
||||
@visibleForTesting
|
||||
static void resetCaches() {
|
||||
_emojiSupport.clear();
|
||||
_recentEmojis = null;
|
||||
}
|
||||
}
|
||||
259
lib/src/visual/components/emoji_picker/emoji_picker_utils.dart
Normal file
259
lib/src/visual/components/emoji_picker/emoji_picker_utils.dart
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
/*
|
||||
* Modified version of https://github.com/Fintasys/emoji_picker_flutter
|
||||
* MIT License
|
||||
* Copyright (c) 2024 Stefan Humm
|
||||
*/
|
||||
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker.dart';
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker_internal_utils.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
|
||||
factory EmojiPickerUtils() {
|
||||
return _singleton;
|
||||
}
|
||||
|
||||
EmojiPickerUtils._internal();
|
||||
|
||||
static final EmojiPickerUtils _singleton = EmojiPickerUtils._internal();
|
||||
static final RegExp _whitespaceRegExp = RegExp(r'\s+');
|
||||
final List<Emoji> _allAvailableEmojiEntities = [];
|
||||
RegExp? _emojiRegExp;
|
||||
|
||||
/// Returns list of recently used emoji from cache
|
||||
Future<List<RecentEmoji>> getRecentEmojis() =>
|
||||
Future.value(EmojiPickerInternalUtils().getRecentEmojis());
|
||||
|
||||
/// Filters out emojis not supported on the platform
|
||||
Future<List<CategoryEmoji>> filterUnsupported(List<CategoryEmoji> data) =>
|
||||
Future.value(EmojiPickerInternalUtils().filterUnsupported(data));
|
||||
|
||||
/// Search for related emoticons based on keywords
|
||||
Future<List<Emoji>> searchEmoji(
|
||||
String search,
|
||||
List<CategoryEmoji> emojiSet, {
|
||||
bool checkPlatformCompatibility = true,
|
||||
}) async {
|
||||
if (search.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;
|
||||
|
||||
// Set all the emoji entities
|
||||
for (var emojis in availableCategoryEmoji) {
|
||||
_allAvailableEmojiEntities.addAll(emojis.emoji);
|
||||
}
|
||||
}
|
||||
|
||||
// Split the input string into a list of lowercase keywords
|
||||
final keywordSet = search
|
||||
.split(_whitespaceRegExp)
|
||||
.where((e) => e.isNotEmpty)
|
||||
.map((e) => e.toLowerCase())
|
||||
.toSet();
|
||||
|
||||
if (keywordSet.isEmpty) return [];
|
||||
|
||||
return _allAvailableEmojiEntities.where((emoji) {
|
||||
// Perform lowercasing of emoji keywords once
|
||||
final emojiKeywordSet = emoji.keywords
|
||||
.map((e) => e.toLowerCase())
|
||||
.toSet();
|
||||
|
||||
// Check if first keyword is a prefix of any emoji keyword
|
||||
final matchFirstKeyword = emojiKeywordSet.any(
|
||||
(emojiKeyword) => emojiKeyword.startsWith(keywordSet.first),
|
||||
);
|
||||
|
||||
var matchKeywords = false;
|
||||
if (matchFirstKeyword) {
|
||||
// Check if each search keyword is a prefix of any emoji keyword
|
||||
// start from second keyword, returns true if empty (only 1 keyword)
|
||||
matchKeywords = keywordSet.skip(1).every((keyword) {
|
||||
return emojiKeywordSet.any(
|
||||
(emojiKeyword) => emojiKeyword.startsWith(keyword),
|
||||
);
|
||||
});
|
||||
} else {
|
||||
matchKeywords = false;
|
||||
}
|
||||
|
||||
// Check for an exact match with emoji character
|
||||
final matchEmoji = emoji.emoji == search.trim();
|
||||
|
||||
return matchKeywords || matchEmoji;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Add an emoji to recently used list or increase its counter
|
||||
Future<void> addEmojiToRecentlyUsed({
|
||||
required GlobalKey<EmojiPickerState> key,
|
||||
required Emoji emoji,
|
||||
Config config = const Config(),
|
||||
}) async {
|
||||
return EmojiPickerInternalUtils()
|
||||
.addEmojiToRecentlyUsed(emoji: emoji, config: config)
|
||||
.then(
|
||||
(recentEmojiList) =>
|
||||
key.currentState?.updateRecentEmoji(recentEmojiList),
|
||||
);
|
||||
}
|
||||
|
||||
/// Produce a list of spans to adjust style for emoji characters.
|
||||
/// 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<InlineSpan> setEmojiTextStyle(
|
||||
String text, {
|
||||
required TextStyle emojiStyle,
|
||||
TextStyle? parentStyle,
|
||||
}) {
|
||||
final composedEmojiStyle = (parentStyle ?? const TextStyle())
|
||||
.merge(DefaultEmojiTextStyle)
|
||||
.merge(emojiStyle);
|
||||
|
||||
final spans = <TextSpan>[];
|
||||
final matches = getEmojiRegex().allMatches(text).toList();
|
||||
var cursor = 0;
|
||||
for (final match in matches) {
|
||||
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: 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;
|
||||
}
|
||||
// Add remaining text
|
||||
if (cursor != text.length) {
|
||||
spans.add(
|
||||
TextSpan(text: text.substring(cursor, text.length), style: parentStyle),
|
||||
);
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
/// Applies skin tone to given emoji
|
||||
///
|
||||
/// Any existing skin tone modifier is stripped first, so re-applying a tone
|
||||
/// to an already toned glyph produces a valid single-modifier sequence
|
||||
/// instead of an invalid double-modifier one (e.g. 👋🏻🏽).
|
||||
Emoji applySkinTone(Emoji emoji, String color) {
|
||||
final codeUnits = removeSkinTone(emoji).emoji.codeUnits;
|
||||
var result = List<int>.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));
|
||||
}
|
||||
return emoji.copyWith(emoji: String.fromCharCodes(result));
|
||||
}
|
||||
|
||||
/// Removes any skin tone modifier from the given emoji
|
||||
Emoji removeSkinTone(Emoji emoji) =>
|
||||
EmojiPickerInternalUtils().removeSkinTone(emoji);
|
||||
|
||||
/// Returns the emoji that should be displayed (and selected) in the grid,
|
||||
/// recents and search results.
|
||||
///
|
||||
/// When [skinToneConfig] remembers a tone and [rememberedSkinTone] is set,
|
||||
/// the toned glyph is returned; otherwise the original emoji is returned
|
||||
/// unchanged. The result keeps [Emoji.hasSkinTone] intact so the indicator
|
||||
/// and long-press picker keep working on the cell.
|
||||
Emoji applyDisplaySkinTone(
|
||||
Emoji emoji,
|
||||
SkinToneConfig skinToneConfig,
|
||||
String? rememberedSkinTone,
|
||||
) {
|
||||
if (!skinToneConfig.enabled ||
|
||||
!skinToneConfig.rememberSkinTone ||
|
||||
rememberedSkinTone == null ||
|
||||
!emoji.hasSkinTone) {
|
||||
return emoji;
|
||||
}
|
||||
return applySkinTone(emoji, rememberedSkinTone);
|
||||
}
|
||||
|
||||
/// Returns the skin tone modifier contained in [emoji], or `null` when the
|
||||
/// emoji carries no skin tone.
|
||||
String? extractSkinTone(Emoji emoji) {
|
||||
for (final tone in SkinTone.values) {
|
||||
if (emoji.emoji.contains(tone)) {
|
||||
return tone;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Returns the last remembered skin tone modifier, or `null` if none.
|
||||
Future<String?> getRememberedSkinTone() =>
|
||||
EmojiPickerInternalUtils().getRememberedSkinTone();
|
||||
|
||||
/// Persists the remembered skin tone modifier. Passing `null` clears it.
|
||||
Future<void> setRememberedSkinTone(String? skinTone) =>
|
||||
EmojiPickerInternalUtils().setRememberedSkinTone(skinTone);
|
||||
|
||||
/// Clears the list of recent emojis
|
||||
Future<void> clearRecentEmojis({
|
||||
required GlobalKey<EmojiPickerState> key,
|
||||
}) async {
|
||||
return await EmojiPickerInternalUtils()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
656
lib/src/visual/components/emoji_picker/emoji_picker_widget.dart
Normal file
656
lib/src/visual/components/emoji_picker/emoji_picker_widget.dart
Normal file
|
|
@ -0,0 +1,656 @@
|
|||
/*
|
||||
* Modified version of https://github.com/Fintasys/emoji_picker_flutter
|
||||
* MIT License
|
||||
* Copyright (c) 2024 Stefan Humm
|
||||
*/
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:twonly/src/visual/components/emoji_picker/locales/default_emoji_set_locale.dart';
|
||||
import 'package:twonly/src/visual/components/emoji_picker/category_view/category.dart';
|
||||
import 'package:twonly/src/visual/components/emoji_picker/config.dart';
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji.dart';
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker_controller.dart';
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker_internal_utils.dart';
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_view/default_emoji_picker_view.dart';
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_view/emoji_view_config.dart';
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_view/emoji_picker_view.dart';
|
||||
import 'package:twonly/src/visual/components/emoji_picker/search_view.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// All the possible categories that [Emoji] can be put into
|
||||
///
|
||||
/// All [Category] are shown in the category bar
|
||||
enum Category {
|
||||
/// Recent / Popular emojis
|
||||
RECENT,
|
||||
|
||||
/// Smiley emojis
|
||||
SMILEYS,
|
||||
|
||||
/// Animal emojis
|
||||
ANIMALS,
|
||||
|
||||
/// Food emojis
|
||||
FOODS,
|
||||
|
||||
/// Activity emojis
|
||||
ACTIVITIES,
|
||||
|
||||
/// Travel emojis
|
||||
TRAVEL,
|
||||
|
||||
/// Ojects emojis
|
||||
OBJECTS,
|
||||
|
||||
/// Sumbol emojis
|
||||
SYMBOLS,
|
||||
|
||||
/// Flag emojis
|
||||
FLAGS,
|
||||
}
|
||||
|
||||
/// Extension on Category enum to get its name
|
||||
extension CategoryExtension on Category {
|
||||
/// Returns name of Category
|
||||
String get name {
|
||||
switch (this) {
|
||||
case Category.RECENT:
|
||||
return 'recent';
|
||||
case Category.SMILEYS:
|
||||
return 'smileys';
|
||||
case Category.ANIMALS:
|
||||
return 'animals';
|
||||
case Category.FOODS:
|
||||
return 'foods';
|
||||
case Category.ACTIVITIES:
|
||||
return 'activities';
|
||||
case Category.TRAVEL:
|
||||
return 'travel';
|
||||
case Category.OBJECTS:
|
||||
return 'objects';
|
||||
case Category.SYMBOLS:
|
||||
return 'symbols';
|
||||
case Category.FLAGS:
|
||||
return 'flags';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enum to alter the keyboard button style
|
||||
enum ButtonMode {
|
||||
/// No cell touch effects, uses GestureDetector only. Provides best grid
|
||||
/// scrolling performance
|
||||
NONE,
|
||||
|
||||
/// Android button style - gives the button a splash color with ripple effect
|
||||
MATERIAL,
|
||||
|
||||
/// iOS button style - gives the button a fade out effect when pressed
|
||||
CUPERTINO,
|
||||
}
|
||||
|
||||
/// Callback function for when emoji is selected
|
||||
///
|
||||
/// The function returns the selected [Emoji] as well
|
||||
/// as the [Category] from which it originated
|
||||
/// Category can be null in some cases, for example in search results
|
||||
typedef OnEmojiSelected = void Function(Category? category, Emoji emoji);
|
||||
|
||||
/// Callback from emoji cell to show a skin tone selection overlay
|
||||
typedef OnSkinToneDialogRequested =
|
||||
void Function(
|
||||
Offset emojiBoxPosition,
|
||||
Emoji emoji,
|
||||
double emojiSize,
|
||||
CategoryEmoji? categoryEmoji,
|
||||
);
|
||||
|
||||
/// Callback function for backspace button
|
||||
typedef OnBackspacePressed = void Function();
|
||||
|
||||
/// Callback function for backspace button when long pressed
|
||||
typedef OnBackspaceLongPressed = void Function();
|
||||
|
||||
/// Callback function for category tab changed
|
||||
typedef OnCategoryChanged = void Function(Category category);
|
||||
|
||||
/// The Emoji Keyboard widget
|
||||
///
|
||||
/// This widget displays a grid of [Emoji] sorted by [Category]
|
||||
/// which the user can vertically scroll through as one continuous list.
|
||||
///
|
||||
/// There is also a bottombar which displays all the possible [Category]
|
||||
/// and allow the user to quickly switch to that [Category]
|
||||
class EmojiPicker extends StatefulWidget {
|
||||
/// EmojiPicker for flutter
|
||||
const EmojiPicker({
|
||||
super.key,
|
||||
this.textEditingController,
|
||||
this.scrollController,
|
||||
this.controller,
|
||||
this.onEmojiSelected,
|
||||
this.onBackspacePressed,
|
||||
this.onCategoryChanged,
|
||||
this.config = const Config(),
|
||||
this.customWidget,
|
||||
});
|
||||
|
||||
/// Custom widget
|
||||
final EmojiViewBuilder? customWidget;
|
||||
|
||||
/// If you provide the [TextEditingController] that is linked to a
|
||||
/// [TextField] this widget handles inserting and deleting for you
|
||||
/// automatically.
|
||||
final TextEditingController? textEditingController;
|
||||
|
||||
/// If you provide the [ScrollController] that is linked to a
|
||||
/// [TextField] this widget handles auto scrolling for you.
|
||||
final ScrollController? scrollController;
|
||||
|
||||
/// Controller for managing the emoji picker state, including
|
||||
/// the currently selected category tab.
|
||||
///
|
||||
/// If provided, this controller allows you to:
|
||||
/// - Read the current selected category programmatically
|
||||
/// - Change the selected category programmatically
|
||||
/// - Listen to category changes
|
||||
///
|
||||
/// If both [controller] and [onCategoryChanged] are provided,
|
||||
/// the controller takes precedence.
|
||||
final EmojiPickerController? controller;
|
||||
|
||||
/// The function called when the emoji is selected
|
||||
final OnEmojiSelected? onEmojiSelected;
|
||||
|
||||
/// The function called when backspace button is pressed
|
||||
final OnBackspacePressed? onBackspacePressed;
|
||||
|
||||
/// The function called when category tab changes
|
||||
final OnCategoryChanged? onCategoryChanged;
|
||||
|
||||
/// Config for customizations
|
||||
final Config config;
|
||||
|
||||
@override
|
||||
EmojiPickerState createState() => EmojiPickerState();
|
||||
}
|
||||
|
||||
/// EmojiPickerState
|
||||
class EmojiPickerState extends State<EmojiPicker> {
|
||||
final List<CategoryEmoji> _categoryEmoji = List.empty(growable: true);
|
||||
List<RecentEmoji> _recentEmoji = List.empty(growable: true);
|
||||
late EmojiViewState _state;
|
||||
|
||||
// Prevent emojis to be reloaded with every build
|
||||
bool _loaded = false;
|
||||
|
||||
// Display Search bar
|
||||
bool _isSearchBarVisible = false;
|
||||
|
||||
// Current height while the user is resizing the picker.
|
||||
double? _currentPickerHeight;
|
||||
|
||||
// Internal helper
|
||||
final _emojiPickerInternalUtils = EmojiPickerInternalUtils();
|
||||
|
||||
/// Update recentEmoji list from outside using EmojiPickerUtils
|
||||
void updateRecentEmoji(
|
||||
List<RecentEmoji> recentEmoji, {
|
||||
bool refresh = false,
|
||||
}) {
|
||||
_recentEmoji = recentEmoji;
|
||||
// Only mutate the displayed category data when we actually intend to
|
||||
// refresh the UI. `_categoryEmoji` is shared by reference with `_state`,
|
||||
// so mutating it here without a `setState()` would still surface the new
|
||||
// recent list on the next unrelated rebuild triggered by a parent widget
|
||||
// (e.g. the RECENT tab re-ordering under the user). The persisted store is
|
||||
// already updated by the caller, so the change is not lost - it simply
|
||||
// becomes visible on the next explicit refresh instead of leaking early.
|
||||
if (!refresh) {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentPickerHeight = widget.config.height;
|
||||
_updateEmojis();
|
||||
widget.textEditingController?.addListener(_scrollToCursorAfterTextChange);
|
||||
widget.controller?.addListener(_onControllerChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant EmojiPicker oldWidget) {
|
||||
if (oldWidget.config.height != widget.config.height) {
|
||||
_currentPickerHeight = widget.config.height;
|
||||
}
|
||||
if (oldWidget.config != widget.config) {
|
||||
// Config changed - rebuild EmojiPickerView completely
|
||||
_loaded = false;
|
||||
_updateEmojis();
|
||||
}
|
||||
// Handle controller changes
|
||||
if (oldWidget.controller != widget.controller) {
|
||||
oldWidget.controller?.removeListener(_onControllerChanged);
|
||||
widget.controller?.addListener(_onControllerChanged);
|
||||
}
|
||||
// Handle text editing controller changes
|
||||
if (oldWidget.textEditingController != widget.textEditingController) {
|
||||
oldWidget.textEditingController?.removeListener(
|
||||
_scrollToCursorAfterTextChange,
|
||||
);
|
||||
widget.textEditingController?.addListener(_scrollToCursorAfterTextChange);
|
||||
}
|
||||
_resetStateWhenOffstage();
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_loaded) {
|
||||
return widget.config.emojiViewConfig.loadingIndicator;
|
||||
}
|
||||
final content = _isSearchBarVisible ? _buildSearchBar() : _buildEmojiView();
|
||||
return _wrapScrollBehaviorForPlatforms(_buildPickerSurface(content));
|
||||
}
|
||||
|
||||
void _resetStateWhenOffstage() {
|
||||
final offstageParent = context.findAncestorWidgetOfExactType<Offstage>();
|
||||
if (offstageParent?.offstage == true) {
|
||||
_isSearchBarVisible = false;
|
||||
_currentPickerHeight = widget.config.height;
|
||||
}
|
||||
}
|
||||
|
||||
void _onBackspacePressed() {
|
||||
if (widget.textEditingController != null) {
|
||||
final controller = widget.textEditingController!;
|
||||
|
||||
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 (cursorPosition >= 0) {
|
||||
final selection = controller.value.selection;
|
||||
final newTextBeforeCursor = selection
|
||||
.textBefore(text)
|
||||
.characters
|
||||
.skipLast(1)
|
||||
.toString();
|
||||
|
||||
controller.value = controller.value.copyWith(
|
||||
text: newTextBeforeCursor + selection.textAfter(text),
|
||||
selection: TextSelection.fromPosition(
|
||||
TextPosition(offset: newTextBeforeCursor.length),
|
||||
),
|
||||
composing: TextRange.collapsed(newTextBeforeCursor.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
widget.onBackspacePressed?.call();
|
||||
|
||||
if (widget.textEditingController == null) {
|
||||
_scrollToCursorAfterTextChange();
|
||||
}
|
||||
}
|
||||
|
||||
void _onBackspaceLongPressed() {
|
||||
if (widget.textEditingController != null) {
|
||||
final controller = widget.textEditingController!;
|
||||
|
||||
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 (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) {
|
||||
// Trim trailing spaces
|
||||
text = text.trimRight();
|
||||
|
||||
// Find the last space to determine the start of the last word
|
||||
final lastSpaceIndex = text.lastIndexOf(' ');
|
||||
|
||||
// If there is a space, remove the last word and spaces before it
|
||||
if (lastSpaceIndex != -1) {
|
||||
return text.substring(0, lastSpaceIndex).trimRight();
|
||||
}
|
||||
|
||||
// If there is no space, remove the entire text
|
||||
return '';
|
||||
}
|
||||
|
||||
// Add recent emoji handling to tap listener
|
||||
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,
|
||||
),
|
||||
composing: TextRange.collapsed(newText.length),
|
||||
);
|
||||
}
|
||||
|
||||
widget.onEmojiSelected?.call(category, emoji);
|
||||
|
||||
if (widget.textEditingController == null) {
|
||||
_scrollToCursorAfterTextChange();
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize emoji data
|
||||
Future<void> _updateEmojis() async {
|
||||
_categoryEmoji.clear();
|
||||
if ([
|
||||
RecentTabBehavior.RECENT,
|
||||
RecentTabBehavior.POPULAR,
|
||||
].contains(widget.config.categoryViewConfig.recentTabBehavior)) {
|
||||
final futureOrRecent = _emojiPickerInternalUtils.getRecentEmojis();
|
||||
_recentEmoji = futureOrRecent is List<RecentEmoji>
|
||||
? futureOrRecent
|
||||
: await futureOrRecent;
|
||||
final recentEmojiMap = _recentEmoji.map((e) => e.emoji).toList();
|
||||
_categoryEmoji.add(CategoryEmoji(Category.RECENT, recentEmojiMap));
|
||||
}
|
||||
final data =
|
||||
widget.config.emojiSet?.call(widget.config.locale) ??
|
||||
getDefaultEmojiLocale(widget.config.locale);
|
||||
if (widget.config.checkPlatformCompatibility) {
|
||||
final futureOrCategories = _emojiPickerInternalUtils.filterUnsupported(
|
||||
data,
|
||||
);
|
||||
_categoryEmoji.addAll(
|
||||
futureOrCategories is List<CategoryEmoji>
|
||||
? futureOrCategories
|
||||
: await futureOrCategories,
|
||||
);
|
||||
} else {
|
||||
_categoryEmoji.addAll(data);
|
||||
}
|
||||
_state = EmojiViewState(
|
||||
_categoryEmoji,
|
||||
_onEmojiSelected,
|
||||
_onBackspacePressed,
|
||||
_onBackspaceLongPressed,
|
||||
_showSearchView,
|
||||
_handleCategoryChanged,
|
||||
currentCategory: widget.controller?.currentCategory,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loaded = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSearchBar() {
|
||||
return widget.config.searchViewConfig.customSearchView == null
|
||||
? DefaultSearchView(widget.config, _state, _hideSearchView)
|
||||
: widget.config.searchViewConfig.customSearchView!(
|
||||
widget.config,
|
||||
_state,
|
||||
_hideSearchView,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _wrapScrollBehaviorForPlatforms(Widget child) {
|
||||
return Platform.isLinux
|
||||
? ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(
|
||||
context,
|
||||
).copyWith(scrollbars: false),
|
||||
child: child,
|
||||
)
|
||||
: child;
|
||||
}
|
||||
|
||||
Widget _buildEmojiView() {
|
||||
return widget.customWidget == null
|
||||
? DefaultEmojiPickerView(widget.config, _state, _showSearchView)
|
||||
: widget.customWidget!(widget.config, _state, _showSearchView);
|
||||
}
|
||||
|
||||
Widget _buildPickerSurface(Widget content) {
|
||||
final initialHeight = widget.config.height;
|
||||
if (initialHeight == null) {
|
||||
return content;
|
||||
}
|
||||
if (!widget.config.resizeConfig.enabled) {
|
||||
return SizedBox(height: initialHeight, child: content);
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
key: const Key('emojiPickerResizableContainer'),
|
||||
height: _currentPickerHeight ?? initialHeight,
|
||||
child: Column(
|
||||
children: [
|
||||
if (widget.config.resizeConfig.showDragHandle)
|
||||
_buildDragHandle(initialHeight),
|
||||
Expanded(child: content),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDragHandle(double initialHeight) {
|
||||
final resizeConfig = widget.config.resizeConfig;
|
||||
final handleColor =
|
||||
resizeConfig.handleColor ?? Theme.of(context).colorScheme.outline;
|
||||
return DecoratedBox(
|
||||
key: const Key('emojiPickerDragHandleBackground'),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.config.emojiViewConfig.backgroundColor,
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(resizeConfig.topBorderRadius),
|
||||
),
|
||||
),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeUpDown,
|
||||
child: Semantics(
|
||||
label: 'Resize emoji picker',
|
||||
child: GestureDetector(
|
||||
key: const Key('emojiPickerDragHandle'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onVerticalDragUpdate: (details) {
|
||||
final maximumHeight = _maximumPickerHeight(initialHeight);
|
||||
final nextHeight =
|
||||
((_currentPickerHeight ?? initialHeight) - details.delta.dy)
|
||||
.clamp(initialHeight, maximumHeight)
|
||||
.toDouble();
|
||||
if (nextHeight != _currentPickerHeight) {
|
||||
setState(() => _currentPickerHeight = nextHeight);
|
||||
}
|
||||
},
|
||||
child: SizedBox(
|
||||
height: resizeConfig.handleHitAreaHeight,
|
||||
child: Center(
|
||||
child: Container(
|
||||
key: const Key('emojiPickerDragIndicator'),
|
||||
width: resizeConfig.handleWidth,
|
||||
height: resizeConfig.handleHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: handleColor,
|
||||
borderRadius: BorderRadius.circular(
|
||||
resizeConfig.handleBorderRadius,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double _maximumPickerHeight(double initialHeight) {
|
||||
final mediaQuery = MediaQuery.maybeOf(context);
|
||||
final screenHeight = mediaQuery?.size.height ?? initialHeight;
|
||||
final safeHeight =
|
||||
screenHeight -
|
||||
(mediaQuery?.padding.top ?? 0) -
|
||||
(mediaQuery?.padding.bottom ?? 0);
|
||||
final resizeConfig = widget.config.resizeConfig;
|
||||
final configuredMaximum =
|
||||
resizeConfig.maxHeight ?? safeHeight * resizeConfig.maxHeightFactor;
|
||||
return configuredMaximum
|
||||
.clamp(
|
||||
initialHeight,
|
||||
safeHeight < initialHeight ? initialHeight : safeHeight,
|
||||
)
|
||||
.toDouble();
|
||||
}
|
||||
|
||||
void _showSearchView() {
|
||||
setState(() {
|
||||
_isSearchBarVisible = true;
|
||||
});
|
||||
}
|
||||
|
||||
void _hideSearchView() {
|
||||
setState(() {
|
||||
_isSearchBarVisible = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _scrollToCursorAfterTextChange() {
|
||||
if (widget.scrollController != null) {
|
||||
final scrollController = widget.scrollController!;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (scrollController.hasClients) {
|
||||
scrollController.animateTo(
|
||||
scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.ease,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler for controller category changes
|
||||
void _onControllerChanged() {
|
||||
// When the controller's category changes programmatically,
|
||||
// navigate the tabbar to the new category without rebuilding
|
||||
if (mounted && _loaded && widget.controller != null) {
|
||||
_state.categoryNavigationNotifier.value =
|
||||
widget.controller!.currentCategory;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle category changes (both from user interaction and programmatic)
|
||||
void _handleCategoryChanged(Category category) {
|
||||
// Update controller if present
|
||||
widget.controller?.updateCategory(category);
|
||||
|
||||
// Call callback if present and no controller (for backward compatibility)
|
||||
if (widget.controller == null) {
|
||||
widget.onCategoryChanged?.call(category);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller?.removeListener(_onControllerChanged);
|
||||
widget.textEditingController?.removeListener(
|
||||
_scrollToCursorAfterTextChange,
|
||||
);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
/*
|
||||
* Modified version of https://github.com/Fintasys/emoji_picker_flutter
|
||||
* MIT License
|
||||
* Copyright (c) 2024 Stefan Humm
|
||||
*/
|
||||
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker.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.
|
||||
class EmojiTextEditingController extends TextEditingController {
|
||||
/// Constructor, requres emojiStyle, since otherwise this class has no effect
|
||||
EmojiTextEditingController({super.text, required this.emojiTextStyle});
|
||||
|
||||
/// The style used for the emoji characters
|
||||
final TextStyle emojiTextStyle;
|
||||
|
||||
/// Emoji Picker Utils
|
||||
final EmojiPickerUtils utils = EmojiPickerUtils();
|
||||
|
||||
@override
|
||||
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);
|
||||
}
|
||||
|
||||
// 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>[
|
||||
TextSpan(
|
||||
children: utils.setEmojiTextStyle(
|
||||
value.composing.textBefore(value.text),
|
||||
emojiStyle: emojiTextStyle,
|
||||
parentStyle: style,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
children: utils.setEmojiTextStyle(
|
||||
value.composing.textInside(value.text),
|
||||
emojiStyle: emojiTextStyle,
|
||||
parentStyle: underlineStyle,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
children: utils.setEmojiTextStyle(
|
||||
value.composing.textAfter(value.text),
|
||||
emojiStyle: emojiTextStyle,
|
||||
parentStyle: style,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Emoji text style providing commonly available fallback fonts.
|
||||
const DefaultEmojiTextStyle = TextStyle(
|
||||
inherit: true,
|
||||
fontFamilyFallback: [
|
||||
'Apple Color Emoji',
|
||||
'Noto Color Emoji',
|
||||
'Segoe UI Emoji',
|
||||
],
|
||||
);
|
||||
|
|
@ -0,0 +1,478 @@
|
|||
/*
|
||||
* Modified version of https://github.com/Fintasys/emoji_picker_flutter
|
||||
* MIT License
|
||||
* Copyright (c) 2024 Stefan Humm
|
||||
*/
|
||||
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Default EmojiPicker Implementation
|
||||
class DefaultEmojiPickerView extends EmojiPickerView {
|
||||
/// Constructor
|
||||
const DefaultEmojiPickerView(
|
||||
super.config,
|
||||
super.state,
|
||||
super.showSearchBar, {
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DefaultEmojiPickerView> createState() => _DefaultEmojiPickerViewState();
|
||||
}
|
||||
|
||||
class _DefaultEmojiPickerViewState extends State<DefaultEmojiPickerView>
|
||||
with SingleTickerProviderStateMixin, SkinToneOverlayStateMixin {
|
||||
late TabController _tabController;
|
||||
late PageController _legacyPageController;
|
||||
final _scrollController = ScrollController();
|
||||
final _utils = EmojiPickerUtils();
|
||||
|
||||
List<double> _categoryOffsets = const [];
|
||||
int _activeCategoryIndex = 0;
|
||||
double _emojiBoxSize = 0;
|
||||
bool _isNavigatingToCategory = false;
|
||||
|
||||
/// Last remembered skin tone, applied to skin-tone-capable emoji for
|
||||
/// display and selection when [SkinToneConfig.rememberSkinTone] is enabled.
|
||||
String? _rememberedSkinTone;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
// Use controller's current category if available,
|
||||
// otherwise use config's initCategory
|
||||
final targetCategory =
|
||||
widget.state.currentCategory ??
|
||||
widget.config.categoryViewConfig.initCategory;
|
||||
|
||||
var initCategory = widget.state.categoryEmoji.indexWhere(
|
||||
(element) => element.category == targetCategory,
|
||||
);
|
||||
if (initCategory == -1) {
|
||||
initCategory = 0;
|
||||
}
|
||||
_tabController = TabController(
|
||||
initialIndex: initCategory,
|
||||
length: widget.state.categoryEmoji.length,
|
||||
vsync: this,
|
||||
);
|
||||
_activeCategoryIndex = initCategory;
|
||||
// Retained for custom category views that still accept the legacy API.
|
||||
_legacyPageController = _ContinuousCategoryPageController(
|
||||
initialPage: initCategory,
|
||||
onPageRequested: _scrollToCategory,
|
||||
);
|
||||
_scrollController.addListener(_onEmojiScroll);
|
||||
|
||||
// Listen to programmatic category changes from controller
|
||||
widget.state.categoryNavigationNotifier.addListener(
|
||||
_onCategoryNavigationChanged,
|
||||
);
|
||||
|
||||
_loadRememberedSkinTone();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_scrollToCategory(initCategory, animate: false);
|
||||
}
|
||||
});
|
||||
|
||||
super.initState();
|
||||
}
|
||||
|
||||
void _loadRememberedSkinTone() {
|
||||
if (!widget.config.skinToneConfig.rememberSkinTone) {
|
||||
return;
|
||||
}
|
||||
_utils.getRememberedSkinTone().then((tone) {
|
||||
if (!mounted || tone == null) {
|
||||
return;
|
||||
}
|
||||
setState(() => _rememberedSkinTone = tone);
|
||||
});
|
||||
}
|
||||
|
||||
void _onCategoryNavigationChanged() {
|
||||
final targetCategory = widget.state.categoryNavigationNotifier.value;
|
||||
if (targetCategory != null) {
|
||||
final index = widget.state.categoryEmoji.indexWhere(
|
||||
(element) => element.category == targetCategory,
|
||||
);
|
||||
if (index != -1 && index != _activeCategoryIndex) {
|
||||
_scrollToCategory(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.state.categoryNavigationNotifier.removeListener(
|
||||
_onCategoryNavigationChanged,
|
||||
);
|
||||
closeSkinToneOverlay();
|
||||
_legacyPageController.dispose();
|
||||
_scrollController.removeListener(_onEmojiScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final emojiSize = widget.config.emojiViewConfig.getEmojiSize(
|
||||
_emojiGridWidth(constraints.maxWidth),
|
||||
);
|
||||
final emojiBoxSize = widget.config.emojiViewConfig.getEmojiBoxSize(
|
||||
_emojiGridWidth(constraints.maxWidth),
|
||||
);
|
||||
_emojiBoxSize = emojiBoxSize;
|
||||
_categoryOffsets = _calculateCategoryOffsets(emojiBoxSize);
|
||||
return EmojiContainer(
|
||||
color: widget.config.emojiViewConfig.backgroundColor,
|
||||
buttonMode: widget.config.emojiViewConfig.buttonMode,
|
||||
child: ClipRect(
|
||||
child: Column(
|
||||
children:
|
||||
[
|
||||
widget.config.viewOrderConfig.top,
|
||||
widget.config.viewOrderConfig.middle,
|
||||
widget.config.viewOrderConfig.bottom,
|
||||
].map((item) {
|
||||
switch (item) {
|
||||
case EmojiPickerItem.categoryBar:
|
||||
// Category view
|
||||
return _buildCategoryView();
|
||||
case EmojiPickerItem.emojiView:
|
||||
// Emoji view
|
||||
return _buildEmojiView(emojiSize, emojiBoxSize);
|
||||
case EmojiPickerItem.searchBar:
|
||||
// Search Bar
|
||||
return _buildBottomSearchBar();
|
||||
}
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategoryView() {
|
||||
return widget.config.categoryViewConfig.customCategoryView != null
|
||||
? widget.config.categoryViewConfig.customCategoryView!(
|
||||
widget.config,
|
||||
widget.state,
|
||||
_tabController,
|
||||
_legacyPageController,
|
||||
)
|
||||
: DefaultCategoryView(
|
||||
widget.config,
|
||||
widget.state,
|
||||
_tabController,
|
||||
_legacyPageController,
|
||||
onCategorySelected: _scrollToCategory,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmojiView(double emojiSize, double emojiBoxSize) {
|
||||
return Flexible(
|
||||
child: CustomScrollView(
|
||||
key: const Key('emojiScrollView'),
|
||||
controller: _scrollController,
|
||||
primary: false,
|
||||
slivers: _buildCategorySlivers(emojiSize, emojiBoxSize),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
double _emojiGridWidth(double availableWidth) {
|
||||
final horizontalPadding =
|
||||
widget.config.emojiViewConfig.gridPadding.horizontal;
|
||||
return (availableWidth - horizontalPadding).clamp(0, availableWidth);
|
||||
}
|
||||
|
||||
List<double> _calculateCategoryOffsets(double emojiBoxSize) {
|
||||
final viewConfig = widget.config.emojiViewConfig;
|
||||
final offsets = <double>[];
|
||||
var offset = viewConfig.gridPadding.top;
|
||||
for (final entry in widget.state.categoryEmoji.asMap().entries) {
|
||||
final categoryEmoji = entry.value;
|
||||
offsets.add(offset);
|
||||
if (categoryEmoji.category == Category.RECENT &&
|
||||
categoryEmoji.emoji.isEmpty) {
|
||||
offset += emojiBoxSize;
|
||||
} else {
|
||||
final rowCount = (categoryEmoji.emoji.length / viewConfig.columns)
|
||||
.ceil();
|
||||
if (rowCount > 0) {
|
||||
offset +=
|
||||
rowCount * emojiBoxSize +
|
||||
(rowCount - 1) * viewConfig.verticalSpacing;
|
||||
}
|
||||
}
|
||||
if (entry.key < widget.state.categoryEmoji.length - 1) {
|
||||
offset += viewConfig.categorySpacing;
|
||||
}
|
||||
}
|
||||
return offsets;
|
||||
}
|
||||
|
||||
List<Widget> _buildCategorySlivers(double emojiSize, double emojiBoxSize) {
|
||||
final viewConfig = widget.config.emojiViewConfig;
|
||||
return [
|
||||
if (viewConfig.gridPadding.top > 0)
|
||||
SliverToBoxAdapter(child: SizedBox(height: viewConfig.gridPadding.top)),
|
||||
for (final entry in widget.state.categoryEmoji.asMap().entries) ...[
|
||||
if (entry.value.category == Category.RECENT &&
|
||||
entry.value.emoji.isEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: emojiBoxSize, child: _buildNoRecent()),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.only(
|
||||
left: viewConfig.gridPadding.left,
|
||||
right: viewConfig.gridPadding.right,
|
||||
),
|
||||
sliver: SliverGrid.builder(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
childAspectRatio: 1,
|
||||
crossAxisCount: viewConfig.columns,
|
||||
mainAxisSpacing: viewConfig.verticalSpacing,
|
||||
crossAxisSpacing: viewConfig.horizontalSpacing,
|
||||
),
|
||||
itemCount: entry.value.emoji.length,
|
||||
itemBuilder: (context, index) =>
|
||||
_buildEmojiCell(entry.value, index, emojiSize, emojiBoxSize),
|
||||
),
|
||||
),
|
||||
if (entry.key < widget.state.categoryEmoji.length - 1 &&
|
||||
viewConfig.categorySpacing > 0)
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: viewConfig.categorySpacing,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (viewConfig.gridPadding.bottom > 0)
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: viewConfig.gridPadding.bottom),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildEmojiCell(
|
||||
CategoryEmoji categoryEmoji,
|
||||
int index,
|
||||
double emojiSize,
|
||||
double emojiBoxSize,
|
||||
) {
|
||||
// Apply a remembered/default skin tone for display and selection.
|
||||
// Falls back to the base glyph when no tone is configured.
|
||||
final displayEmoji = _utils.applyDisplaySkinTone(
|
||||
categoryEmoji.emoji[index],
|
||||
widget.config.skinToneConfig,
|
||||
_rememberedSkinTone,
|
||||
);
|
||||
return addSkinToneTargetIfAvailable(
|
||||
hasSkinTone: displayEmoji.hasSkinTone,
|
||||
linkKey: categoryEmoji.category.name + displayEmoji.emoji,
|
||||
child: EmojiCell.fromConfig(
|
||||
emoji: displayEmoji,
|
||||
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 _onEmojiScroll() {
|
||||
closeSkinToneOverlay();
|
||||
if (_isNavigatingToCategory ||
|
||||
_categoryOffsets.isEmpty ||
|
||||
!_scrollController.hasClients) {
|
||||
return;
|
||||
}
|
||||
|
||||
final position = _scrollController.position;
|
||||
var categoryIndex = 0;
|
||||
if (position.pixels >= position.maxScrollExtent - 0.5) {
|
||||
categoryIndex = _lastCategoryWithEmoji();
|
||||
} else {
|
||||
final leadingOffset = position.pixels + _emojiBoxSize / 2;
|
||||
for (var index = 0; index < _categoryOffsets.length; index++) {
|
||||
if (_categoryOffsets[index] <= leadingOffset) {
|
||||
categoryIndex = index;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_setActiveCategory(categoryIndex);
|
||||
}
|
||||
|
||||
int _lastCategoryWithEmoji() {
|
||||
for (
|
||||
var index = widget.state.categoryEmoji.length - 1;
|
||||
index >= 0;
|
||||
index--
|
||||
) {
|
||||
if (widget.state.categoryEmoji[index].emoji.isNotEmpty) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
void _setActiveCategory(int index) {
|
||||
if (index == _activeCategoryIndex ||
|
||||
index < 0 ||
|
||||
index >= widget.state.categoryEmoji.length) {
|
||||
return;
|
||||
}
|
||||
_activeCategoryIndex = index;
|
||||
_tabController.animateTo(
|
||||
index,
|
||||
duration: widget.config.categoryViewConfig.tabIndicatorAnimDuration,
|
||||
);
|
||||
widget.state.onCategoryChanged?.call(
|
||||
widget.state.categoryEmoji[index].category,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _scrollToCategory(int index, {bool animate = true}) async {
|
||||
if (index < 0 || index >= widget.state.categoryEmoji.length) {
|
||||
return;
|
||||
}
|
||||
_setActiveCategory(index);
|
||||
if (!_scrollController.hasClients || _categoryOffsets.length <= index) {
|
||||
return;
|
||||
}
|
||||
|
||||
final position = _scrollController.position;
|
||||
final target = _categoryOffsets[index].clamp(
|
||||
position.minScrollExtent,
|
||||
position.maxScrollExtent,
|
||||
);
|
||||
_isNavigatingToCategory = true;
|
||||
if (animate) {
|
||||
await _scrollController.animateTo(
|
||||
target,
|
||||
duration: widget.config.categoryViewConfig.tabIndicatorAnimDuration,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
} else {
|
||||
_scrollController.jumpTo(target);
|
||||
}
|
||||
_isNavigatingToCategory = false;
|
||||
}
|
||||
|
||||
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) {
|
||||
_rememberSkinToneIfEnabled(emoji);
|
||||
widget.state.onEmojiSelected(category, emoji);
|
||||
closeSkinToneOverlay();
|
||||
}
|
||||
|
||||
/// Persists and re-applies the skin tone of the selected [emoji] when
|
||||
/// [SkinToneConfig.rememberSkinTone] is enabled. Selecting a
|
||||
/// skin-tone-capable base glyph (no modifier) clears the remembered tone.
|
||||
void _rememberSkinToneIfEnabled(Emoji emoji) {
|
||||
if (!widget.config.skinToneConfig.rememberSkinTone || !emoji.hasSkinTone) {
|
||||
return;
|
||||
}
|
||||
final tone = _utils.extractSkinTone(emoji);
|
||||
if (tone == _rememberedSkinTone) {
|
||||
return;
|
||||
}
|
||||
_utils.setRememberedSkinTone(tone);
|
||||
setState(() => _rememberedSkinTone = tone);
|
||||
}
|
||||
}
|
||||
|
||||
/// Adapts the previous page-based category navigation API to the continuous
|
||||
/// scroll view so existing custom category bars keep working.
|
||||
class _ContinuousCategoryPageController extends PageController {
|
||||
_ContinuousCategoryPageController({
|
||||
required super.initialPage,
|
||||
required this.onPageRequested,
|
||||
}) : _currentPage = initialPage;
|
||||
|
||||
final ValueChanged<int> onPageRequested;
|
||||
int _currentPage;
|
||||
|
||||
@override
|
||||
double? get page => _currentPage.toDouble();
|
||||
|
||||
@override
|
||||
void jumpToPage(int page) {
|
||||
_currentPage = page;
|
||||
onPageRequested(page);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> animateToPage(
|
||||
int page, {
|
||||
required Duration duration,
|
||||
required Curve curve,
|
||||
}) async {
|
||||
_currentPage = page;
|
||||
onPageRequested(page);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> nextPage({required Duration duration, required Curve curve}) =>
|
||||
animateToPage(_currentPage + 1, duration: duration, curve: curve);
|
||||
|
||||
@override
|
||||
Future<void> previousPage({
|
||||
required Duration duration,
|
||||
required Curve curve,
|
||||
}) => animateToPage(_currentPage - 1, duration: duration, curve: curve);
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
* Modified version of https://github.com/Fintasys/emoji_picker_flutter
|
||||
* MIT License
|
||||
* Copyright (c) 2024 Stefan Humm
|
||||
*/
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker.dart';
|
||||
|
||||
/// Template class for custom implementation
|
||||
/// Inhert this class to create your own EmojiPicker
|
||||
abstract class EmojiPickerView extends StatefulWidget {
|
||||
/// Constructor
|
||||
const EmojiPickerView(
|
||||
this.config,
|
||||
this.state,
|
||||
this.showSearchBar, {
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Config for customizations
|
||||
final Config config;
|
||||
|
||||
/// State that holds current emoji data
|
||||
final EmojiViewState state;
|
||||
|
||||
/// Show Search Bar
|
||||
final VoidCallback showSearchBar;
|
||||
}
|
||||
|
||||
/// A wrapper that provides the requested emoji-cell button behavior.
|
||||
class EmojiContainer extends StatelessWidget {
|
||||
/// Constructor.
|
||||
const EmojiContainer({
|
||||
super.key,
|
||||
required this.color,
|
||||
required this.buttonMode,
|
||||
this.padding,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final Color color;
|
||||
final ButtonMode buttonMode;
|
||||
final EdgeInsets? padding;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (buttonMode == ButtonMode.MATERIAL) {
|
||||
return Material(
|
||||
color: color,
|
||||
child: padding == null
|
||||
? child
|
||||
: Padding(padding: padding!, child: child),
|
||||
);
|
||||
}
|
||||
return Container(color: color, padding: padding, child: child);
|
||||
}
|
||||
}
|
||||
|
||||
/// State shared by the picker views.
|
||||
class EmojiViewState {
|
||||
/// Constructor.
|
||||
EmojiViewState(
|
||||
this.categoryEmoji,
|
||||
this.onEmojiSelected,
|
||||
this.onBackspacePressed,
|
||||
this.onBackspaceLongPressed,
|
||||
this.onShowSearchView,
|
||||
this.onCategoryChanged, {
|
||||
this.currentCategory,
|
||||
});
|
||||
|
||||
final List<CategoryEmoji> categoryEmoji;
|
||||
final OnEmojiSelected onEmojiSelected;
|
||||
final OnBackspacePressed? onBackspacePressed;
|
||||
final OnBackspaceLongPressed onBackspaceLongPressed;
|
||||
final VoidCallback onShowSearchView;
|
||||
final OnCategoryChanged? onCategoryChanged;
|
||||
final Category? currentCategory;
|
||||
|
||||
/// Notifies the tab bar about programmatic category changes.
|
||||
final ValueNotifier<Category?> categoryNavigationNotifier =
|
||||
ValueNotifier<Category?>(null);
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
/*
|
||||
* Modified version of https://github.com/Fintasys/emoji_picker_flutter
|
||||
* MIT License
|
||||
* Copyright (c) 2024 Stefan Humm
|
||||
*/
|
||||
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker.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.categorySpacing = 12,
|
||||
this.gridPadding = EdgeInsets.zero,
|
||||
this.recentsLimit = 28,
|
||||
this.replaceEmojiOnLimitExceed = false,
|
||||
this.noRecents = DefaultNoRecentsWidget,
|
||||
this.loadingIndicator = const SizedBox.shrink(),
|
||||
this.buttonMode = ButtonMode.MATERIAL,
|
||||
}) : assert(categorySpacing >= 0);
|
||||
|
||||
/// 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;
|
||||
|
||||
/// Vertical spacing between consecutive emoji categories
|
||||
final double categorySpacing;
|
||||
|
||||
/// 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 = getEmojiBoxSize(width);
|
||||
return min(maxSize, emojiSizeMax);
|
||||
}
|
||||
|
||||
/// Get Emoji hitbox size based on properties and screen width
|
||||
double getEmojiBoxSize(double width) {
|
||||
final totalHorizontalSpacing = (columns - 1) * horizontalSpacing;
|
||||
final availableWidth = width - totalHorizontalSpacing;
|
||||
return availableWidth / 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.categorySpacing == categorySpacing &&
|
||||
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 ^
|
||||
categorySpacing.hashCode ^
|
||||
recentsLimit.hashCode ^
|
||||
buttonMode.hashCode ^
|
||||
gridPadding.hashCode ^
|
||||
replaceEmojiOnLimitExceed.hashCode;
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Modified version of https://github.com/Fintasys/emoji_picker_flutter
|
||||
* MIT License
|
||||
* Copyright (c) 2024 Stefan Humm
|
||||
*/
|
||||
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Default method for locale selection
|
||||
List<CategoryEmoji> getDefaultEmojiLocale(Locale locale) {
|
||||
switch (locale.languageCode) {
|
||||
case 'de':
|
||||
return emojiSetGerman;
|
||||
case 'en':
|
||||
return emojiSetEnglish;
|
||||
case 'es':
|
||||
return emojiSetSpanish;
|
||||
case 'fr':
|
||||
return emojiSetFrance;
|
||||
case 'hi':
|
||||
return emojiSetHindi;
|
||||
case 'it':
|
||||
return emojiSetItalian;
|
||||
case 'ja':
|
||||
return emojiSetJapanese;
|
||||
case 'nl':
|
||||
return emojiSetDutch;
|
||||
case 'pt':
|
||||
return emojiSetPortuguese;
|
||||
case 'ru':
|
||||
return emojiSetRussian;
|
||||
case 'zh':
|
||||
return emojiSetChinese;
|
||||
default:
|
||||
return emojiSetEnglish;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* Modified version of https://github.com/Fintasys/emoji_picker_flutter
|
||||
* MIT License
|
||||
* Copyright (c) 2024 Stefan Humm
|
||||
*/
|
||||
|
||||
export 'package:twonly/src/visual/components/emoji_picker/locales/emoji_set_de.dart';
|
||||
export 'package:twonly/src/visual/components/emoji_picker/locales/emoji_set_en.dart';
|
||||
export 'package:twonly/src/visual/components/emoji_picker/locales/emoji_set_es.dart';
|
||||
export 'package:twonly/src/visual/components/emoji_picker/locales/emoji_set_fr.dart';
|
||||
export 'package:twonly/src/visual/components/emoji_picker/locales/emoji_set_hi.dart';
|
||||
export 'package:twonly/src/visual/components/emoji_picker/locales/emoji_set_it.dart';
|
||||
export 'package:twonly/src/visual/components/emoji_picker/locales/emoji_set_ja.dart';
|
||||
export 'package:twonly/src/visual/components/emoji_picker/locales/emoji_set_nl.dart';
|
||||
export 'package:twonly/src/visual/components/emoji_picker/locales/emoji_set_pt.dart';
|
||||
export 'package:twonly/src/visual/components/emoji_picker/locales/emoji_set_ru.dart';
|
||||
export 'package:twonly/src/visual/components/emoji_picker/locales/emoji_set_zh.dart';
|
||||
4482
lib/src/visual/components/emoji_picker/locales/emoji_set_de.dart
Normal file
4482
lib/src/visual/components/emoji_picker/locales/emoji_set_de.dart
Normal file
File diff suppressed because it is too large
Load diff
3757
lib/src/visual/components/emoji_picker/locales/emoji_set_en.dart
Normal file
3757
lib/src/visual/components/emoji_picker/locales/emoji_set_en.dart
Normal file
File diff suppressed because it is too large
Load diff
3540
lib/src/visual/components/emoji_picker/locales/emoji_set_es.dart
Normal file
3540
lib/src/visual/components/emoji_picker/locales/emoji_set_es.dart
Normal file
File diff suppressed because it is too large
Load diff
4926
lib/src/visual/components/emoji_picker/locales/emoji_set_fr.dart
Normal file
4926
lib/src/visual/components/emoji_picker/locales/emoji_set_fr.dart
Normal file
File diff suppressed because it is too large
Load diff
4095
lib/src/visual/components/emoji_picker/locales/emoji_set_hi.dart
Normal file
4095
lib/src/visual/components/emoji_picker/locales/emoji_set_hi.dart
Normal file
File diff suppressed because it is too large
Load diff
5682
lib/src/visual/components/emoji_picker/locales/emoji_set_it.dart
Normal file
5682
lib/src/visual/components/emoji_picker/locales/emoji_set_it.dart
Normal file
File diff suppressed because it is too large
Load diff
2588
lib/src/visual/components/emoji_picker/locales/emoji_set_ja.dart
Normal file
2588
lib/src/visual/components/emoji_picker/locales/emoji_set_ja.dart
Normal file
File diff suppressed because it is too large
Load diff
4301
lib/src/visual/components/emoji_picker/locales/emoji_set_nl.dart
Normal file
4301
lib/src/visual/components/emoji_picker/locales/emoji_set_nl.dart
Normal file
File diff suppressed because it is too large
Load diff
4343
lib/src/visual/components/emoji_picker/locales/emoji_set_pt.dart
Normal file
4343
lib/src/visual/components/emoji_picker/locales/emoji_set_pt.dart
Normal file
File diff suppressed because it is too large
Load diff
4647
lib/src/visual/components/emoji_picker/locales/emoji_set_ru.dart
Normal file
4647
lib/src/visual/components/emoji_picker/locales/emoji_set_ru.dart
Normal file
File diff suppressed because it is too large
Load diff
2557
lib/src/visual/components/emoji_picker/locales/emoji_set_zh.dart
Normal file
2557
lib/src/visual/components/emoji_picker/locales/emoji_set_zh.dart
Normal file
File diff suppressed because it is too large
Load diff
307
lib/src/visual/components/emoji_picker/search_view.dart
Normal file
307
lib/src/visual/components/emoji_picker/search_view.dart
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
/*
|
||||
* Modified version of https://github.com/Fintasys/emoji_picker_flutter
|
||||
* MIT License
|
||||
* Copyright (c) 2024 Stefan Humm
|
||||
*/
|
||||
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker.dart';
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker_internal_utils.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, {super.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<T extends SearchView> extends State<T>
|
||||
with SkinToneOverlayStateMixin {
|
||||
/// Emoji picker utils
|
||||
final utils = EmojiPickerUtils();
|
||||
|
||||
/// Internal utils, used for the cache-backed synchronous fast-path when
|
||||
/// loading recent emojis (the public [utils] returns a `Future`).
|
||||
final _internalUtils = EmojiPickerInternalUtils();
|
||||
|
||||
/// Focus node for textfield
|
||||
final focusNode = FocusNode();
|
||||
|
||||
/// Search results
|
||||
final results = List<Emoji>.empty(growable: true);
|
||||
|
||||
/// Last remembered skin tone, applied to skin-tone-capable emoji for display
|
||||
/// and selection when [SkinToneConfig.rememberSkinTone] is enabled.
|
||||
String? _rememberedSkinTone;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadRememberedSkinTone();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
// Auto focus textfield
|
||||
FocusScope.of(context).requestFocus(focusNode);
|
||||
// Load recent emojis initially
|
||||
final futureOrRecent = _internalUtils.getRecentEmojis();
|
||||
if (futureOrRecent is List<RecentEmoji>) {
|
||||
setState(
|
||||
() => _updateResults(futureOrRecent.map((e) => e.emoji).toList()),
|
||||
);
|
||||
} else {
|
||||
futureOrRecent.then((value) {
|
||||
if (!mounted) return;
|
||||
setState(() => _updateResults(value.map((e) => e.emoji).toList()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _loadRememberedSkinTone() {
|
||||
if (!widget.config.skinToneConfig.rememberSkinTone) {
|
||||
return;
|
||||
}
|
||||
utils.getRememberedSkinTone().then((tone) {
|
||||
if (!mounted || tone == null) {
|
||||
return;
|
||||
}
|
||||
setState(() => _rememberedSkinTone = tone);
|
||||
});
|
||||
}
|
||||
|
||||
/// On text input changed callback
|
||||
void onTextInputChanged(String text) {
|
||||
links.clear();
|
||||
results.clear();
|
||||
utils.searchEmoji(text, widget.state.categoryEmoji).then((value) {
|
||||
if (!mounted) return;
|
||||
setState(() => _updateResults(value));
|
||||
});
|
||||
}
|
||||
|
||||
void _updateResults(List<Emoji> emojis) {
|
||||
results
|
||||
..clear()
|
||||
..addAll(emojis);
|
||||
results.asMap().entries.forEach((e) {
|
||||
final displayEmoji = utils.applyDisplaySkinTone(
|
||||
e.value,
|
||||
widget.config.skinToneConfig,
|
||||
_rememberedSkinTone,
|
||||
);
|
||||
links[displayEmoji.emoji] = LayerLink();
|
||||
});
|
||||
}
|
||||
|
||||
/// Build emoji cell
|
||||
Widget buildEmoji(Emoji emoji, double emojiSize, double emojiBoxSize) {
|
||||
// Apply a remembered skin tone for display and selection.
|
||||
// Falls back to the base glyph when no tone is remembered.
|
||||
final displayEmoji = utils.applyDisplaySkinTone(
|
||||
emoji,
|
||||
widget.config.skinToneConfig,
|
||||
_rememberedSkinTone,
|
||||
);
|
||||
return addSkinToneTargetIfAvailable(
|
||||
hasSkinTone: displayEmoji.hasSkinTone,
|
||||
linkKey: displayEmoji.emoji,
|
||||
child: EmojiCell.fromConfig(
|
||||
emoji: displayEmoji,
|
||||
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) {
|
||||
_rememberSkinToneIfEnabled(emoji);
|
||||
widget.state.onEmojiSelected(category, emoji);
|
||||
closeSkinToneOverlay();
|
||||
}
|
||||
|
||||
/// Persists and re-applies the skin tone of the selected [emoji] when
|
||||
/// [SkinToneConfig.rememberSkinTone] is enabled. Selecting a
|
||||
/// skin-tone-capable base glyph (no modifier) clears the remembered tone.
|
||||
void _rememberSkinToneIfEnabled(Emoji emoji) {
|
||||
if (!widget.config.skinToneConfig.rememberSkinTone || !emoji.hasSkinTone) {
|
||||
return;
|
||||
}
|
||||
final tone = utils.extractSkinTone(emoji);
|
||||
if (tone == _rememberedSkinTone) {
|
||||
return;
|
||||
}
|
||||
utils.setRememberedSkinTone(tone);
|
||||
setState(() => _rememberedSkinTone = tone);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
throw UnimplementedError('Search View implementation missing');
|
||||
}
|
||||
}
|
||||
|
||||
/// Callback for a custom search view.
|
||||
typedef SearchViewBuilder =
|
||||
Widget Function(
|
||||
Config config,
|
||||
EmojiViewState state,
|
||||
VoidCallback showEmojiView,
|
||||
);
|
||||
|
||||
/// Search view configuration.
|
||||
class SearchViewConfig {
|
||||
/// Constructor.
|
||||
const SearchViewConfig({
|
||||
this.backgroundColor = const Color(0xFFEBEFF2),
|
||||
this.buttonIconColor = Colors.black26,
|
||||
this.inputTextStyle,
|
||||
this.hintText = 'Search',
|
||||
this.hintTextStyle,
|
||||
this.customSearchView,
|
||||
});
|
||||
|
||||
final Color backgroundColor;
|
||||
final Color buttonIconColor;
|
||||
final TextStyle? inputTextStyle;
|
||||
final String? hintText;
|
||||
final TextStyle? hintTextStyle;
|
||||
|
||||
/// Custom search view. Hot reload is not supported.
|
||||
final SearchViewBuilder? customSearchView;
|
||||
|
||||
@override
|
||||
bool operator ==(other) {
|
||||
return other is SearchViewConfig &&
|
||||
other.backgroundColor == backgroundColor &&
|
||||
other.buttonIconColor == buttonIconColor &&
|
||||
other.hintText == hintText &&
|
||||
other.hintTextStyle == hintTextStyle &&
|
||||
other.inputTextStyle == inputTextStyle;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
backgroundColor,
|
||||
buttonIconColor,
|
||||
hintText,
|
||||
hintTextStyle,
|
||||
inputTextStyle,
|
||||
);
|
||||
}
|
||||
|
||||
/// Default search implementation.
|
||||
class DefaultSearchView extends SearchView {
|
||||
/// Constructor.
|
||||
const DefaultSearchView(
|
||||
super.config,
|
||||
super.state,
|
||||
super.showEmojiView, {
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
DefaultSearchViewState createState() => DefaultSearchViewState();
|
||||
}
|
||||
|
||||
/// State for the default search view.
|
||||
class DefaultSearchViewState extends SearchViewState<DefaultSearchView> {
|
||||
@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(
|
||||
color: Colors.transparent,
|
||||
child: SizedBox(
|
||||
height: emojiBoxSize + 8,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
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.buttonIconColor,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
onChanged: onTextInputChanged,
|
||||
focusNode: focusNode,
|
||||
style: widget.config.searchViewConfig.inputTextStyle,
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: widget.config.searchViewConfig.hintText,
|
||||
hintStyle: widget.config.searchViewConfig.hintTextStyle,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Modified version of https://github.com/Fintasys/emoji_picker_flutter
|
||||
* MIT License
|
||||
* Copyright (c) 2024 Stefan Humm
|
||||
*/
|
||||
|
||||
/// Alternative skin tones of Emoji
|
||||
class SkinTone {
|
||||
SkinTone._();
|
||||
|
||||
/// Light Skin Tone
|
||||
static const String light = '🏻';
|
||||
|
||||
/// Medium-Light Skin Tone
|
||||
static const String mediumLight = '🏼';
|
||||
|
||||
/// Medium Skin Tone
|
||||
static const String medium = '🏽';
|
||||
|
||||
/// Medium-Dark Skin Tone
|
||||
static const String mediumDark = '🏾';
|
||||
|
||||
/// Dark Skin Tone
|
||||
static const String dark = '🏿';
|
||||
|
||||
/// Return all values as Array
|
||||
static const values = [light, mediumLight, medium, mediumDark, dark];
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* Modified version of https://github.com/Fintasys/emoji_picker_flutter
|
||||
* MIT License
|
||||
* Copyright (c) 2024 Stefan Humm
|
||||
*/
|
||||
|
||||
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,
|
||||
this.rememberSkinTone = false,
|
||||
});
|
||||
|
||||
/// 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;
|
||||
|
||||
/// Remember the last skin tone the user selected.
|
||||
///
|
||||
/// When `true`, the tone chosen via the long-press picker is persisted (in
|
||||
/// `SharedPreferences`) and re-applied as the default for every
|
||||
/// skin-tone-capable emoji in the grid, recents and search on the next
|
||||
/// launch. Selecting the base (no-tone) glyph clears the remembered tone.
|
||||
///
|
||||
/// When `false` (default) the base glyph is always shown, which is the
|
||||
/// previous behavior.
|
||||
final bool rememberSkinTone;
|
||||
|
||||
@override
|
||||
bool operator ==(other) {
|
||||
return (other is SkinToneConfig) &&
|
||||
other.enabled == enabled &&
|
||||
other.dialogBackgroundColor == dialogBackgroundColor &&
|
||||
other.indicatorColor == indicatorColor &&
|
||||
other.rememberSkinTone == rememberSkinTone;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
enabled.hashCode ^
|
||||
dialogBackgroundColor.hashCode ^
|
||||
indicatorColor.hashCode ^
|
||||
rememberSkinTone.hashCode;
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
/*
|
||||
* Modified version of https://github.com/Fintasys/emoji_picker_flutter
|
||||
* MIT License
|
||||
* Copyright (c) 2024 Stefan Humm
|
||||
*/
|
||||
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Skin tone overlay mixin
|
||||
mixin SkinToneOverlayStateMixin<T extends StatefulWidget> on State<T> {
|
||||
final _utils = EmojiPickerUtils();
|
||||
OverlayEntry? _overlay;
|
||||
|
||||
/// Layer links for skin tone overlay
|
||||
final links = HashMap<String, LayerLink>();
|
||||
|
||||
/// 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: [
|
||||
// The first cell is always the neutral (no-tone) glyph so
|
||||
// it doubles as the "reset" option: selecting it clears any
|
||||
// remembered skin tone. When [emoji] carries a remembered
|
||||
// tone this strips it; otherwise it is already neutral.
|
||||
EmojiCell.fromConfig(
|
||||
emoji: _utils.removeSkinTone(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() {
|
||||
closeSkinToneOverlay();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* Modified version of https://github.com/Fintasys/emoji_picker_flutter
|
||||
* MIT License
|
||||
* Copyright (c) 2024 Stefan Humm
|
||||
*/
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Decoration that can be used to render a triangle in the bottom-right
|
||||
/// corner of a container
|
||||
class TriangleDecoration extends Decoration {
|
||||
/// Constructor
|
||||
const TriangleDecoration({required this.color, required this.size}) : super();
|
||||
|
||||
/// Color of the triangle
|
||||
final Color color;
|
||||
|
||||
/// Width and height of the triangle
|
||||
final double size;
|
||||
|
||||
@override
|
||||
BoxPainter createBoxPainter([VoidCallback? onChanged]) {
|
||||
return _TriangleShapePainter(color, size);
|
||||
}
|
||||
}
|
||||
|
||||
class _TriangleShapePainter extends BoxPainter {
|
||||
/// Constructor
|
||||
/// Expects color that the triangle will be filled with and
|
||||
/// size of the triangle
|
||||
_TriangleShapePainter(Color color, double size) {
|
||||
_painter = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.fill;
|
||||
_size = size;
|
||||
}
|
||||
|
||||
late final Paint _painter;
|
||||
late final double _size;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
|
||||
// per documentation, the size should be always not null here, no need
|
||||
// for null checks
|
||||
final s = configuration.size!;
|
||||
var path = Path()
|
||||
..moveTo(s.width + offset.dx, s.height - _size + offset.dy)
|
||||
..lineTo(s.width - _size + offset.dx, s.height + offset.dy)
|
||||
..lineTo(s.width + offset.dx, s.height + offset.dy)
|
||||
..lineTo(s.width + offset.dx, s.height - _size + offset.dy)
|
||||
..close();
|
||||
|
||||
canvas.drawPath(path, _painter);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
/*
|
||||
* Modified version of https://github.com/Fintasys/emoji_picker_flutter
|
||||
* MIT License
|
||||
* Copyright (c) 2024 Stefan Humm
|
||||
*/
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker.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<BackspaceButton> createState() => _BackspaceButtonState();
|
||||
}
|
||||
|
||||
class _BackspaceButtonState extends State<BackspaceButton> {
|
||||
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:
|
||||
widget.config.customBackspaceIcon ??
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// Search button widget.
|
||||
class SearchButton extends StatelessWidget {
|
||||
/// Constructor.
|
||||
const SearchButton(
|
||||
this.config,
|
||||
this.showSearchView,
|
||||
this.buttonIconColor, {
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Config config;
|
||||
final VoidCallback showSearchView;
|
||||
final Color buttonIconColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
onPressed: showSearchView,
|
||||
icon:
|
||||
config.customSearchIcon ?? Icon(Icons.search, color: buttonIconColor),
|
||||
);
|
||||
}
|
||||
}
|
||||
170
lib/src/visual/components/emoji_picker/widgets/emoji_cell.dart
Normal file
170
lib/src/visual/components/emoji_picker/widgets/emoji_cell.dart
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
/*
|
||||
* Modified version of https://github.com/Fintasys/emoji_picker_flutter
|
||||
* MIT License
|
||||
* Copyright (c) 2024 Stefan Humm
|
||||
*/
|
||||
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A widget that represents an individual clickable emoji cell.
|
||||
/// Can have a long pressed listener [onSkinToneDialogRequested] that
|
||||
/// provides necessary data to show a skin tone popup.
|
||||
class EmojiCell extends StatelessWidget {
|
||||
/// Constructor for manually setting all properties
|
||||
const EmojiCell({
|
||||
super.key,
|
||||
required this.emoji,
|
||||
required this.emojiSize,
|
||||
required this.emojiBoxSize,
|
||||
this.categoryEmoji,
|
||||
required this.buttonMode,
|
||||
required this.enableSkinTones,
|
||||
required this.textStyle,
|
||||
required this.skinToneIndicatorColor,
|
||||
this.onSkinToneDialogRequested,
|
||||
required this.onEmojiSelected,
|
||||
});
|
||||
|
||||
/// Constructor that can retrieve as much information as possible from
|
||||
/// [Config]
|
||||
EmojiCell.fromConfig({
|
||||
super.key,
|
||||
required this.emoji,
|
||||
required this.emojiSize,
|
||||
required this.emojiBoxSize,
|
||||
this.categoryEmoji,
|
||||
required this.onEmojiSelected,
|
||||
this.onSkinToneDialogRequested,
|
||||
required Config config,
|
||||
}) : buttonMode = config.emojiViewConfig.buttonMode,
|
||||
enableSkinTones = config.skinToneConfig.enabled,
|
||||
textStyle = config.emojiTextStyle,
|
||||
skinToneIndicatorColor = config.skinToneConfig.indicatorColor;
|
||||
|
||||
/// Emoji to display as the cell content
|
||||
final Emoji emoji;
|
||||
|
||||
/// 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;
|
||||
|
||||
/// Whether to show skin popup indicator if emoji supports skin colors
|
||||
final bool enableSkinTones;
|
||||
|
||||
/// Custom text style to use on emoji
|
||||
final TextStyle? textStyle;
|
||||
|
||||
/// Color for skin color indicator triangle
|
||||
final Color skinToneIndicatorColor;
|
||||
|
||||
/// Callback triggered on long press. Will be called regardless
|
||||
/// whether [enableSkinTones] is set or not and for any emoji to
|
||||
/// give a way for the caller to dismiss any existing overlays.
|
||||
final OnSkinToneDialogRequested? onSkinToneDialogRequested;
|
||||
|
||||
/// Callback for a single tap on the cell.
|
||||
final OnEmojiSelected onEmojiSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
onPressed() {
|
||||
onEmojiSelected(categoryEmoji?.category, emoji);
|
||||
}
|
||||
|
||||
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: FittedBox(fit: BoxFit.scaleDown, child: _buildEmoji()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build different Button based on ButtonMode
|
||||
Widget _buildButtonWidget({
|
||||
required VoidCallback onPressed,
|
||||
VoidCallback? onLongPressed,
|
||||
required Widget child,
|
||||
}) {
|
||||
if (buttonMode == ButtonMode.MATERIAL) {
|
||||
return MaterialButton(
|
||||
onPressed: onPressed,
|
||||
onLongPress: onLongPressed,
|
||||
elevation: 0,
|
||||
highlightElevation: 0,
|
||||
padding: EdgeInsets.zero,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
if (buttonMode == ButtonMode.CUPERTINO) {
|
||||
return GestureDetector(
|
||||
onLongPress: onLongPressed,
|
||||
child: CupertinoButton(
|
||||
onPressed: onPressed,
|
||||
padding: EdgeInsets.zero,
|
||||
alignment: Alignment.center,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
return GestureDetector(
|
||||
onLongPress: onLongPressed,
|
||||
onTap: onPressed,
|
||||
child: Center(child: child),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build and display Emoji centered of its parent
|
||||
Widget _buildEmoji() {
|
||||
final emojiText = Text(
|
||||
emoji.emoji,
|
||||
textScaler: const TextScaler.linear(1.0),
|
||||
style: _getEmojiTextStyle(),
|
||||
);
|
||||
|
||||
return emoji.hasSkinTone &&
|
||||
enableSkinTones &&
|
||||
onSkinToneDialogRequested != null
|
||||
? Container(
|
||||
decoration: TriangleDecoration(
|
||||
color: skinToneIndicatorColor,
|
||||
size: 8.0,
|
||||
),
|
||||
child: emojiText,
|
||||
)
|
||||
: emojiText;
|
||||
}
|
||||
|
||||
TextStyle _getEmojiTextStyle() {
|
||||
final defaultStyle = DefaultEmojiTextStyle.copyWith(
|
||||
fontSize: emojiSize,
|
||||
inherit: true,
|
||||
);
|
||||
// textStyle properties have priority over defaultStyle
|
||||
return textStyle == null ? defaultStyle : defaultStyle.merge(textStyle);
|
||||
}
|
||||
}
|
||||
|
|
@ -46,12 +46,12 @@ enum AppColor {
|
|||
scanHighlight(light: Color(0xFF76FF03), dark: Color(0xFF76FF03)),
|
||||
|
||||
// Message-kind indicators
|
||||
messageText(light: Color(0xFF35618D), dark: Color(0xFF9FCBFF)),
|
||||
messageRestore(light: Color(0xFFB85F00), dark: Color(0xFFFFB95F)),
|
||||
messageWidget(light: Color(0xFF79528D), dark: Color(0xFFD7B9E8)),
|
||||
messageVideo(light: Color(0xFF9C3A8D), dark: Color(0xFFFFA9E5)),
|
||||
messageAudio(light: Color(0xFFB85F00), dark: Color(0xFFFFB68A)),
|
||||
messageImage(light: Color(0xFFBA1A1A), dark: Color(0xFFFFB4AB));
|
||||
messageText(light: Color(0xFF448AFF), dark: Color(0xFF448AFF)),
|
||||
messageRestore(light: Color(0xFFFF9800), dark: Color(0xFFFF9800)),
|
||||
messageWidget(light: Color(0xFF9B59B6), dark: Color(0xFF9B59B6)),
|
||||
messageVideo(light: Color(0xFFF321D0), dark: Color(0xFFF321D0)),
|
||||
messageAudio(light: Color(0xFFFC9555), dark: Color(0xFFFC9555)),
|
||||
messageImage(light: Color(0xFFFF5252), dark: Color(0xFFFF5252));
|
||||
|
||||
const AppColor({required this.light, required this.dark});
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,12 @@ import 'package:twonly/src/visual/views/camera/camera_preview_components/painter
|
|||
import 'package:twonly/src/visual/views/camera/camera_preview_components/painters/face_filters/dog_filter_painter.dart';
|
||||
import 'package:twonly/src/visual/views/camera/camera_preview_components/painters/face_filters/face_filter_painter.dart';
|
||||
|
||||
// A captured widget cannot contain more camera detail than the live preview.
|
||||
// Full HD keeps the screenshot capture fast while avoiding the visible 720p
|
||||
// upscale on most modern phone displays. Ultra-high/max would also make the
|
||||
// continuously running barcode and face-detection image stream much heavier.
|
||||
const ResolutionPreset _cameraResolutionPreset = ResolutionPreset.veryHigh;
|
||||
|
||||
class PreviewLink {
|
||||
const PreviewLink({
|
||||
required this.url,
|
||||
|
|
@ -208,7 +214,7 @@ class MainCameraController {
|
|||
|
||||
var controller = CameraController(
|
||||
AppEnvironment.cameras[cameraId],
|
||||
ResolutionPreset.high,
|
||||
_cameraResolutionPreset,
|
||||
enableAudio: hasMic,
|
||||
imageFormatGroup: Platform.isAndroid
|
||||
? ImageFormatGroup.nv21
|
||||
|
|
@ -228,7 +234,7 @@ class MainCameraController {
|
|||
await controller.dispose();
|
||||
controller = CameraController(
|
||||
AppEnvironment.cameras[cameraId],
|
||||
ResolutionPreset.high,
|
||||
_cameraResolutionPreset,
|
||||
enableAudio: hasMic,
|
||||
);
|
||||
_initializeFuture = controller.initialize();
|
||||
|
|
|
|||
|
|
@ -14,13 +14,14 @@ import 'package:twonly/src/utils/log.dart';
|
|||
/// `maximal_upload_size_of_single_media_size`. That makes the recording budget
|
||||
/// a plain division: plan limit divided by the rate the encoder writes at.
|
||||
///
|
||||
/// The rate is the part that cannot be known up front. Both platforms record
|
||||
/// `ResolutionPreset.high`, which is 720p, but what an encoder spends on those
|
||||
/// pixels differs by a factor of several between devices - the same minute
|
||||
/// that costs 18 MB on one phone costs far more on a phone whose camcorder
|
||||
/// profile asks for a high bitrate. So the first recording on a device is
|
||||
/// budgeted from a deliberately pessimistic estimate, and every recording
|
||||
/// after that is budgeted from what this device actually wrote.
|
||||
/// The rate is the part that cannot be known up front. Both platforms request
|
||||
/// `ResolutionPreset.veryHigh`, which is approximately 1080p, but what an
|
||||
/// encoder spends on those pixels differs by a factor of several between
|
||||
/// devices - the same minute that costs 18 MB on one phone costs far more on a
|
||||
/// phone whose camcorder profile asks for a high bitrate. So the first
|
||||
/// recording on a device is budgeted from a deliberately pessimistic estimate,
|
||||
/// and every recording after that is budgeted from what this device actually
|
||||
/// wrote.
|
||||
abstract final class VideoRecordingBudget {
|
||||
/// Mirrors `maximal_upload_size_of_single_media_size` of the server's plans.
|
||||
/// Every paid plan shares the same per-file limit.
|
||||
|
|
@ -40,8 +41,9 @@ abstract final class VideoRecordingBudget {
|
|||
/// AAC alongside the video, when the microphone is available.
|
||||
static const double _audioBitsPerSecond = 128000;
|
||||
|
||||
/// Falls back to 720p when the camera has not reported its preview size.
|
||||
static const Size _assumedRecordingSize = Size(1280, 720);
|
||||
/// Falls back to the requested 1080p when the camera has not reported its
|
||||
/// preview size.
|
||||
static const Size _assumedRecordingSize = Size(1920, 1080);
|
||||
|
||||
/// Leaves the container overhead and a scene busier than the one that was
|
||||
/// measured somewhere to go.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:flutter/foundation.dart' show setEquals;
|
||||
import 'package:flutter/foundation.dart' show listEquals, setEquals;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
|
@ -111,8 +111,10 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
ItemPositionsListener.create();
|
||||
int? focusedScrollItem;
|
||||
bool _receiverDeletedAccount = false;
|
||||
bool _emojiPickerShowing = false;
|
||||
Future<void>? _olderMessagesLoad;
|
||||
bool _hasMoreMessages = true;
|
||||
int _loadedMessagesRevision = 0;
|
||||
|
||||
Timer? _nextTypingIndicator;
|
||||
|
||||
|
|
@ -220,6 +222,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
}
|
||||
|
||||
Future<void> _applyLoadedMessages({required bool reportOpened}) async {
|
||||
final revision = ++_loadedMessagesRevision;
|
||||
final byId = <String, Message>{
|
||||
for (final message in _data.olderMessages) message.messageId: message,
|
||||
for (final message in _data.latestMessages) message.messageId: message,
|
||||
|
|
@ -229,6 +232,30 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
final byTime = a.createdAt.compareTo(b.createdAt);
|
||||
return byTime != 0 ? byTime : a.messageId.compareTo(b.messageId);
|
||||
});
|
||||
|
||||
// A media row must know its type before it is exposed to the list. Without
|
||||
// this initial read, stored media first renders as an empty bubble and then
|
||||
// grows to the preview height when the media watch emits. That changes the
|
||||
// extent of rows around the active scroll position and makes the positioned
|
||||
// list jump, especially while another page is being appended.
|
||||
final missingMediaIds = loadedMessages
|
||||
.map((message) => message.mediaId)
|
||||
.whereType<String>()
|
||||
.where((mediaId) => !_data.mediaFilesById.containsKey(mediaId))
|
||||
.toSet();
|
||||
final missingMediaFiles = missingMediaIds.isEmpty
|
||||
? const <MediaFile>[]
|
||||
: await twonlyDB.mediaFilesDao.getMediaFilesByIds(
|
||||
missingMediaIds.toList(),
|
||||
);
|
||||
if (!mounted || revision != _loadedMessagesRevision) return;
|
||||
|
||||
if (missingMediaFiles.isNotEmpty) {
|
||||
_data.mediaFilesById = {
|
||||
..._data.mediaFilesById,
|
||||
for (final mediaFile in missingMediaFiles) mediaFile.mediaId: mediaFile,
|
||||
};
|
||||
}
|
||||
_data.allMessages = loadedMessages;
|
||||
_data.messagesById = byId;
|
||||
_watchGroupActionsForLoadedRange();
|
||||
|
|
@ -315,8 +342,11 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
_data.mediaFilesById = {
|
||||
for (final mediaFile in mediaFiles) mediaFile.mediaId: mediaFile,
|
||||
};
|
||||
_notifyMessageDataChanged();
|
||||
_updateGalleryItems(force: true);
|
||||
// Publish the media map and matching gallery snapshot together. A
|
||||
// single database emission must not rebuild the entire message list
|
||||
// once with stale gallery data and immediately again with fresh data.
|
||||
_notifyMessageDataChanged();
|
||||
});
|
||||
_subscriptions.reactions = twonlyDB.reactionsDao
|
||||
.watchReactionsForMessages(messageIds)
|
||||
|
|
@ -489,6 +519,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
|
||||
if (!mounted) return;
|
||||
_data.chatItems = chatItems.reversed.toList();
|
||||
_updateGalleryItems(messages: storedMediaFiles);
|
||||
_notifyMessageDataChanged();
|
||||
|
||||
if (wasSentByMe) {
|
||||
|
|
@ -506,8 +537,6 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
_updateGalleryItems(messages: storedMediaFiles);
|
||||
}
|
||||
|
||||
Future<void> _reportMessagesOpened(
|
||||
|
|
@ -541,24 +570,56 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
.toSet();
|
||||
if (!force && setEquals(_data.galleryMessageIds, messageIds)) return;
|
||||
|
||||
final items = <String, MemoryItem>{};
|
||||
final messagesByMediaId = <String, List<Message>>{};
|
||||
for (final message in storedMediaMessages) {
|
||||
final mediaFile = _data.mediaFilesById[message.mediaId];
|
||||
if (mediaFile == null) continue;
|
||||
final mediaService = MediaFileService(mediaFile);
|
||||
if (!mediaService.imagePreviewAvailable) continue;
|
||||
items
|
||||
.putIfAbsent(
|
||||
mediaFile.mediaId,
|
||||
() => MemoryItem(mediaService: mediaService, messages: []),
|
||||
)
|
||||
.messages
|
||||
if (!MediaFileService(mediaFile).imagePreviewAvailable) continue;
|
||||
messagesByMediaId
|
||||
.putIfAbsent(mediaFile.mediaId, () => <Message>[])
|
||||
.add(message);
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
||||
final previousItems = {
|
||||
for (final item in _data.galleryItems)
|
||||
item.mediaService.mediaFile.mediaId: item,
|
||||
};
|
||||
final nextItems = <MemoryItem>[];
|
||||
for (final entry in messagesByMediaId.entries) {
|
||||
final mediaFile = _data.mediaFilesById[entry.key]!;
|
||||
final previous = previousItems[entry.key];
|
||||
if (previous != null &&
|
||||
previous.mediaService.mediaFile == mediaFile &&
|
||||
listEquals(previous.messages, entry.value)) {
|
||||
nextItems.add(previous);
|
||||
} else {
|
||||
nextItems.add(
|
||||
MemoryItem(
|
||||
mediaService: MediaFileService(mediaFile),
|
||||
messages: entry.value,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final previousOrder = _data.galleryItems
|
||||
.map((item) => item.mediaService.mediaFile.mediaId)
|
||||
.toList();
|
||||
final nextOrder = nextItems
|
||||
.map((item) => item.mediaService.mediaFile.mediaId)
|
||||
.toList();
|
||||
if (listEquals(previousOrder, nextOrder)) {
|
||||
// Preserve the list identity when its indexes are unchanged. Media rows
|
||||
// cache their gallery index, so replacing this list on every media status
|
||||
// update made every visible preview scan the whole gallery again.
|
||||
for (var i = 0; i < nextItems.length; i++) {
|
||||
_data.galleryItems[i] = nextItems[i];
|
||||
}
|
||||
} else {
|
||||
_data.galleryItems = nextItems;
|
||||
}
|
||||
_data.galleryMessageIds = messageIds;
|
||||
_data.galleryItems = items.values.toList();
|
||||
_notifyMessageDataChanged();
|
||||
}
|
||||
|
||||
Future<void> scrollToMessage(String messageId) async {
|
||||
|
|
@ -612,6 +673,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
return GestureDetector(
|
||||
onTap: () => FocusScope.of(context).unfocus(),
|
||||
child: Scaffold(
|
||||
resizeToAvoidBottomInset: !_emojiPickerShowing,
|
||||
appBar: AppBar(
|
||||
title: GestureDetector(
|
||||
onTap: () async {
|
||||
|
|
@ -672,6 +734,11 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
// Keep the system bottom inset reserved while the custom picker is
|
||||
// visible. Otherwise it reappears during the final keyboard-close
|
||||
// frames and makes the layout jump by roughly the navigation-bar
|
||||
// height.
|
||||
maintainBottomViewPadding: _emojiPickerShowing,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
|
|
@ -807,6 +874,10 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
quotesMessage = null;
|
||||
});
|
||||
},
|
||||
onEmojiVisibilityChanged: (visible) {
|
||||
if (_emojiPickerShowing == visible) return;
|
||||
setState(() => _emojiPickerShowing = visible);
|
||||
},
|
||||
),
|
||||
if (_receiverDeletedAccount)
|
||||
Text(context.lang.userDeletedAccount),
|
||||
|
|
|
|||
|
|
@ -146,6 +146,12 @@ class _ChatMediaEntryState extends State<ChatMediaEntry> {
|
|||
);
|
||||
|
||||
var imageBorderRadius = widget.borderRadius;
|
||||
// ChatMediaEntry is only used for non-audio media. Reserve the final
|
||||
// preview extent for every stored item, even while its thumbnail metadata
|
||||
// is still catching up, so the row cannot grow underneath the scroll
|
||||
// position. Widget media deliberately remains the compact status bubble.
|
||||
final reservesStoredPreviewSpace =
|
||||
widget.message.mediaStored && !widget.message.isWidgetMedia;
|
||||
|
||||
Widget additionalMessageData = Container();
|
||||
|
||||
|
|
@ -186,17 +192,13 @@ class _ChatMediaEntryState extends State<ChatMediaEntry> {
|
|||
onDoubleTap: onDoubleTap,
|
||||
onTap: (widget.message.type == MessageType.media.name) ? onTap : null,
|
||||
child: SizedBox(
|
||||
key: ValueKey('chat_media_frame_${widget.message.messageId}'),
|
||||
width: (widget.info.minWidth > 150)
|
||||
? widget.info.minWidth
|
||||
: (widget.message.mediaStored &&
|
||||
widget.mediaService.imagePreviewAvailable)
|
||||
: reservesStoredPreviewSpace
|
||||
? 150
|
||||
: null,
|
||||
height:
|
||||
(widget.message.mediaStored &&
|
||||
widget.mediaService.imagePreviewAvailable)
|
||||
? 271
|
||||
: null,
|
||||
height: reservesStoredPreviewSpace ? 271 : null,
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: ClipRRect(
|
||||
|
|
|
|||
|
|
@ -137,20 +137,15 @@ class _InChatMediaViewerState extends State<InChatMediaViewer> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final awaitsStoredPreview =
|
||||
!widget.message.isWidgetMedia &&
|
||||
widget.message.mediaStored &&
|
||||
!widget.mediaService.imagePreviewAvailable;
|
||||
if (widget.message.isWidgetMedia ||
|
||||
!widget.message.mediaStored ||
|
||||
!widget.mediaService.imagePreviewAvailable) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: 39,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.info.color,
|
||||
borderRadius: widget.borderRadius,
|
||||
),
|
||||
child: Padding(
|
||||
padding: widget.info.padding,
|
||||
child: Row(
|
||||
final status = Row(
|
||||
mainAxisSize: awaitsStoredPreview ? MainAxisSize.min : MainAxisSize.max,
|
||||
children: [
|
||||
MessageSendStateIcon(
|
||||
[widget.message],
|
||||
|
|
@ -166,7 +161,22 @@ class _InChatMediaViewerState extends State<InChatMediaViewer> {
|
|||
color: context.appColor(AppColor.chatMessageMeta),
|
||||
),
|
||||
],
|
||||
);
|
||||
return Container(
|
||||
constraints: awaitsStoredPreview
|
||||
? const BoxConstraints.expand()
|
||||
: const BoxConstraints(minHeight: 39),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.info.color,
|
||||
borderRadius: widget.borderRadius,
|
||||
),
|
||||
child: Padding(
|
||||
padding: widget.info.padding,
|
||||
child: awaitsStoredPreview
|
||||
? Center(
|
||||
child: FittedBox(fit: BoxFit.scaleDown, child: status),
|
||||
)
|
||||
: status,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import 'dart:io';
|
|||
import 'package:audio_waveforms/audio_waveforms.dart';
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
|
|
@ -15,6 +14,7 @@ import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
|||
import 'package:twonly/src/database/twonly.db.dart';
|
||||
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker.dart';
|
||||
import 'package:twonly/src/visual/views/camera/camera_send_to.view.dart';
|
||||
import 'package:twonly/src/visual/views/chats/chat_messages_components/bottom_sheets/share_additional.bottom_sheet.dart';
|
||||
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_audio_entry.dart';
|
||||
|
|
@ -30,6 +30,7 @@ class MessageInput extends StatefulWidget {
|
|||
required this.quotesMessage,
|
||||
required this.textFieldFocus,
|
||||
required this.onMessageSend,
|
||||
required this.onEmojiVisibilityChanged,
|
||||
required this.composing,
|
||||
super.key,
|
||||
});
|
||||
|
|
@ -38,6 +39,7 @@ class MessageInput extends StatefulWidget {
|
|||
final FocusNode textFieldFocus;
|
||||
final Message? quotesMessage;
|
||||
final VoidCallback onMessageSend;
|
||||
final ValueChanged<bool> onEmojiVisibilityChanged;
|
||||
|
||||
/// Published so the chat-open heartbeat can stay quiet while the typing
|
||||
/// announcement is already covering this conversation.
|
||||
|
|
@ -63,11 +65,13 @@ const _lockOffset = 100.0;
|
|||
enum RecordingState { none, recording, finished }
|
||||
|
||||
class _MessageInputState extends State<MessageInput>
|
||||
with SingleTickerProviderStateMixin {
|
||||
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
|
||||
late final TextEditingController _textFieldController;
|
||||
late final RecorderController recorderController;
|
||||
final bool isApple = Platform.isIOS;
|
||||
bool _emojiShowing = false;
|
||||
bool _switchingToSystemKeyboard = false;
|
||||
double _emojiPickerHeight = 300;
|
||||
bool _showSparks = false;
|
||||
bool _audioRecordingLock = false;
|
||||
bool _overLockIcon = false;
|
||||
|
|
@ -79,6 +83,7 @@ class _MessageInputState extends State<MessageInput>
|
|||
DateTime? _lastTextChangeTime;
|
||||
int? _contactId;
|
||||
Timer? _recordingTimer;
|
||||
Timer? _keyboardTransitionTimer;
|
||||
DateTime? _recordingStartTime;
|
||||
|
||||
/// Pulses the microphone icon while a recording is running so the composer
|
||||
|
|
@ -111,6 +116,7 @@ class _MessageInputState extends State<MessageInput>
|
|||
void initState() {
|
||||
super.initState();
|
||||
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_textFieldController = TextEditingController();
|
||||
_textFieldController.addListener(_handleTextChange);
|
||||
if (widget.group.draftMessage != null) {
|
||||
|
|
@ -138,10 +144,12 @@ class _MessageInputState extends State<MessageInput>
|
|||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_textFieldController.removeListener(_handleTextChange);
|
||||
widget.textFieldFocus.removeListener(_handleTextFocusChange);
|
||||
widget.textFieldFocus.dispose();
|
||||
_recordingTimer?.cancel();
|
||||
_keyboardTransitionTimer?.cancel();
|
||||
_recordingBlink.dispose();
|
||||
recorderController.dispose();
|
||||
_nextTypingIndicator?.cancel();
|
||||
|
|
@ -201,11 +209,78 @@ class _MessageInputState extends State<MessageInput>
|
|||
}
|
||||
|
||||
void _handleTextFocusChange() {
|
||||
if (widget.textFieldFocus.hasFocus) {
|
||||
if (widget.textFieldFocus.hasFocus &&
|
||||
_emojiShowing &&
|
||||
!_switchingToSystemKeyboard) {
|
||||
_showSystemKeyboard();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeMetrics() {
|
||||
if (!mounted || !_switchingToSystemKeyboard) return;
|
||||
final keyboardHeight = _currentKeyboardHeight();
|
||||
if (keyboardHeight < _emojiPickerHeight * 0.8) return;
|
||||
|
||||
_keyboardTransitionTimer?.cancel();
|
||||
_keyboardTransitionTimer = Timer(
|
||||
const Duration(milliseconds: 80),
|
||||
_finishSystemKeyboardTransition,
|
||||
);
|
||||
}
|
||||
|
||||
void _toggleKeyboard() {
|
||||
if (_emojiShowing) {
|
||||
_showSystemKeyboard();
|
||||
} else {
|
||||
_showEmojiPicker();
|
||||
}
|
||||
}
|
||||
|
||||
void _showEmojiPicker() {
|
||||
_keyboardTransitionTimer?.cancel();
|
||||
final keyboardHeight = _currentKeyboardHeight();
|
||||
setState(() {
|
||||
_switchingToSystemKeyboard = false;
|
||||
if (keyboardHeight > 0) {
|
||||
_emojiPickerHeight = keyboardHeight;
|
||||
}
|
||||
_emojiShowing = true;
|
||||
});
|
||||
widget.onEmojiVisibilityChanged(true);
|
||||
widget.textFieldFocus.unfocus();
|
||||
}
|
||||
|
||||
double _currentKeyboardHeight() {
|
||||
final view = View.of(context);
|
||||
final keyboardHeight = view.viewInsets.bottom / view.devicePixelRatio;
|
||||
final safeAreaBottom = view.viewPadding.bottom / view.devicePixelRatio;
|
||||
return (keyboardHeight - safeAreaBottom).clamp(0, double.infinity);
|
||||
}
|
||||
|
||||
void _showSystemKeyboard() {
|
||||
if (_switchingToSystemKeyboard) return;
|
||||
setState(() => _switchingToSystemKeyboard = true);
|
||||
widget.textFieldFocus.requestFocus();
|
||||
|
||||
// Some platforms only report the final keyboard inset. The fallback still
|
||||
// performs a single picker/keyboard swap instead of following every inset
|
||||
// animation frame.
|
||||
_keyboardTransitionTimer?.cancel();
|
||||
_keyboardTransitionTimer = Timer(
|
||||
const Duration(milliseconds: 700),
|
||||
_finishSystemKeyboardTransition,
|
||||
);
|
||||
}
|
||||
|
||||
void _finishSystemKeyboardTransition() {
|
||||
if (!mounted || !_switchingToSystemKeyboard) return;
|
||||
_keyboardTransitionTimer?.cancel();
|
||||
setState(() {
|
||||
_switchingToSystemKeyboard = false;
|
||||
_emojiShowing = false;
|
||||
});
|
||||
}
|
||||
widget.onEmojiVisibilityChanged(false);
|
||||
}
|
||||
|
||||
Future<void> _startAudioRecording() async {
|
||||
|
|
@ -387,16 +462,7 @@ class _MessageInputState extends State<MessageInput>
|
|||
children: [
|
||||
if (_recordingState != RecordingState.recording)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_emojiShowing = !_emojiShowing;
|
||||
if (_emojiShowing) {
|
||||
widget.textFieldFocus.unfocus();
|
||||
} else {
|
||||
widget.textFieldFocus.requestFocus();
|
||||
}
|
||||
});
|
||||
},
|
||||
onTap: _toggleKeyboard,
|
||||
child: ColoredBox(
|
||||
color: Colors.transparent,
|
||||
child: Padding(
|
||||
|
|
@ -407,7 +473,7 @@ class _MessageInputState extends State<MessageInput>
|
|||
right: 8,
|
||||
),
|
||||
child: FaIcon(
|
||||
size: 20,
|
||||
size: _emojiShowing ? 18 : 20,
|
||||
_emojiShowing
|
||||
? FontAwesomeIcons.keyboard
|
||||
: FontAwesomeIcons.faceSmile,
|
||||
|
|
@ -698,13 +764,8 @@ class _MessageInputState extends State<MessageInput>
|
|||
setState(() {});
|
||||
},
|
||||
config: Config(
|
||||
height: 300,
|
||||
height: _emojiPickerHeight,
|
||||
locale: Localizations.localeOf(context),
|
||||
viewOrderConfig: const ViewOrderConfig(
|
||||
top: EmojiPickerItem.searchBar,
|
||||
// middle: EmojiPickerItem.emojiView,
|
||||
bottom: EmojiPickerItem.categoryBar,
|
||||
),
|
||||
emojiTextStyle: TextStyle(
|
||||
fontSize: 24 * (Platform.isIOS ? 1.2 : 1),
|
||||
),
|
||||
|
|
@ -718,7 +779,6 @@ class _MessageInputState extends State<MessageInput>
|
|||
),
|
||||
categoryViewConfig: CategoryViewConfig(
|
||||
backgroundColor: context.color.surfaceContainer,
|
||||
dividerColor: context.color.outlineVariant,
|
||||
indicatorColor: context.color.primary,
|
||||
iconColorSelected: context.color.primary,
|
||||
iconColor: context.color.secondary,
|
||||
|
|
|
|||
|
|
@ -396,13 +396,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.1"
|
||||
emoji_picker_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "dependencies/emoji_picker_flutter"
|
||||
relative: true
|
||||
source: path
|
||||
version: "4.5.3"
|
||||
exif:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -1535,7 +1528,7 @@ packages:
|
|||
source: hosted
|
||||
version: "7.2.0"
|
||||
shared_preferences:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ dependencies:
|
|||
vector_graphics: ^1.1.19
|
||||
in_app_purchase: ^3.3.0
|
||||
go_router: ^17.1.0
|
||||
shared_preferences: ^2.5.5
|
||||
|
||||
|
||||
# Trusted publisher fluttercommunity.dev
|
||||
|
|
@ -88,7 +89,6 @@ dependencies:
|
|||
audio_waveforms: ^2.0.2
|
||||
avatar_maker: ^1.5.0
|
||||
blurhash_dart: ^1.2.1
|
||||
emoji_picker_flutter: ^4.5.3
|
||||
exif: ^3.3.0
|
||||
sprintf: ^7.0.0
|
||||
flutter_blurhash: ^0.9.1
|
||||
|
|
@ -127,8 +127,6 @@ dependency_overrides:
|
|||
path: ./dependencies/avatar_maker
|
||||
blurhash_dart:
|
||||
path: ./dependencies/blurhash_dart
|
||||
emoji_picker_flutter:
|
||||
path: ./dependencies/emoji_picker_flutter
|
||||
exif:
|
||||
path: ./dependencies/exif
|
||||
sprintf:
|
||||
|
|
|
|||
108
test/visual/chat_media_entry_test.dart
Normal file
108
test/visual/chat_media_entry_test.dart
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||
import 'package:twonly/src/database/tables/messages.table.dart';
|
||||
import 'package:twonly/src/database/twonly.db.dart';
|
||||
import 'package:twonly/src/localization/generated/app_localizations.dart';
|
||||
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||
import 'package:twonly/src/visual/themes/light.dart';
|
||||
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_media_entry.dart';
|
||||
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/common.dart';
|
||||
|
||||
Message buildStoredMessage() => Message(
|
||||
groupId: 'group-1',
|
||||
messageId: 'message-1',
|
||||
type: MessageType.media.name,
|
||||
mediaId: 'media-1',
|
||||
mediaStored: true,
|
||||
mediaReopened: false,
|
||||
isDeletedFromSender: false,
|
||||
isWidgetMedia: false,
|
||||
isStory: false,
|
||||
openedAt: DateTime(2026),
|
||||
createdAt: DateTime(2026),
|
||||
);
|
||||
|
||||
MediaFile buildStoredVideoWithoutThumbnail() => MediaFile(
|
||||
mediaId: 'media-1',
|
||||
type: MediaType.video,
|
||||
cloudState: CloudState.none,
|
||||
requiresAuthentication: false,
|
||||
stored: true,
|
||||
isDraftMedia: false,
|
||||
isWidgetMedia: false,
|
||||
isFavorite: false,
|
||||
hasCropAnalyzed: false,
|
||||
hasThumbnail: false,
|
||||
galleryExportPending: false,
|
||||
createdAt: DateTime(2026),
|
||||
);
|
||||
|
||||
Group buildGroup() => Group(
|
||||
groupId: 'group-1',
|
||||
isGroupAdmin: true,
|
||||
isDirectChat: true,
|
||||
pinned: false,
|
||||
archived: false,
|
||||
joinedGroup: true,
|
||||
leftGroup: false,
|
||||
deletedContent: false,
|
||||
stateVersionId: 0,
|
||||
groupName: 'Test',
|
||||
totalMediaCounter: 1,
|
||||
alsoBestFriend: false,
|
||||
deleteMessagesAfterMilliseconds: 0,
|
||||
createdAt: DateTime(2026),
|
||||
flameCounter: 0,
|
||||
maxFlameCounter: 0,
|
||||
lastMessageExchange: DateTime(2026),
|
||||
);
|
||||
|
||||
BubbleInfo buildInfo() => BubbleInfo()
|
||||
..text = ''
|
||||
..textColor = Colors.white
|
||||
..displayTime = false
|
||||
..displayUserName = ''
|
||||
..color = Colors.blue
|
||||
..expanded = false
|
||||
..spacerWidth = 0
|
||||
..padding = EdgeInsets.zero
|
||||
..minWidth = 0;
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'stored media reserves its final height before the thumbnail is ready',
|
||||
(tester) async {
|
||||
final message = buildStoredMessage();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: lightTheme,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: ChatMediaEntry(
|
||||
message: message,
|
||||
group: buildGroup(),
|
||||
galleryItems: const [],
|
||||
mediaService: MediaFileService(
|
||||
buildStoredVideoWithoutThumbnail(),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
info: buildInfo(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final frame = find.byKey(
|
||||
ValueKey('chat_media_frame_${message.messageId}'),
|
||||
);
|
||||
expect(frame, findsOneWidget);
|
||||
expect(tester.getSize(frame), const Size(150, 271));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
/*
|
||||
* Modified version of https://github.com/Fintasys/emoji_picker_flutter
|
||||
* MIT License
|
||||
* Copyright (c) 2024 Stefan Humm
|
||||
*/
|
||||
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker.dart';
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker_internal_utils.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
skinToneTests();
|
||||
emojiModelTests();
|
||||
}
|
||||
|
||||
void skinToneTests() {
|
||||
final internalUtils = EmojiPickerInternalUtils();
|
||||
final utils = EmojiPickerUtils();
|
||||
|
||||
test('applySkinTone()', () {
|
||||
expect(
|
||||
utils.applySkinTone(const Emoji('👍', ''), SkinTone.light).emoji,
|
||||
'👍🏻',
|
||||
);
|
||||
expect(
|
||||
utils.applySkinTone(const Emoji('🏊♂️', ''), SkinTone.mediumDark).emoji,
|
||||
'🏊🏾♂️',
|
||||
);
|
||||
expect(
|
||||
utils.applySkinTone(const Emoji('👱♀️', ''), SkinTone.dark).emoji,
|
||||
'👱🏿♀️',
|
||||
);
|
||||
});
|
||||
|
||||
test('applySkinTone() strips an existing tone before re-applying', () {
|
||||
// Re-applying a tone to an already toned glyph must not produce an
|
||||
// invalid double-modifier sequence (e.g. 👋🏻🏽).
|
||||
expect(
|
||||
utils.applySkinTone(const Emoji('👍🏻', ''), SkinTone.medium).emoji,
|
||||
'👍🏽',
|
||||
);
|
||||
expect(
|
||||
utils.applySkinTone(const Emoji('🏊🏾♂️', ''), SkinTone.light).emoji,
|
||||
'🏊🏻♂️',
|
||||
);
|
||||
});
|
||||
|
||||
test('removeSkinTone()', () {
|
||||
expect(internalUtils.removeSkinTone(const Emoji('👍🏻', '')).emoji, '👍');
|
||||
expect(
|
||||
internalUtils.removeSkinTone(const Emoji('🏊🏾♂️', '')).emoji,
|
||||
'🏊♂️',
|
||||
);
|
||||
expect(
|
||||
internalUtils.removeSkinTone(const Emoji('👱🏿♀️', '')).emoji,
|
||||
'👱♀️',
|
||||
);
|
||||
});
|
||||
|
||||
test('EmojiPickerUtils.removeSkinTone()', () {
|
||||
expect(utils.removeSkinTone(const Emoji('👍🏻', '')).emoji, '👍');
|
||||
expect(utils.removeSkinTone(const Emoji('👍', '')).emoji, '👍');
|
||||
});
|
||||
|
||||
test('extractSkinTone()', () {
|
||||
expect(utils.extractSkinTone(const Emoji('👍🏽', '')), SkinTone.medium);
|
||||
expect(
|
||||
utils.extractSkinTone(const Emoji('🏊🏾♂️', '')),
|
||||
SkinTone.mediumDark,
|
||||
);
|
||||
// Base glyph without a modifier -> null
|
||||
expect(utils.extractSkinTone(const Emoji('👍', '')), isNull);
|
||||
});
|
||||
|
||||
test('applyDisplaySkinTone()', () {
|
||||
const toneable = Emoji('👍', '', hasSkinTone: true);
|
||||
const plain = Emoji('😀', '');
|
||||
const remember = SkinToneConfig(rememberSkinTone: true);
|
||||
|
||||
// No remembered tone -> emoji is returned unchanged
|
||||
expect(utils.applyDisplaySkinTone(toneable, remember, null).emoji, '👍');
|
||||
|
||||
// Emoji without skin tone support is never modified
|
||||
expect(
|
||||
utils.applyDisplaySkinTone(plain, remember, SkinTone.dark).emoji,
|
||||
'😀',
|
||||
);
|
||||
|
||||
// Remembered tone is applied and hasSkinTone is preserved
|
||||
final toned = utils.applyDisplaySkinTone(
|
||||
toneable,
|
||||
remember,
|
||||
SkinTone.medium,
|
||||
);
|
||||
expect(toned.emoji, '👍🏽');
|
||||
expect(toned.hasSkinTone, isTrue);
|
||||
|
||||
// rememberSkinTone disabled -> tone is not applied
|
||||
expect(
|
||||
utils
|
||||
.applyDisplaySkinTone(
|
||||
toneable,
|
||||
const SkinToneConfig(rememberSkinTone: false),
|
||||
SkinTone.medium,
|
||||
)
|
||||
.emoji,
|
||||
'👍',
|
||||
);
|
||||
|
||||
// Skin tones disabled entirely -> tone is not applied
|
||||
expect(
|
||||
utils
|
||||
.applyDisplaySkinTone(
|
||||
toneable,
|
||||
const SkinToneConfig(enabled: false, rememberSkinTone: true),
|
||||
SkinTone.medium,
|
||||
)
|
||||
.emoji,
|
||||
'👍',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void emojiModelTests() {
|
||||
test('encode Emoji', () {
|
||||
final encode = const Emoji('🤣', 'name');
|
||||
expect(encode.toJson(), <String, dynamic>{
|
||||
'emoji': '🤣',
|
||||
'name': 'name',
|
||||
'hasSkinTone': false,
|
||||
});
|
||||
});
|
||||
|
||||
test('decode Emoji without hasSkinTone property', () {
|
||||
final decode = <String, dynamic>{'name': 'name', 'emoji': '🤣'};
|
||||
final result = Emoji.fromJson(decode);
|
||||
expect(result.name, 'name');
|
||||
expect(result.emoji, '🤣');
|
||||
expect(result.hasSkinTone, false);
|
||||
});
|
||||
|
||||
test('decode Emoji with hasSkinTone property', () {
|
||||
final decode = <String, dynamic>{
|
||||
'name': 'name',
|
||||
'emoji': '🤣',
|
||||
'hasSkinTone': true,
|
||||
};
|
||||
final result = Emoji.fromJson(decode);
|
||||
expect(result.name, 'name');
|
||||
expect(result.emoji, '🤣');
|
||||
expect(result.hasSkinTone, true);
|
||||
});
|
||||
}
|
||||
378
test/visual/components/emoji_picker/emoji_picker_test.dart
Normal file
378
test/visual/components/emoji_picker/emoji_picker_test.dart
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
/*
|
||||
* Modified version of https://github.com/Fintasys/emoji_picker_flutter
|
||||
* MIT License
|
||||
* Copyright (c) 2024 Stefan Humm
|
||||
*/
|
||||
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker.dart';
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker_internal_utils.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', () {
|
||||
// Caches are static and would otherwise leak between test cases.
|
||||
setUp(EmojiPickerInternalUtils.resetCaches);
|
||||
|
||||
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('🙂', 'face | happy | slightly | smile | smiling')),
|
||||
);
|
||||
|
||||
// Check if the category been passed to the 'onEmojiSelected' callback
|
||||
expect(_categorySelected, equals(Category.SMILEYS));
|
||||
});
|
||||
|
||||
testWidgets('Shows the redesigned header and one continuous emoji list', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
var stickerPressed = false;
|
||||
final controller = EmojiPickerController(
|
||||
initialCategory: Category.SMILEYS,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: EmojiPicker(
|
||||
controller: controller,
|
||||
config: Config(
|
||||
height: 400,
|
||||
categoryViewConfig: const CategoryViewConfig(
|
||||
recentTabBehavior: RecentTabBehavior.NONE,
|
||||
),
|
||||
bottomActionBarConfig: BottomActionBarConfig(
|
||||
onStickerButtonPressed: () => stickerPressed = true,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('emojiPickerDragHandle')), findsOneWidget);
|
||||
expect(find.text('Emoji'), findsOneWidget);
|
||||
expect(find.text('Sticker'), findsOneWidget);
|
||||
expect(find.byType(CustomScrollView), findsOneWidget);
|
||||
expect(find.byType(PageView), findsNothing);
|
||||
expect(const EmojiViewConfig().categorySpacing, 12);
|
||||
expect(const ResizeConfig().showDragHandle, isTrue);
|
||||
expect(const ResizeConfig().handleWidth, 60);
|
||||
expect(const ResizeConfig().handleHeight, 3);
|
||||
expect(const ResizeConfig().handleHitAreaHeight, 23);
|
||||
expect(const ResizeConfig().handleBorderRadius, 32);
|
||||
|
||||
final tabBar = tester.widget<TabBar>(find.byType(TabBar));
|
||||
expect(tabBar.indicator, isA<BoxDecoration>());
|
||||
expect(tabBar.dividerColor, Colors.transparent);
|
||||
|
||||
await tester.tap(find.text('Sticker'));
|
||||
expect(stickerPressed, isTrue);
|
||||
|
||||
await tester.tap(find.byIcon(const CategoryIcons().flagIcon));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(controller.currentCategory, Category.FLAGS);
|
||||
final scrollable = tester.state<ScrollableState>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('emojiScrollView')),
|
||||
matching: find.byType(Scrollable),
|
||||
),
|
||||
);
|
||||
expect(scrollable.position.pixels, greaterThan(0));
|
||||
});
|
||||
|
||||
testWidgets('Drag handle grows picker from its configured height', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
var showing = true;
|
||||
late StateSetter setHarnessState;
|
||||
final pickerController = EmojiPickerController();
|
||||
const pickerColor = Color(0xFF123456);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: ThemeData.dark(),
|
||||
home: Scaffold(
|
||||
body: StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
setHarnessState = setState;
|
||||
return Offstage(
|
||||
offstage: !showing,
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: EmojiPicker(
|
||||
controller: pickerController,
|
||||
config: const Config(
|
||||
height: 256,
|
||||
resizeConfig: ResizeConfig(maxHeight: 420),
|
||||
emojiViewConfig: EmojiViewConfig(
|
||||
backgroundColor: pickerColor,
|
||||
),
|
||||
categoryViewConfig: CategoryViewConfig(
|
||||
recentTabBehavior: RecentTabBehavior.NONE,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final picker = find.byKey(const Key('emojiPickerResizableContainer'));
|
||||
expect(tester.getSize(picker).height, 256);
|
||||
|
||||
final handleBackground = tester.widget<DecoratedBox>(
|
||||
find.byKey(const Key('emojiPickerDragHandleBackground')),
|
||||
);
|
||||
final handleDecoration = handleBackground.decoration as BoxDecoration;
|
||||
expect(handleDecoration.color, pickerColor);
|
||||
expect(
|
||||
handleDecoration.borderRadius,
|
||||
const BorderRadius.vertical(top: Radius.circular(18)),
|
||||
);
|
||||
final dragIndicator = tester.widget<Container>(
|
||||
find.byKey(const Key('emojiPickerDragIndicator')),
|
||||
);
|
||||
final dragDecoration = dragIndicator.decoration as BoxDecoration;
|
||||
expect(dragDecoration.color, ThemeData.dark().colorScheme.outline);
|
||||
expect(dragDecoration.borderRadius, BorderRadius.circular(32));
|
||||
|
||||
await tester.drag(
|
||||
find.byKey(const Key('emojiPickerDragHandle')),
|
||||
const Offset(0, -100),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.getSize(picker).height, 356);
|
||||
|
||||
setHarnessState(() => showing = false);
|
||||
await tester.pump();
|
||||
setHarnessState(() => showing = true);
|
||||
await tester.pump();
|
||||
|
||||
expect(tester.getSize(picker).height, 256);
|
||||
});
|
||||
|
||||
testWidgets('Drag handle can be hidden', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: EmojiPicker(
|
||||
config: Config(
|
||||
height: 256,
|
||||
resizeConfig: ResizeConfig(showDragHandle: false),
|
||||
categoryViewConfig: CategoryViewConfig(
|
||||
recentTabBehavior: RecentTabBehavior.NONE,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('emojiPickerDragHandle')), findsNothing);
|
||||
expect(
|
||||
tester
|
||||
.getSize(find.byKey(const Key('emojiPickerResizableContainer')))
|
||||
.height,
|
||||
256,
|
||||
);
|
||||
});
|
||||
|
||||
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('+1 | good | hand | like | thumb | up | yes'),
|
||||
);
|
||||
expect(_emojiSelected?.hasSkinTone, equals(true));
|
||||
|
||||
// Check if the category been passed to the 'onEmojiSelected' callback
|
||||
expect(_categorySelected, equals(Category.SMILEYS));
|
||||
});
|
||||
|
||||
testWidgets('Clips overflow when constrained tighter than natural height', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final _controller = TextEditingController();
|
||||
|
||||
// Constrain the picker far below the natural sum of the category bar
|
||||
// and bottom action bar to force the inner Column to overflow (#256).
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: SizedBox(
|
||||
width: 300,
|
||||
height: 30,
|
||||
child: EmojiPicker(
|
||||
textEditingController: _controller,
|
||||
config: const Config(
|
||||
height: 30,
|
||||
categoryViewConfig: CategoryViewConfig(
|
||||
recentTabBehavior: RecentTabBehavior.NONE,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// A RenderFlex overflow is reported in debug mode; drain it so the test
|
||||
// can assert the overflow is clipped rather than left painting stripes.
|
||||
final exception = tester.takeException();
|
||||
expect(
|
||||
exception == null || exception.toString().contains('overflowed'),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
// The inner view Column is wrapped in a ClipRect that clips the
|
||||
// overflow (#256). Match that specific ClipRect (the one whose direct
|
||||
// child is the Column) rather than the incidental ClipRects the grid
|
||||
// and scroll views introduce.
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(EmojiContainer),
|
||||
matching: find.byWidgetPredicate(
|
||||
(widget) => widget is ClipRect && widget.child is Column,
|
||||
),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Modified version of https://github.com/Fintasys/emoji_picker_flutter
|
||||
* MIT License
|
||||
* Copyright (c) 2024 Stefan Humm
|
||||
*/
|
||||
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker.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();
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* Modified version of https://github.com/Fintasys/emoji_picker_flutter
|
||||
* MIT License
|
||||
* Copyright (c) 2024 Stefan Humm
|
||||
*/
|
||||
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker.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();
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
158
test/visual/components/emoji_picker/recent_emoji_state_test.dart
Normal file
158
test/visual/components/emoji_picker/recent_emoji_state_test.dart
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
/*
|
||||
* Modified version of https://github.com/Fintasys/emoji_picker_flutter
|
||||
* MIT License
|
||||
* Copyright (c) 2024 Stefan Humm
|
||||
*/
|
||||
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker.dart';
|
||||
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker_internal_utils.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Test harness that lets us trigger a rebuild of [EmojiPicker] from its
|
||||
/// parent, mimicking an unrelated `setState()` higher up in the widget tree.
|
||||
class _RebuildHarness extends StatefulWidget {
|
||||
const _RebuildHarness({
|
||||
super.key,
|
||||
required this.pickerKey,
|
||||
required this.config,
|
||||
});
|
||||
|
||||
final GlobalKey<EmojiPickerState> pickerKey;
|
||||
final Config config;
|
||||
|
||||
@override
|
||||
State<_RebuildHarness> createState() => _RebuildHarnessState();
|
||||
}
|
||||
|
||||
class _RebuildHarnessState extends State<_RebuildHarness> {
|
||||
int _rebuildCounter = 0;
|
||||
|
||||
/// Force the parent to rebuild without touching the picker's own state.
|
||||
void forceRebuild() => setState(() => _rebuildCounter++);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
// Reference the counter so the framework actually rebuilds the subtree.
|
||||
body: Column(
|
||||
children: [
|
||||
Text('rebuilds: $_rebuildCounter'),
|
||||
Expanded(
|
||||
child: EmojiPicker(
|
||||
key: widget.pickerKey,
|
||||
config: widget.config,
|
||||
onEmojiSelected: (_, _) {},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
const config = Config(
|
||||
height: 400,
|
||||
categoryViewConfig: CategoryViewConfig(
|
||||
initCategory: Category.RECENT,
|
||||
recentTabBehavior: RecentTabBehavior.RECENT,
|
||||
),
|
||||
);
|
||||
|
||||
// Deliberately not part of the built-in set. In the continuous picker a
|
||||
// normal emoji can be visible both in Recents and in its original category.
|
||||
const emoji = Emoji('recent-only', 'recent-only');
|
||||
|
||||
setUp(() {
|
||||
// Start every test with an empty recent-emoji store.
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
// The internal caches are static and would otherwise leak the recent
|
||||
// emoji added by one test into the next.
|
||||
EmojiPickerInternalUtils.resetCaches();
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'A recent update with refresh:false must not leak into the UI on an '
|
||||
'unrelated parent rebuild',
|
||||
(tester) async {
|
||||
final pickerKey = GlobalKey<EmojiPickerState>();
|
||||
final harnessKey = GlobalKey<_RebuildHarnessState>();
|
||||
|
||||
await tester.pumpWidget(
|
||||
_RebuildHarness(key: harnessKey, pickerKey: pickerKey, config: config),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Recent tab starts empty.
|
||||
expect(find.text('No Recents'), findsOneWidget);
|
||||
expect(find.text(emoji.emoji), findsNothing);
|
||||
|
||||
// Persist a recent emoji through the public API. This calls
|
||||
// `updateRecentEmoji(..., refresh: false)` under the hood - the same
|
||||
// path taken when an emoji is tapped while the RECENT tab is visible.
|
||||
await EmojiPickerUtils().addEmojiToRecentlyUsed(
|
||||
key: pickerKey,
|
||||
emoji: emoji,
|
||||
config: config,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// refresh:false means the visible list must NOT change yet.
|
||||
expect(
|
||||
find.text('No Recents'),
|
||||
findsOneWidget,
|
||||
reason: 'refresh:false should not update the UI immediately',
|
||||
);
|
||||
expect(find.text(emoji.emoji), findsNothing);
|
||||
|
||||
// Now an unrelated part of the app rebuilds the widget tree.
|
||||
harnessKey.currentState!.forceRebuild();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// The recent list must STILL be unchanged: a refresh:false update is not
|
||||
// allowed to surface on a stray rebuild triggered from a parent widget.
|
||||
expect(
|
||||
find.text('No Recents'),
|
||||
findsOneWidget,
|
||||
reason: 'a refresh:false update must not leak in on a parent rebuild',
|
||||
);
|
||||
expect(find.text(emoji.emoji), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('A recent update with refresh:true updates the UI immediately', (
|
||||
tester,
|
||||
) async {
|
||||
final pickerKey = GlobalKey<EmojiPickerState>();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: EmojiPicker(
|
||||
key: pickerKey,
|
||||
config: config,
|
||||
onEmojiSelected: (_, _) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('No Recents'), findsOneWidget);
|
||||
|
||||
// An explicit refresh must render the new recent emoji right away.
|
||||
pickerKey.currentState!.updateRecentEmoji([
|
||||
RecentEmoji(emoji, 1),
|
||||
], refresh: true);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('No Recents'), findsNothing);
|
||||
expect(find.text(emoji.emoji), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
|
@ -47,4 +47,21 @@ void main() {
|
|||
expect(middle[color], Color.lerp(color.light, color.dark, 0.5));
|
||||
}
|
||||
});
|
||||
|
||||
test('message-kind indicators keep their original fixed colors', () {
|
||||
const expectedColors = {
|
||||
AppColor.messageText: Color(0xFF448AFF),
|
||||
AppColor.messageRestore: Color(0xFFFF9800),
|
||||
AppColor.messageWidget: Color(0xFF9B59B6),
|
||||
AppColor.messageVideo: Color(0xFFF321D0),
|
||||
AppColor.messageAudio: Color(0xFFFC9555),
|
||||
AppColor.messageImage: Color(0xFFFF5252),
|
||||
};
|
||||
|
||||
for (final MapEntry(key: color, value: expected)
|
||||
in expectedColors.entries) {
|
||||
expect(color.light, expected);
|
||||
expect(color.dark, expected);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ void main() {
|
|||
late Directory tempDir;
|
||||
|
||||
const hd = Size(1280, 720);
|
||||
const fullHd = Size(1920, 1080);
|
||||
|
||||
setUp(() {
|
||||
tempDir = Directory.systemTemp.createTempSync('twonly_video_budget_test_');
|
||||
|
|
@ -54,14 +55,14 @@ void main() {
|
|||
expect(free.inSeconds, inInclusiveRange(45, 60));
|
||||
});
|
||||
|
||||
test('an unknown recording size falls back to 720p', () {
|
||||
test('an unknown recording size falls back to 1080p', () {
|
||||
expect(
|
||||
budgetFor(SubscriptionPlan.Free, size: null),
|
||||
budgetFor(SubscriptionPlan.Free),
|
||||
budgetFor(SubscriptionPlan.Free, size: fullHd),
|
||||
);
|
||||
expect(
|
||||
budgetFor(SubscriptionPlan.Free, size: Size.zero),
|
||||
budgetFor(SubscriptionPlan.Free),
|
||||
budgetFor(SubscriptionPlan.Free, size: fullHd),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -69,7 +70,7 @@ void main() {
|
|||
final hdBudget = budgetFor(SubscriptionPlan.Free);
|
||||
final fullHdBudget = budgetFor(
|
||||
SubscriptionPlan.Free,
|
||||
size: const Size(1920, 1080),
|
||||
size: fullHd,
|
||||
);
|
||||
expect(fullHdBudget.inMilliseconds, lessThan(hdBudget.inMilliseconds));
|
||||
});
|
||||
|
|
@ -118,7 +119,7 @@ void main() {
|
|||
);
|
||||
// A different recording size has not been measured.
|
||||
expect(
|
||||
budgetFor(SubscriptionPlan.Free, size: const Size(1920, 1080)),
|
||||
budgetFor(SubscriptionPlan.Free, size: fullHd),
|
||||
lessThan(budgetFor(SubscriptionPlan.Free)),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue