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
|
package eu.twonly
|
||||||
|
|
||||||
|
import android.graphics.Paint
|
||||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import dev.darttools.flutter_android_volume_keydown.FlutterAndroidVolumeKeydownPlugin.eventSink
|
import dev.darttools.flutter_android_volume_keydown.FlutterAndroidVolumeKeydownPlugin.eventSink
|
||||||
|
|
@ -10,6 +11,7 @@ import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import io.crates.keyring.Keyring
|
import io.crates.keyring.Keyring
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
|
import androidx.core.graphics.PaintCompat
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
@ -25,6 +27,7 @@ import android.content.pm.PackageManager
|
||||||
|
|
||||||
class MainActivity : FlutterFragmentActivity() {
|
class MainActivity : FlutterFragmentActivity() {
|
||||||
private val CHANNEL = "eu.twonly/photo_picker"
|
private val CHANNEL = "eu.twonly/photo_picker"
|
||||||
|
private val emojiPaint = Paint()
|
||||||
private var pendingResult: MethodChannel.Result? = null
|
private var pendingResult: MethodChannel.Result? = null
|
||||||
private var pendingLocationPermissionResult: MethodChannel.Result? = null
|
private var pendingLocationPermissionResult: MethodChannel.Result? = null
|
||||||
|
|
||||||
|
|
@ -95,6 +98,18 @@ class MainActivity : FlutterFragmentActivity() {
|
||||||
WidgetRuntimeChannel.configure(flutterEngine, applicationContext)
|
WidgetRuntimeChannel.configure(flutterEngine, applicationContext)
|
||||||
WebxdcChannel.configure(flutterEngine, this)
|
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 ->
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"pickImages" -> {
|
"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 result.stdout.strip().split('/')[-1]
|
||||||
return 'main'
|
return 'main'
|
||||||
|
|
||||||
def integrate_package(folder_name, data, cache_dir, out_dir):
|
def integrate_package(folder_name, data, cache_dir, out_dir, cache_only=False):
|
||||||
repo_url = data['git']
|
|
||||||
keep_list = ["lib", "LICENSE", "pubspec.yaml", "android", "ios", "darwin"]
|
keep_list = ["lib", "LICENSE", "pubspec.yaml", "android", "ios", "darwin"]
|
||||||
if "keep" in data:
|
if "keep" in data:
|
||||||
keep_list += [item.rstrip('/') for item in data['keep']]
|
keep_list += [item.rstrip('/') for item in data['keep']]
|
||||||
|
|
@ -50,27 +49,36 @@ def integrate_package(folder_name, data, cache_dir, out_dir):
|
||||||
print(f"Processing {folder_name}...")
|
print(f"Processing {folder_name}...")
|
||||||
|
|
||||||
cache_path = os.path.join(cache_dir, folder_name)
|
cache_path = os.path.join(cache_dir, folder_name)
|
||||||
if not os.path.exists(cache_path):
|
if cache_only:
|
||||||
subprocess.run(["git", "clone", repo_url, cache_path], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
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:
|
else:
|
||||||
result = subprocess.run(["git", "fetch", "--all"], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=cache_path)
|
repo_url = data['git']
|
||||||
if result.returncode != 0:
|
if not os.path.exists(cache_path):
|
||||||
print_yellow(f"Warning: Could not fetch updates for {folder_name}. You might be offline.")
|
subprocess.run(["git", "clone", repo_url, cache_path], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
else:
|
||||||
if "commit" in data:
|
result = subprocess.run(["git", "fetch", "--all"], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=cache_path)
|
||||||
commit_hash = data["commit"]
|
if result.returncode != 0:
|
||||||
subprocess.run(["git", "checkout", commit_hash], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=cache_path)
|
print_yellow(f"Warning: Could not fetch updates for {folder_name}. You might be offline.")
|
||||||
elif "tag" in data:
|
|
||||||
tag_name = data["tag"]
|
if "commit" in data:
|
||||||
subprocess.run(["git", "checkout", tag_name], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=cache_path)
|
commit_hash = data["commit"]
|
||||||
else:
|
subprocess.run(["git", "checkout", commit_hash], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=cache_path)
|
||||||
print_yellow(f"Warning: No commit or tag specified for {folder_name}. Using default branch.")
|
elif "tag" in data:
|
||||||
default_branch = get_default_branch(cache_path)
|
tag_name = data["tag"]
|
||||||
subprocess.run(["git", "checkout", default_branch], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=cache_path)
|
subprocess.run(["git", "checkout", tag_name], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=cache_path)
|
||||||
subprocess.run(["git", "pull"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=cache_path)
|
else:
|
||||||
last_commit_hash = get_git_head(cache_path)
|
print_yellow(f"Warning: No commit or tag specified for {folder_name}. Using default branch.")
|
||||||
data["commit"] = last_commit_hash
|
default_branch = get_default_branch(cache_path)
|
||||||
print_blue(f"Recorded commit {last_commit_hash} for {folder_name}")
|
subprocess.run(["git", "checkout", default_branch], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=cache_path)
|
||||||
|
subprocess.run(["git", "pull"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=cache_path)
|
||||||
|
last_commit_hash = get_git_head(cache_path)
|
||||||
|
data["commit"] = last_commit_hash
|
||||||
|
print_blue(f"Recorded commit {last_commit_hash} for {folder_name}")
|
||||||
|
|
||||||
results = [] # List of (pkg_name, version)
|
results = [] # List of (pkg_name, version)
|
||||||
|
|
||||||
|
|
@ -82,14 +90,18 @@ def integrate_package(folder_name, data, cache_dir, out_dir):
|
||||||
for pkg in packages_to_extract:
|
for pkg in packages_to_extract:
|
||||||
pkg_name = pkg["name"]
|
pkg_name = pkg["name"]
|
||||||
subpath = pkg.get("path", "")
|
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)
|
out_path = os.path.join(out_dir, pkg_name)
|
||||||
if os.path.exists(out_path):
|
if os.path.exists(out_path):
|
||||||
shutil.rmtree(out_path)
|
shutil.rmtree(out_path)
|
||||||
os.makedirs(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:
|
for item in keep_list:
|
||||||
src_item = os.path.join(package_src_path, item)
|
src_item = os.path.join(package_src_path, item)
|
||||||
dst_item = os.path.join(out_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():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Update specific or all repositories.")
|
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('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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
with open("dependencies.yaml", "r") as f:
|
with open("dependencies.yaml", "r") as f:
|
||||||
|
|
@ -141,7 +158,13 @@ def main():
|
||||||
def process_deps_recursive(deps_dict, to_update=None):
|
def process_deps_recursive(deps_dict, to_update=None):
|
||||||
for name, data in deps_dict.items():
|
for name, data in deps_dict.items():
|
||||||
if to_update is None or name in to_update:
|
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:
|
for pkg_name, version in extracted_packages:
|
||||||
pubspec_overrides.append(f" {pkg_name}:\n path: {out_dir}/{pkg_name}\n")
|
pubspec_overrides.append(f" {pkg_name}:\n path: {out_dir}/{pkg_name}\n")
|
||||||
if version and version != "any":
|
if version and version != "any":
|
||||||
|
|
@ -153,7 +176,13 @@ def main():
|
||||||
# Actually, the original logic updated children automatically if parent is updated.
|
# 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 [])
|
process_deps_recursive(data["dependencies"], None if (to_update is None or name in to_update) else [])
|
||||||
|
|
||||||
process_deps_recursive(deps, repos_to_update if args.repo_name else None)
|
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):
|
def sort_dependencies(d):
|
||||||
sorted_d = {k: d[k] for k in sorted(d.keys())}
|
sorted_d = {k: d[k] for k in sorted(d.keys())}
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,6 @@ dependencies:
|
||||||
blurhash_dart:
|
blurhash_dart:
|
||||||
git: https://github.com/justacid/blurhash-dart.git
|
git: https://github.com/justacid/blurhash-dart.git
|
||||||
tag: v1.2.1
|
tag: v1.2.1
|
||||||
emoji_picker_flutter:
|
|
||||||
git: https://github.com/otsmr/emoji_picker_flutter.git
|
|
||||||
commit: 47ddf705fb4840d6fbe305b79e896666dda7b0d5
|
|
||||||
exif:
|
exif:
|
||||||
git: https://github.com/bigflood/dartexif.git
|
git: https://github.com/bigflood/dartexif.git
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:twonly/src/utils/misc.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';
|
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layer_data.dart';
|
||||||
|
|
||||||
class EmojiPickerBottom extends StatelessWidget {
|
class EmojiPickerBottom extends StatelessWidget {
|
||||||
|
|
@ -54,6 +55,7 @@ class EmojiPickerBottom extends StatelessWidget {
|
||||||
height: 400,
|
height: 400,
|
||||||
locale: Localizations.localeOf(context),
|
locale: Localizations.localeOf(context),
|
||||||
checkPlatformCompatibility: false,
|
checkPlatformCompatibility: false,
|
||||||
|
resizeConfig: const ResizeConfig(showDragHandle: false),
|
||||||
emojiTextStyle: TextStyle(
|
emojiTextStyle: TextStyle(
|
||||||
fontSize: 24 * (Platform.isIOS ? 1.2 : 1),
|
fontSize: 24 * (Platform.isIOS ? 1.2 : 1),
|
||||||
fontFamilyFallback: Platform.isAndroid
|
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)),
|
scanHighlight(light: Color(0xFF76FF03), dark: Color(0xFF76FF03)),
|
||||||
|
|
||||||
// Message-kind indicators
|
// Message-kind indicators
|
||||||
messageText(light: Color(0xFF35618D), dark: Color(0xFF9FCBFF)),
|
messageText(light: Color(0xFF448AFF), dark: Color(0xFF448AFF)),
|
||||||
messageRestore(light: Color(0xFFB85F00), dark: Color(0xFFFFB95F)),
|
messageRestore(light: Color(0xFFFF9800), dark: Color(0xFFFF9800)),
|
||||||
messageWidget(light: Color(0xFF79528D), dark: Color(0xFFD7B9E8)),
|
messageWidget(light: Color(0xFF9B59B6), dark: Color(0xFF9B59B6)),
|
||||||
messageVideo(light: Color(0xFF9C3A8D), dark: Color(0xFFFFA9E5)),
|
messageVideo(light: Color(0xFFF321D0), dark: Color(0xFFF321D0)),
|
||||||
messageAudio(light: Color(0xFFB85F00), dark: Color(0xFFFFB68A)),
|
messageAudio(light: Color(0xFFFC9555), dark: Color(0xFFFC9555)),
|
||||||
messageImage(light: Color(0xFFBA1A1A), dark: Color(0xFFFFB4AB));
|
messageImage(light: Color(0xFFFF5252), dark: Color(0xFFFF5252));
|
||||||
|
|
||||||
const AppColor({required this.light, required this.dark});
|
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/dog_filter_painter.dart';
|
||||||
import 'package:twonly/src/visual/views/camera/camera_preview_components/painters/face_filters/face_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 {
|
class PreviewLink {
|
||||||
const PreviewLink({
|
const PreviewLink({
|
||||||
required this.url,
|
required this.url,
|
||||||
|
|
@ -208,7 +214,7 @@ class MainCameraController {
|
||||||
|
|
||||||
var controller = CameraController(
|
var controller = CameraController(
|
||||||
AppEnvironment.cameras[cameraId],
|
AppEnvironment.cameras[cameraId],
|
||||||
ResolutionPreset.high,
|
_cameraResolutionPreset,
|
||||||
enableAudio: hasMic,
|
enableAudio: hasMic,
|
||||||
imageFormatGroup: Platform.isAndroid
|
imageFormatGroup: Platform.isAndroid
|
||||||
? ImageFormatGroup.nv21
|
? ImageFormatGroup.nv21
|
||||||
|
|
@ -228,7 +234,7 @@ class MainCameraController {
|
||||||
await controller.dispose();
|
await controller.dispose();
|
||||||
controller = CameraController(
|
controller = CameraController(
|
||||||
AppEnvironment.cameras[cameraId],
|
AppEnvironment.cameras[cameraId],
|
||||||
ResolutionPreset.high,
|
_cameraResolutionPreset,
|
||||||
enableAudio: hasMic,
|
enableAudio: hasMic,
|
||||||
);
|
);
|
||||||
_initializeFuture = controller.initialize();
|
_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
|
/// `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.
|
/// 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
|
/// The rate is the part that cannot be known up front. Both platforms request
|
||||||
/// `ResolutionPreset.high`, which is 720p, but what an encoder spends on those
|
/// `ResolutionPreset.veryHigh`, which is approximately 1080p, but what an
|
||||||
/// pixels differs by a factor of several between devices - the same minute
|
/// encoder spends on those pixels differs by a factor of several between
|
||||||
/// that costs 18 MB on one phone costs far more on a phone whose camcorder
|
/// devices - the same minute that costs 18 MB on one phone costs far more on a
|
||||||
/// profile asks for a high bitrate. So the first recording on a device is
|
/// phone whose camcorder profile asks for a high bitrate. So the first
|
||||||
/// budgeted from a deliberately pessimistic estimate, and every recording
|
/// recording on a device is budgeted from a deliberately pessimistic estimate,
|
||||||
/// after that is budgeted from what this device actually wrote.
|
/// and every recording after that is budgeted from what this device actually
|
||||||
|
/// wrote.
|
||||||
abstract final class VideoRecordingBudget {
|
abstract final class VideoRecordingBudget {
|
||||||
/// Mirrors `maximal_upload_size_of_single_media_size` of the server's plans.
|
/// Mirrors `maximal_upload_size_of_single_media_size` of the server's plans.
|
||||||
/// Every paid plan shares the same per-file limit.
|
/// 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.
|
/// AAC alongside the video, when the microphone is available.
|
||||||
static const double _audioBitsPerSecond = 128000;
|
static const double _audioBitsPerSecond = 128000;
|
||||||
|
|
||||||
/// Falls back to 720p when the camera has not reported its preview size.
|
/// Falls back to the requested 1080p when the camera has not reported its
|
||||||
static const Size _assumedRecordingSize = Size(1280, 720);
|
/// preview size.
|
||||||
|
static const Size _assumedRecordingSize = Size(1920, 1080);
|
||||||
|
|
||||||
/// Leaves the container overhead and a scene busier than the one that was
|
/// Leaves the container overhead and a scene busier than the one that was
|
||||||
/// measured somewhere to go.
|
/// measured somewhere to go.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:collection';
|
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:flutter/material.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
@ -111,8 +111,10 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
ItemPositionsListener.create();
|
ItemPositionsListener.create();
|
||||||
int? focusedScrollItem;
|
int? focusedScrollItem;
|
||||||
bool _receiverDeletedAccount = false;
|
bool _receiverDeletedAccount = false;
|
||||||
|
bool _emojiPickerShowing = false;
|
||||||
Future<void>? _olderMessagesLoad;
|
Future<void>? _olderMessagesLoad;
|
||||||
bool _hasMoreMessages = true;
|
bool _hasMoreMessages = true;
|
||||||
|
int _loadedMessagesRevision = 0;
|
||||||
|
|
||||||
Timer? _nextTypingIndicator;
|
Timer? _nextTypingIndicator;
|
||||||
|
|
||||||
|
|
@ -220,6 +222,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _applyLoadedMessages({required bool reportOpened}) async {
|
Future<void> _applyLoadedMessages({required bool reportOpened}) async {
|
||||||
|
final revision = ++_loadedMessagesRevision;
|
||||||
final byId = <String, Message>{
|
final byId = <String, Message>{
|
||||||
for (final message in _data.olderMessages) message.messageId: message,
|
for (final message in _data.olderMessages) message.messageId: message,
|
||||||
for (final message in _data.latestMessages) 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);
|
final byTime = a.createdAt.compareTo(b.createdAt);
|
||||||
return byTime != 0 ? byTime : a.messageId.compareTo(b.messageId);
|
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.allMessages = loadedMessages;
|
||||||
_data.messagesById = byId;
|
_data.messagesById = byId;
|
||||||
_watchGroupActionsForLoadedRange();
|
_watchGroupActionsForLoadedRange();
|
||||||
|
|
@ -315,8 +342,11 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
_data.mediaFilesById = {
|
_data.mediaFilesById = {
|
||||||
for (final mediaFile in mediaFiles) mediaFile.mediaId: mediaFile,
|
for (final mediaFile in mediaFiles) mediaFile.mediaId: mediaFile,
|
||||||
};
|
};
|
||||||
_notifyMessageDataChanged();
|
|
||||||
_updateGalleryItems(force: true);
|
_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
|
_subscriptions.reactions = twonlyDB.reactionsDao
|
||||||
.watchReactionsForMessages(messageIds)
|
.watchReactionsForMessages(messageIds)
|
||||||
|
|
@ -489,6 +519,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_data.chatItems = chatItems.reversed.toList();
|
_data.chatItems = chatItems.reversed.toList();
|
||||||
|
_updateGalleryItems(messages: storedMediaFiles);
|
||||||
_notifyMessageDataChanged();
|
_notifyMessageDataChanged();
|
||||||
|
|
||||||
if (wasSentByMe) {
|
if (wasSentByMe) {
|
||||||
|
|
@ -506,8 +537,6 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_updateGalleryItems(messages: storedMediaFiles);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _reportMessagesOpened(
|
Future<void> _reportMessagesOpened(
|
||||||
|
|
@ -541,24 +570,56 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
.toSet();
|
.toSet();
|
||||||
if (!force && setEquals(_data.galleryMessageIds, messageIds)) return;
|
if (!force && setEquals(_data.galleryMessageIds, messageIds)) return;
|
||||||
|
|
||||||
final items = <String, MemoryItem>{};
|
final messagesByMediaId = <String, List<Message>>{};
|
||||||
for (final message in storedMediaMessages) {
|
for (final message in storedMediaMessages) {
|
||||||
final mediaFile = _data.mediaFilesById[message.mediaId];
|
final mediaFile = _data.mediaFilesById[message.mediaId];
|
||||||
if (mediaFile == null) continue;
|
if (mediaFile == null) continue;
|
||||||
final mediaService = MediaFileService(mediaFile);
|
if (!MediaFileService(mediaFile).imagePreviewAvailable) continue;
|
||||||
if (!mediaService.imagePreviewAvailable) continue;
|
messagesByMediaId
|
||||||
items
|
.putIfAbsent(mediaFile.mediaId, () => <Message>[])
|
||||||
.putIfAbsent(
|
|
||||||
mediaFile.mediaId,
|
|
||||||
() => MemoryItem(mediaService: mediaService, messages: []),
|
|
||||||
)
|
|
||||||
.messages
|
|
||||||
.add(message);
|
.add(message);
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
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.galleryMessageIds = messageIds;
|
||||||
_data.galleryItems = items.values.toList();
|
|
||||||
_notifyMessageDataChanged();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> scrollToMessage(String messageId) async {
|
Future<void> scrollToMessage(String messageId) async {
|
||||||
|
|
@ -612,6 +673,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => FocusScope.of(context).unfocus(),
|
onTap: () => FocusScope.of(context).unfocus(),
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
|
resizeToAvoidBottomInset: !_emojiPickerShowing,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: GestureDetector(
|
title: GestureDetector(
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
|
|
@ -672,6 +734,11 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
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(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -807,6 +874,10 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
quotesMessage = null;
|
quotesMessage = null;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
onEmojiVisibilityChanged: (visible) {
|
||||||
|
if (_emojiPickerShowing == visible) return;
|
||||||
|
setState(() => _emojiPickerShowing = visible);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
if (_receiverDeletedAccount)
|
if (_receiverDeletedAccount)
|
||||||
Text(context.lang.userDeletedAccount),
|
Text(context.lang.userDeletedAccount),
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,12 @@ class _ChatMediaEntryState extends State<ChatMediaEntry> {
|
||||||
);
|
);
|
||||||
|
|
||||||
var imageBorderRadius = widget.borderRadius;
|
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();
|
Widget additionalMessageData = Container();
|
||||||
|
|
||||||
|
|
@ -186,17 +192,13 @@ class _ChatMediaEntryState extends State<ChatMediaEntry> {
|
||||||
onDoubleTap: onDoubleTap,
|
onDoubleTap: onDoubleTap,
|
||||||
onTap: (widget.message.type == MessageType.media.name) ? onTap : null,
|
onTap: (widget.message.type == MessageType.media.name) ? onTap : null,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
|
key: ValueKey('chat_media_frame_${widget.message.messageId}'),
|
||||||
width: (widget.info.minWidth > 150)
|
width: (widget.info.minWidth > 150)
|
||||||
? widget.info.minWidth
|
? widget.info.minWidth
|
||||||
: (widget.message.mediaStored &&
|
: reservesStoredPreviewSpace
|
||||||
widget.mediaService.imagePreviewAvailable)
|
|
||||||
? 150
|
? 150
|
||||||
: null,
|
: null,
|
||||||
height:
|
height: reservesStoredPreviewSpace ? 271 : null,
|
||||||
(widget.message.mediaStored &&
|
|
||||||
widget.mediaService.imagePreviewAvailable)
|
|
||||||
? 271
|
|
||||||
: null,
|
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
|
|
|
||||||
|
|
@ -137,36 +137,46 @@ class _InChatMediaViewerState extends State<InChatMediaViewer> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final awaitsStoredPreview =
|
||||||
|
!widget.message.isWidgetMedia &&
|
||||||
|
widget.message.mediaStored &&
|
||||||
|
!widget.mediaService.imagePreviewAvailable;
|
||||||
if (widget.message.isWidgetMedia ||
|
if (widget.message.isWidgetMedia ||
|
||||||
!widget.message.mediaStored ||
|
!widget.message.mediaStored ||
|
||||||
!widget.mediaService.imagePreviewAvailable) {
|
!widget.mediaService.imagePreviewAvailable) {
|
||||||
|
final status = Row(
|
||||||
|
mainAxisSize: awaitsStoredPreview ? MainAxisSize.min : MainAxisSize.max,
|
||||||
|
children: [
|
||||||
|
MessageSendStateIcon(
|
||||||
|
[widget.message],
|
||||||
|
[widget.mediaService.mediaFile],
|
||||||
|
mainAxisAlignment: widget.message.senderId == null
|
||||||
|
? MainAxisAlignment.end
|
||||||
|
: MainAxisAlignment.start,
|
||||||
|
canBeReopened: widget.canBeReopened,
|
||||||
|
),
|
||||||
|
if (widget.info.displayTime || widget.message.modifiedAt != null)
|
||||||
|
FriendlyMessageTime(
|
||||||
|
message: widget.message,
|
||||||
|
color: context.appColor(AppColor.chatMessageMeta),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
return Container(
|
return Container(
|
||||||
constraints: const BoxConstraints(
|
constraints: awaitsStoredPreview
|
||||||
minHeight: 39,
|
? const BoxConstraints.expand()
|
||||||
),
|
: const BoxConstraints(minHeight: 39),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: widget.info.color,
|
color: widget.info.color,
|
||||||
borderRadius: widget.borderRadius,
|
borderRadius: widget.borderRadius,
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: widget.info.padding,
|
padding: widget.info.padding,
|
||||||
child: Row(
|
child: awaitsStoredPreview
|
||||||
children: [
|
? Center(
|
||||||
MessageSendStateIcon(
|
child: FittedBox(fit: BoxFit.scaleDown, child: status),
|
||||||
[widget.message],
|
)
|
||||||
[widget.mediaService.mediaFile],
|
: status,
|
||||||
mainAxisAlignment: widget.message.senderId == null
|
|
||||||
? MainAxisAlignment.end
|
|
||||||
: MainAxisAlignment.start,
|
|
||||||
canBeReopened: widget.canBeReopened,
|
|
||||||
),
|
|
||||||
if (widget.info.displayTime || widget.message.modifiedAt != null)
|
|
||||||
FriendlyMessageTime(
|
|
||||||
message: widget.message,
|
|
||||||
color: context.appColor(AppColor.chatMessageMeta),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import 'dart:io';
|
||||||
import 'package:audio_waveforms/audio_waveforms.dart';
|
import 'package:audio_waveforms/audio_waveforms.dart';
|
||||||
import 'package:clock/clock.dart';
|
import 'package:clock/clock.dart';
|
||||||
import 'package:drift/drift.dart' show Value;
|
import 'package:drift/drift.dart' show Value;
|
||||||
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.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/database/twonly.db.dart';
|
||||||
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||||
import 'package:twonly/src/utils/misc.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/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/bottom_sheets/share_additional.bottom_sheet.dart';
|
||||||
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_audio_entry.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.quotesMessage,
|
||||||
required this.textFieldFocus,
|
required this.textFieldFocus,
|
||||||
required this.onMessageSend,
|
required this.onMessageSend,
|
||||||
|
required this.onEmojiVisibilityChanged,
|
||||||
required this.composing,
|
required this.composing,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
@ -38,6 +39,7 @@ class MessageInput extends StatefulWidget {
|
||||||
final FocusNode textFieldFocus;
|
final FocusNode textFieldFocus;
|
||||||
final Message? quotesMessage;
|
final Message? quotesMessage;
|
||||||
final VoidCallback onMessageSend;
|
final VoidCallback onMessageSend;
|
||||||
|
final ValueChanged<bool> onEmojiVisibilityChanged;
|
||||||
|
|
||||||
/// Published so the chat-open heartbeat can stay quiet while the typing
|
/// Published so the chat-open heartbeat can stay quiet while the typing
|
||||||
/// announcement is already covering this conversation.
|
/// announcement is already covering this conversation.
|
||||||
|
|
@ -63,11 +65,13 @@ const _lockOffset = 100.0;
|
||||||
enum RecordingState { none, recording, finished }
|
enum RecordingState { none, recording, finished }
|
||||||
|
|
||||||
class _MessageInputState extends State<MessageInput>
|
class _MessageInputState extends State<MessageInput>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
|
||||||
late final TextEditingController _textFieldController;
|
late final TextEditingController _textFieldController;
|
||||||
late final RecorderController recorderController;
|
late final RecorderController recorderController;
|
||||||
final bool isApple = Platform.isIOS;
|
final bool isApple = Platform.isIOS;
|
||||||
bool _emojiShowing = false;
|
bool _emojiShowing = false;
|
||||||
|
bool _switchingToSystemKeyboard = false;
|
||||||
|
double _emojiPickerHeight = 300;
|
||||||
bool _showSparks = false;
|
bool _showSparks = false;
|
||||||
bool _audioRecordingLock = false;
|
bool _audioRecordingLock = false;
|
||||||
bool _overLockIcon = false;
|
bool _overLockIcon = false;
|
||||||
|
|
@ -79,6 +83,7 @@ class _MessageInputState extends State<MessageInput>
|
||||||
DateTime? _lastTextChangeTime;
|
DateTime? _lastTextChangeTime;
|
||||||
int? _contactId;
|
int? _contactId;
|
||||||
Timer? _recordingTimer;
|
Timer? _recordingTimer;
|
||||||
|
Timer? _keyboardTransitionTimer;
|
||||||
DateTime? _recordingStartTime;
|
DateTime? _recordingStartTime;
|
||||||
|
|
||||||
/// Pulses the microphone icon while a recording is running so the composer
|
/// Pulses the microphone icon while a recording is running so the composer
|
||||||
|
|
@ -111,6 +116,7 @@ class _MessageInputState extends State<MessageInput>
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_textFieldController = TextEditingController();
|
_textFieldController = TextEditingController();
|
||||||
_textFieldController.addListener(_handleTextChange);
|
_textFieldController.addListener(_handleTextChange);
|
||||||
if (widget.group.draftMessage != null) {
|
if (widget.group.draftMessage != null) {
|
||||||
|
|
@ -138,10 +144,12 @@ class _MessageInputState extends State<MessageInput>
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_textFieldController.removeListener(_handleTextChange);
|
_textFieldController.removeListener(_handleTextChange);
|
||||||
widget.textFieldFocus.removeListener(_handleTextFocusChange);
|
widget.textFieldFocus.removeListener(_handleTextFocusChange);
|
||||||
widget.textFieldFocus.dispose();
|
widget.textFieldFocus.dispose();
|
||||||
_recordingTimer?.cancel();
|
_recordingTimer?.cancel();
|
||||||
|
_keyboardTransitionTimer?.cancel();
|
||||||
_recordingBlink.dispose();
|
_recordingBlink.dispose();
|
||||||
recorderController.dispose();
|
recorderController.dispose();
|
||||||
_nextTypingIndicator?.cancel();
|
_nextTypingIndicator?.cancel();
|
||||||
|
|
@ -201,13 +209,80 @@ class _MessageInputState extends State<MessageInput>
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleTextFocusChange() {
|
void _handleTextFocusChange() {
|
||||||
if (widget.textFieldFocus.hasFocus) {
|
if (widget.textFieldFocus.hasFocus &&
|
||||||
setState(() {
|
_emojiShowing &&
|
||||||
_emojiShowing = false;
|
!_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 {
|
Future<void> _startAudioRecording() async {
|
||||||
if (!await Permission.microphone.isGranted) {
|
if (!await Permission.microphone.isGranted) {
|
||||||
final statuses = await [
|
final statuses = await [
|
||||||
|
|
@ -387,16 +462,7 @@ class _MessageInputState extends State<MessageInput>
|
||||||
children: [
|
children: [
|
||||||
if (_recordingState != RecordingState.recording)
|
if (_recordingState != RecordingState.recording)
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () {
|
onTap: _toggleKeyboard,
|
||||||
setState(() {
|
|
||||||
_emojiShowing = !_emojiShowing;
|
|
||||||
if (_emojiShowing) {
|
|
||||||
widget.textFieldFocus.unfocus();
|
|
||||||
} else {
|
|
||||||
widget.textFieldFocus.requestFocus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
child: ColoredBox(
|
child: ColoredBox(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|
@ -407,7 +473,7 @@ class _MessageInputState extends State<MessageInput>
|
||||||
right: 8,
|
right: 8,
|
||||||
),
|
),
|
||||||
child: FaIcon(
|
child: FaIcon(
|
||||||
size: 20,
|
size: _emojiShowing ? 18 : 20,
|
||||||
_emojiShowing
|
_emojiShowing
|
||||||
? FontAwesomeIcons.keyboard
|
? FontAwesomeIcons.keyboard
|
||||||
: FontAwesomeIcons.faceSmile,
|
: FontAwesomeIcons.faceSmile,
|
||||||
|
|
@ -698,13 +764,8 @@ class _MessageInputState extends State<MessageInput>
|
||||||
setState(() {});
|
setState(() {});
|
||||||
},
|
},
|
||||||
config: Config(
|
config: Config(
|
||||||
height: 300,
|
height: _emojiPickerHeight,
|
||||||
locale: Localizations.localeOf(context),
|
locale: Localizations.localeOf(context),
|
||||||
viewOrderConfig: const ViewOrderConfig(
|
|
||||||
top: EmojiPickerItem.searchBar,
|
|
||||||
// middle: EmojiPickerItem.emojiView,
|
|
||||||
bottom: EmojiPickerItem.categoryBar,
|
|
||||||
),
|
|
||||||
emojiTextStyle: TextStyle(
|
emojiTextStyle: TextStyle(
|
||||||
fontSize: 24 * (Platform.isIOS ? 1.2 : 1),
|
fontSize: 24 * (Platform.isIOS ? 1.2 : 1),
|
||||||
),
|
),
|
||||||
|
|
@ -718,7 +779,6 @@ class _MessageInputState extends State<MessageInput>
|
||||||
),
|
),
|
||||||
categoryViewConfig: CategoryViewConfig(
|
categoryViewConfig: CategoryViewConfig(
|
||||||
backgroundColor: context.color.surfaceContainer,
|
backgroundColor: context.color.surfaceContainer,
|
||||||
dividerColor: context.color.outlineVariant,
|
|
||||||
indicatorColor: context.color.primary,
|
indicatorColor: context.color.primary,
|
||||||
iconColorSelected: context.color.primary,
|
iconColorSelected: context.color.primary,
|
||||||
iconColor: context.color.secondary,
|
iconColor: context.color.secondary,
|
||||||
|
|
|
||||||
|
|
@ -396,13 +396,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.1"
|
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:
|
exif:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -1535,7 +1528,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.2.0"
|
version: "7.2.0"
|
||||||
shared_preferences:
|
shared_preferences:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: shared_preferences
|
name: shared_preferences
|
||||||
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ dependencies:
|
||||||
vector_graphics: ^1.1.19
|
vector_graphics: ^1.1.19
|
||||||
in_app_purchase: ^3.3.0
|
in_app_purchase: ^3.3.0
|
||||||
go_router: ^17.1.0
|
go_router: ^17.1.0
|
||||||
|
shared_preferences: ^2.5.5
|
||||||
|
|
||||||
|
|
||||||
# Trusted publisher fluttercommunity.dev
|
# Trusted publisher fluttercommunity.dev
|
||||||
|
|
@ -88,7 +89,6 @@ dependencies:
|
||||||
audio_waveforms: ^2.0.2
|
audio_waveforms: ^2.0.2
|
||||||
avatar_maker: ^1.5.0
|
avatar_maker: ^1.5.0
|
||||||
blurhash_dart: ^1.2.1
|
blurhash_dart: ^1.2.1
|
||||||
emoji_picker_flutter: ^4.5.3
|
|
||||||
exif: ^3.3.0
|
exif: ^3.3.0
|
||||||
sprintf: ^7.0.0
|
sprintf: ^7.0.0
|
||||||
flutter_blurhash: ^0.9.1
|
flutter_blurhash: ^0.9.1
|
||||||
|
|
@ -127,8 +127,6 @@ dependency_overrides:
|
||||||
path: ./dependencies/avatar_maker
|
path: ./dependencies/avatar_maker
|
||||||
blurhash_dart:
|
blurhash_dart:
|
||||||
path: ./dependencies/blurhash_dart
|
path: ./dependencies/blurhash_dart
|
||||||
emoji_picker_flutter:
|
|
||||||
path: ./dependencies/emoji_picker_flutter
|
|
||||||
exif:
|
exif:
|
||||||
path: ./dependencies/exif
|
path: ./dependencies/exif
|
||||||
sprintf:
|
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));
|
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;
|
late Directory tempDir;
|
||||||
|
|
||||||
const hd = Size(1280, 720);
|
const hd = Size(1280, 720);
|
||||||
|
const fullHd = Size(1920, 1080);
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
tempDir = Directory.systemTemp.createTempSync('twonly_video_budget_test_');
|
tempDir = Directory.systemTemp.createTempSync('twonly_video_budget_test_');
|
||||||
|
|
@ -54,14 +55,14 @@ void main() {
|
||||||
expect(free.inSeconds, inInclusiveRange(45, 60));
|
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(
|
expect(
|
||||||
budgetFor(SubscriptionPlan.Free, size: null),
|
budgetFor(SubscriptionPlan.Free, size: null),
|
||||||
budgetFor(SubscriptionPlan.Free),
|
budgetFor(SubscriptionPlan.Free, size: fullHd),
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
budgetFor(SubscriptionPlan.Free, size: Size.zero),
|
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 hdBudget = budgetFor(SubscriptionPlan.Free);
|
||||||
final fullHdBudget = budgetFor(
|
final fullHdBudget = budgetFor(
|
||||||
SubscriptionPlan.Free,
|
SubscriptionPlan.Free,
|
||||||
size: const Size(1920, 1080),
|
size: fullHd,
|
||||||
);
|
);
|
||||||
expect(fullHdBudget.inMilliseconds, lessThan(hdBudget.inMilliseconds));
|
expect(fullHdBudget.inMilliseconds, lessThan(hdBudget.inMilliseconds));
|
||||||
});
|
});
|
||||||
|
|
@ -118,7 +119,7 @@ void main() {
|
||||||
);
|
);
|
||||||
// A different recording size has not been measured.
|
// A different recording size has not been measured.
|
||||||
expect(
|
expect(
|
||||||
budgetFor(SubscriptionPlan.Free, size: const Size(1920, 1080)),
|
budgetFor(SubscriptionPlan.Free, size: fullHd),
|
||||||
lessThan(budgetFor(SubscriptionPlan.Free)),
|
lessThan(budgetFor(SubscriptionPlan.Free)),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue