diff --git a/lib/backend/modules/animoji.dart b/lib/backend/modules/animoji.dart index 3afd2ca..ba541ab 100644 --- a/lib/backend/modules/animoji.dart +++ b/lib/backend/modules/animoji.dart @@ -1,3 +1,5 @@ +import 'package:shared_preferences/shared_preferences.dart'; + import '../api.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/utils/logger.dart'; @@ -17,8 +19,13 @@ class AnimojiModule { '😍', ]; + static const String _recentsKey = 'komet_recent_animoji'; + static const int _maxRecents = 24; + final Map _byId = {}; List _orderedIds = []; + List _recentIds = []; + bool _recentsLoaded = false; Future? _loading; bool get isLoaded => _orderedIds.isNotEmpty; @@ -26,8 +33,38 @@ class AnimojiModule { List get animojis => _orderedIds.map((id) => _byId[id]).whereType().toList(); + List get recentAnimojis => + _recentIds.map((id) => _byId[id]).whereType().toList(); + List get emojis => animojis.map((a) => a.emoji).toList(); + Future ensureRecentsLoaded() async { + if (_recentsLoaded) return; + _recentsLoaded = true; + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getStringList(_recentsKey) ?? const []; + _recentIds = raw.map(int.tryParse).whereType().toList(); + } catch (_) {} + } + + Future noteUsed(Animoji animoji) async { + await ensureRecentsLoaded(); + _byId[animoji.id] = animoji; + _recentIds.remove(animoji.id); + _recentIds.insert(0, animoji.id); + if (_recentIds.length > _maxRecents) { + _recentIds = _recentIds.sublist(0, _maxRecents); + } + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList( + _recentsKey, + _recentIds.map((e) => e.toString()).toList(), + ); + } catch (_) {} + } + List get quickAnimojis { final list = animojis; return list.length <= 6 ? list : list.sublist(0, 6); diff --git a/lib/core/utils/text_format.dart b/lib/core/utils/text_format.dart index 79b94ca..4100ad9 100644 --- a/lib/core/utils/text_format.dart +++ b/lib/core/utils/text_format.dart @@ -8,6 +8,7 @@ enum TextFormat { monospaced, quote, link, + animoji, } const Map _formatToServer = { @@ -18,6 +19,7 @@ const Map _formatToServer = { TextFormat.monospaced: 'MONOSPACED', TextFormat.quote: 'QUOTE', TextFormat.link: 'LINK', + TextFormat.animoji: 'ANIMOJI', }; final Map _serverToFormat = { @@ -49,6 +51,11 @@ class FormatRange { return value is String ? value : null; } + String? get animojiUrl { + final value = attributes?['animojiLottieUrl']; + return value is String && value.isNotEmpty ? value : null; + } + Map toServer() => { 'type': textFormatToServer(format), 'from': start, @@ -87,6 +94,34 @@ List> serializeFormatElements( Iterable ranges, ) => [for (final range in ranges) range.toServer()]; +List? animojiOnlyLottieUrls( + String? text, + List ranges, { + int limit = 4, +}) { + if (text == null || text.isEmpty) return null; + final len = text.length; + final animoji = + ranges + .where((r) => r.format == TextFormat.animoji && r.animojiUrl != null) + .toList() + ..sort((a, b) => a.start.compareTo(b.start)); + if (animoji.isEmpty || animoji.length > limit) return null; + + var cursor = 0; + for (final r in animoji) { + final start = r.start.clamp(0, len).toInt(); + if (text.substring(cursor.clamp(0, len).toInt(), start).trim().isNotEmpty) { + return null; + } + cursor = r.end.clamp(0, len).toInt(); + } + if (text.substring(cursor.clamp(0, len).toInt()).trim().isNotEmpty) { + return null; + } + return [for (final r in animoji) r.animojiUrl!]; +} + int _asInt(dynamic value) { if (value is int) return value; if (value is String) return int.tryParse(value) ?? 0; @@ -98,12 +133,14 @@ class FormatSegment { final int end; final Set formats; final String? url; + final String? animojiUrl; const FormatSegment({ required this.start, required this.end, required this.formats, this.url, + this.animojiUrl, }); } @@ -142,14 +179,22 @@ List segmentizeFormats(String text, List ranges) { if (end <= start) continue; final formats = {}; String? url; + String? animojiUrl; for (final range in clamped) { if (range.start <= start && range.end >= end) { formats.add(range.format); if (range.format == TextFormat.link) url ??= range.url; + if (range.format == TextFormat.animoji) animojiUrl ??= range.animojiUrl; } } segments.add( - FormatSegment(start: start, end: end, formats: formats, url: url), + FormatSegment( + start: start, + end: end, + formats: formats, + url: url, + animojiUrl: animojiUrl, + ), ); } return segments; diff --git a/lib/frontend/screens/chats/chat/view/composer_input.dart b/lib/frontend/screens/chats/chat/view/composer_input.dart index b2e3d68..4f2ee27 100644 --- a/lib/frontend/screens/chats/chat/view/composer_input.dart +++ b/lib/frontend/screens/chats/chat/view/composer_input.dart @@ -6,6 +6,7 @@ import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:komet/backend/modules/messages.dart'; +import 'package:komet/core/config/app_chat_chrome.dart'; import 'package:komet/core/config/app_colors.dart'; import 'package:komet/frontend/screens/chats/chat/upload_status.dart'; import 'package:komet/frontend/screens/chats/chat/video_note_controller.dart'; @@ -17,6 +18,7 @@ class ComposerInputBar extends StatelessWidget { const ComposerInputBar({ super.key, required this.chatType, + required this.chrome, required this.attachAnim, required this.replyTo, required this.myId, @@ -40,6 +42,7 @@ class ComposerInputBar extends StatelessWidget { }); final String chatType; + final ChatChromeStyle chrome; final Animation attachAnim; final ValueListenable replyTo; final int myId; @@ -418,7 +421,7 @@ class ComposerInputBar extends StatelessWidget { attachments: reply.attachments, ); final preview = info.previewText(); - return Padding( + final row = Padding( padding: const EdgeInsets.fromLTRB(16, 6, 8, 2), child: Row( children: [ @@ -462,6 +465,24 @@ class ComposerInputBar extends StatelessWidget { ], ), ); + if (chrome != ChatChromeStyle.transparent) return row; + return ClipRect( + child: BackdropFilter( + filter: ui.ImageFilter.blur(sigmaX: 34, sigmaY: 34), + child: DecoratedBox( + decoration: BoxDecoration( + color: cs.surface.withValues(alpha: 0.38), + border: Border( + top: BorderSide( + color: cs.outlineVariant.withValues(alpha: 0.4), + width: 0.5, + ), + ), + ), + child: row, + ), + ), + ); }, ); } diff --git a/lib/frontend/screens/chats/chat/view/sticker_panel_view.dart b/lib/frontend/screens/chats/chat/view/sticker_panel_view.dart index 226c977..f61cce8 100644 --- a/lib/frontend/screens/chats/chat/view/sticker_panel_view.dart +++ b/lib/frontend/screens/chats/chat/view/sticker_panel_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:komet/frontend/screens/chats/chat/sticker_panel_controller.dart'; import 'package:komet/frontend/widgets/sticker_panel.dart'; +import 'package:komet/models/animoji.dart'; import 'package:komet/models/sticker.dart'; class StickerPanelView extends StatelessWidget { @@ -9,10 +10,12 @@ class StickerPanelView extends StatelessWidget { super.key, required this.stickers, required this.onStickerTap, + this.onEmojiTap, }); final StickerPanelController stickers; final void Function(StickerItem sticker) onStickerTap; + final void Function(Animoji animoji)? onEmojiTap; @override Widget build(BuildContext context) { @@ -21,6 +24,7 @@ class StickerPanelView extends StatelessWidget { child: StickerPanel( height: stickers.panelHeight, onStickerTap: onStickerTap, + onEmojiTap: onEmojiTap, ), builder: (context, child) { final t = Curves.easeOutCubic.transform( diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index f234e50..ff7c375 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -497,7 +497,6 @@ class _ChatScreenState extends State WidgetsBinding.instance.addObserver(this); chats.chatsChanged.addListener(_onChatsBump); _messageController.addListener(_onTextChanged); - _messageFocusNode.addListener(_onComposerFocusChanged); _scrollController.addListener(_onScrollForDate); _scrollController.addListener(_maybeLoadMoreHistory); _scrollController.addListener(_recordScrollPixels); @@ -1368,7 +1367,6 @@ class _ChatScreenState extends State _search.dispose(); _selectedIds.dispose(); _messageController.dispose(); - _messageFocusNode.removeListener(_onComposerFocusChanged); _messageFocusNode.dispose(); _stickers.dispose(); _scrollController.dispose(); @@ -1413,7 +1411,11 @@ class _ChatScreenState extends State void _saveDraft() { if (_myId == 0) return; unawaited( - DraftStore.instance.set(_myId, widget.chatId, _messageController.text), + DraftStore.instance.set( + _myId, + widget.chatId, + _messageController.buildContent().text, + ), ); } @@ -1853,6 +1855,7 @@ class _ChatScreenState extends State ), ComposerInputBar( chatType: widget.chatType, + chrome: _effectiveChrome, attachAnim: _attachAnim, replyTo: _replyTo, myId: _myId, @@ -1875,7 +1878,11 @@ class _ChatScreenState extends State isMuted: chat?.isMuted ?? false, onToggleMute: _toggleChatMute, ), - StickerPanelView(stickers: _stickers, onStickerTap: _sendSticker), + StickerPanelView( + stickers: _stickers, + onStickerTap: _sendSticker, + onEmojiTap: _insertAnimoji, + ), ], ), ), @@ -2016,9 +2023,10 @@ class _ChatScreenState extends State return; } - final rawText = controller.text; + final content = controller.buildContent(); + final rawText = content.text; final newText = rawText.trim(); - final elements = _trimmedElements(controller, rawText, newText); + final elements = _trimmedElements(content.elements, rawText, newText); controller.dispose(); final oldElements = serializeFormatElements( @@ -2791,6 +2799,8 @@ class _ChatScreenState extends State return 'Цитата'; case TextFormat.link: return 'Ссылка'; + case TextFormat.animoji: + return 'Animoji'; } } @@ -2842,11 +2852,10 @@ class _ChatScreenState extends State } List> _trimmedElements( - RichMessageController controller, + List> raw, String rawText, String text, ) { - final raw = controller.elementsForSend(); if (raw.isEmpty) return const []; final leading = rawText.length - rawText.trimLeft().length; final result = >[]; @@ -2866,7 +2875,8 @@ class _ChatScreenState extends State } Future _sendMessage() async { - final rawText = _messageController.text; + final content = _messageController.buildContent(); + final rawText = content.text; final text = rawText.trim(); if (text.isEmpty || _myId == 0) return; @@ -2911,7 +2921,7 @@ class _ChatScreenState extends State } _replyTo.value = null; - final elements = _trimmedElements(_messageController, rawText, text); + final elements = _trimmedElements(content.elements, rawText, text); final Map? composedPayload = (replyPayload == null && elements.isEmpty) ? null @@ -5117,12 +5127,6 @@ class _ChatScreenState extends State _stickers.showPanel.value = true; } - void _onComposerFocusChanged() { - if (_messageFocusNode.hasFocus && _stickers.showPanel.value) { - _stickers.hide(); - } - } - Future _sendSticker(StickerItem sticker) async { _stickers.hide(); await _sendAttachMessage([ @@ -5136,6 +5140,12 @@ class _ChatScreenState extends State ], () => messagesModule.sendStickerMessage(widget.chatId, sticker.id)); } + void _insertAnimoji(Animoji animoji) { + _messageController.insertAnimoji(animoji); + unawaited(animojiModule.noteUsed(animoji)); + Haptics.selection(); + } + Future _shareLocation() async { final position = await _resolveCurrentPosition(); if (position == null || !mounted) return; diff --git a/lib/frontend/widgets/emoji_panel.dart b/lib/frontend/widgets/emoji_panel.dart new file mode 100644 index 0000000..5f0ac7a --- /dev/null +++ b/lib/frontend/widgets/emoji_panel.dart @@ -0,0 +1,332 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../main.dart' show animojiModule; +import '../../models/animoji.dart'; +import 'lottie_image.dart'; +import 'small_spinner.dart'; + +class _DragScrollBehavior extends MaterialScrollBehavior { + const _DragScrollBehavior(); + + @override + Set get dragDevices => const { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + PointerDeviceKind.trackpad, + PointerDeviceKind.stylus, + PointerDeviceKind.invertedStylus, + }; +} + +class _EmojiSection { + final String title; + final IconData icon; + final List items; + + const _EmojiSection({ + required this.title, + required this.icon, + required this.items, + }); +} + +class EmojiPanel extends StatefulWidget { + final void Function(Animoji animoji) onEmojiTap; + + const EmojiPanel({super.key, required this.onEmojiTap}); + + @override + State createState() => _EmojiPanelState(); +} + +class _EmojiPanelState extends State { + static const double _tabBarHeight = 46; + static const double _headerHeight = 30; + + final ScrollController _scroll = ScrollController(); + final ValueNotifier _scrolling = ValueNotifier(false); + bool _loading = true; + Object? _error; + int _selectedTab = 0; + List<_EmojiSection> _sections = const []; + List _heights = const []; + List _offsets = const []; + + @override + void initState() { + super.initState(); + _scroll.addListener(_onScroll); + _load(); + } + + @override + void dispose() { + _scroll.removeListener(_onScroll); + _scroll.dispose(); + _scrolling.dispose(); + super.dispose(); + } + + Future _load() async { + try { + await animojiModule.ensureRecentsLoaded(); + await animojiModule.ensureLoaded(); + if (!mounted) return; + _buildSections(); + setState(() => _loading = false); + } catch (e) { + if (!mounted) return; + setState(() { + _loading = false; + _error = e; + }); + } + } + + void _buildSections() { + final sections = <_EmojiSection>[]; + final recent = animojiModule.recentAnimojis; + if (recent.isNotEmpty) { + sections.add( + _EmojiSection( + title: 'Недавние', + icon: Symbols.schedule, + items: recent, + ), + ); + } + final all = animojiModule.animojis; + if (all.isNotEmpty) { + sections.add( + _EmojiSection( + title: 'Animated', + icon: Symbols.animation, + items: all, + ), + ); + } + _sections = sections; + } + + void _onScroll() { + if (_offsets.isEmpty) return; + final pixels = _scroll.position.pixels; + var index = 0; + for (var i = 0; i < _offsets.length; i++) { + if (pixels + 1 >= _offsets[i]) index = i; + } + if (index != _selectedTab) setState(() => _selectedTab = index); + } + + bool _onScrollNotification(ScrollNotification n) { + if (n is ScrollStartNotification || n is ScrollUpdateNotification) { + if (!_scrolling.value) _scrolling.value = true; + } else if (n is ScrollEndNotification) { + if (_scrolling.value) _scrolling.value = false; + } + return false; + } + + void _jumpTo(int index) { + if (!mounted || index >= _offsets.length || !_scroll.hasClients) return; + setState(() => _selectedTab = index); + final max = _scroll.position.maxScrollExtent; + _scroll.animateTo( + _offsets[index].clamp(0.0, max), + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + if (_loading) return const Center(child: SmallSpinner()); + if (_error != null || _sections.isEmpty) { + return Center( + child: Text( + _error != null ? 'Не удалось загрузить эмодзи' : 'Нет эмодзи', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + ); + } + + return ScrollConfiguration( + behavior: const _DragScrollBehavior(), + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final columns = (width / 44).floor().clamp(6, 10); + final cell = width / columns; + + final heights = []; + final offsets = []; + var acc = 0.0; + for (final s in _sections) { + final rows = (s.items.length / columns).ceil(); + final h = _headerHeight + rows * cell; + offsets.add(acc); + heights.add(h); + acc += h; + } + _heights = heights; + _offsets = offsets; + + return Column( + children: [ + _buildTabBar(cs), + Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.3), + ), + Expanded(child: _buildContent(columns, cell)), + ], + ); + }, + ), + ); + } + + Widget _buildTabBar(ColorScheme cs) { + return SizedBox( + height: _tabBarHeight, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 6), + itemCount: _sections.length, + itemBuilder: (context, i) { + final s = _sections[i]; + final selected = i == _selectedTab; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _jumpTo(i), + child: Container( + width: 40, + height: 40, + margin: const EdgeInsets.symmetric(horizontal: 2, vertical: 3), + decoration: BoxDecoration( + color: selected + ? cs.surfaceContainerHighest + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + s.icon, + size: 22, + color: selected ? cs.primary : cs.onSurfaceVariant, + ), + ), + ); + }, + ), + ); + } + + Widget _buildContent(int columns, double cell) { + return LottieScrollScope( + isScrolling: _scrolling, + child: NotificationListener( + onNotification: _onScrollNotification, + child: CustomScrollView( + controller: _scroll, + slivers: [ + SliverVariedExtentList( + itemExtentBuilder: (i, _) => _heights[i], + delegate: SliverChildBuilderDelegate( + (context, i) => _EmojiSectionView( + key: ValueKey(_sections[i].title + i.toString()), + section: _sections[i], + columns: columns, + cell: cell, + headerHeight: _headerHeight, + onTap: widget.onEmojiTap, + ), + childCount: _sections.length, + ), + ), + ], + ), + ), + ); + } +} + +class _EmojiSectionView extends StatelessWidget { + final _EmojiSection section; + final int columns; + final double cell; + final double headerHeight; + final void Function(Animoji animoji) onTap; + + const _EmojiSectionView({ + super.key, + required this.section, + required this.columns, + required this.cell, + required this.headerHeight, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final items = section.items; + final rows = (items.length / columns).ceil(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: headerHeight, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + section.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + for (var r = 0; r < rows; r++) + Row( + children: [ + for (var c = 0; c < columns; c++) + SizedBox( + width: cell, + height: cell, + child: r * columns + c < items.length + ? _cell(items[r * columns + c]) + : null, + ), + ], + ), + ], + ); + } + + Widget _cell(Animoji animoji) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onTap(animoji), + child: Padding( + padding: const EdgeInsets.all(5), + child: LottieImage( + url: animoji.iconUrl, + lottieUrl: animoji.lottieUrl, + memCacheWidth: 120, + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/formatted_message_text.dart b/lib/frontend/widgets/formatted_message_text.dart index 849f6ef..ec22c2a 100644 --- a/lib/frontend/widgets/formatted_message_text.dart +++ b/lib/frontend/widgets/formatted_message_text.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import '../../core/utils/link_opener.dart'; import '../../core/utils/text_format.dart'; import 'link_text.dart'; +import 'lottie_image.dart'; class FormattedMessageText extends StatefulWidget { final String text; @@ -122,6 +123,36 @@ class _FormattedMessageTextState extends State { quoteColor: quoteColor, ); final content = widget.text.substring(segment.start, segment.end); + if (segment.animojiUrl != null) { + final fontSize = widget.style.fontSize ?? 16; + final box = fontSize * 1.5; + spans.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: SizedBox( + width: box, + height: box, + child: Stack( + alignment: Alignment.center, + children: [ + Text( + content, + style: widget.style.copyWith(fontSize: fontSize * 1.15), + ), + LottieImage( + lottieUrl: segment.animojiUrl, + size: box, + memCacheWidth: 120, + shimmer: false, + eager: true, + ), + ], + ), + ), + ), + ); + continue; + } if (segment.url != null) { final url = segment.url!; final recognizer = TapGestureRecognizer() diff --git a/lib/frontend/widgets/lottie_image.dart b/lib/frontend/widgets/lottie_image.dart index 1219125..fea63fd 100644 --- a/lib/frontend/widgets/lottie_image.dart +++ b/lib/frontend/widgets/lottie_image.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:ui' as ui; import 'package:cached_network_image/cached_network_image.dart'; @@ -68,6 +69,8 @@ class LottiePlayer extends StatefulWidget { final String? fallbackUrl; final double? size; final int? memCacheWidth; + final bool shimmer; + final bool eager; const LottiePlayer({ super.key, @@ -75,6 +78,8 @@ class LottiePlayer extends StatefulWidget { this.fallbackUrl, this.size, this.memCacheWidth, + this.shimmer = true, + this.eager = false, }); @override @@ -95,6 +100,9 @@ class _LottiePlayerState extends State int? _px; bool _started = false; bool _showedFrames = false; + Timer? _deferTimer; + + static const Duration _maxLoadDefer = Duration(milliseconds: 700); double _speed = 1.0; double _targetSpeed = 1.0; @@ -103,7 +111,8 @@ class _LottiePlayerState extends State bool get _isScrolling => _scrollState?.value ?? false; bool get _canLoad => - !_isScrolling && !LottieLoadGovernor.instance.throttled.value; + !_isScrolling && + (widget.eager || !LottieLoadGovernor.instance.throttled.value); @override void initState() { @@ -130,6 +139,8 @@ class _LottiePlayerState extends State if (oldWidget.lottieUrl != widget.lottieUrl) { _ticker.stop(); _releaseClip(); + _deferTimer?.cancel(); + _deferTimer = null; _started = false; _showedFrames = false; _playheadMs = 0.0; @@ -141,6 +152,7 @@ class _LottiePlayerState extends State @override void dispose() { + _deferTimer?.cancel(); LottieLoadGovernor.instance.throttled.removeListener(_onGateChanged); _scrollState?.removeListener(_onGateChanged); _ticker.dispose(); @@ -217,13 +229,25 @@ class _LottiePlayerState extends State final dpr = MediaQuery.devicePixelRatioOf(context); final raw = (box * dpr.clamp(1.0, 2.0)).clamp(96.0, 384.0); _px = (raw / 32).ceil() * 32; - if (_started || !_canLoad) return; - _startLoad(); + if (_started) return; + if (_canLoad) { + _startLoad(); + } else if (!_isScrolling) { + // Blocked only by the frame-time governor: defer, but never starve. + _deferTimer ??= Timer(_maxLoadDefer, _forceDeferredLoad); + } + } + + void _forceDeferredLoad() { + _deferTimer = null; + if (mounted && !_started && _clip == null && !_isScrolling) _startLoad(); } void _startLoad() { final px = _px; if (_started || px == null) return; + _deferTimer?.cancel(); + _deferTimer = null; _started = true; RlottieEngine.instance.acquire(widget.lottieUrl, px).then((clip) { if (clip == null) return; @@ -281,8 +305,11 @@ class _LottiePlayerState extends State Widget _staticFallback(double box) { final url = widget.fallbackUrl ?? ''; - final blank = SizedBox(width: box, height: box); - if (url.isEmpty) return blank; + if (url.isEmpty) { + return widget.shimmer + ? LottieShimmer(size: box) + : SizedBox(width: box, height: box); + } return CachedNetworkImage( imageUrl: url, width: box, @@ -290,8 +317,8 @@ class _LottiePlayerState extends State fit: BoxFit.contain, memCacheWidth: widget.memCacheWidth, fadeInDuration: const Duration(milliseconds: 120), - placeholder: (_, _) => blank, - errorWidget: (_, _, _) => blank, + placeholder: (_, _) => LottieShimmer(size: box), + errorWidget: (_, _, _) => SizedBox(width: box, height: box), ); } } @@ -301,6 +328,8 @@ class LottieImage extends StatelessWidget { final String? lottieUrl; final double? size; final int? memCacheWidth; + final bool shimmer; + final bool eager; const LottieImage({ super.key, @@ -308,6 +337,8 @@ class LottieImage extends StatelessWidget { this.lottieUrl, this.size, this.memCacheWidth, + this.shimmer = true, + this.eager = false, }); @override @@ -318,6 +349,8 @@ class LottieImage extends StatelessWidget { fallbackUrl: url, size: size, memCacheWidth: memCacheWidth, + shimmer: shimmer, + eager: eager, ); } return _static(); @@ -325,8 +358,7 @@ class LottieImage extends StatelessWidget { Widget _static() { final src = url ?? ''; - final blank = SizedBox(width: size, height: size); - if (src.isEmpty) return blank; + if (src.isEmpty) return SizedBox(width: size, height: size); return CachedNetworkImage( imageUrl: src, width: size, @@ -334,8 +366,56 @@ class LottieImage extends StatelessWidget { fit: BoxFit.contain, memCacheWidth: memCacheWidth, fadeInDuration: const Duration(milliseconds: 120), - placeholder: (_, _) => blank, - errorWidget: (_, _, _) => blank, + placeholder: (_, _) => LottieShimmer(size: size), + errorWidget: (_, _, _) => SizedBox(width: size, height: size), + ); + } +} + +class LottieShimmer extends StatefulWidget { + final double? size; + + const LottieShimmer({super.key, this.size}); + + @override + State createState() => _LottieShimmerState(); +} + +class _LottieShimmerState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + )..repeat(reverse: true); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final base = Theme.of(context).colorScheme.onSurfaceVariant; + final box = widget.size; + final inset = box == null ? 2.0 : box * 0.06; + final radius = box == null ? 8.0 : (box * 0.2).clamp(6.0, 26.0); + return SizedBox( + width: box, + height: box, + child: Padding( + padding: EdgeInsets.all(inset), + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) => DecoratedBox( + decoration: BoxDecoration( + color: base.withValues(alpha: 0.12 + 0.16 * _controller.value), + borderRadius: BorderRadius.circular(radius), + ), + child: const SizedBox.expand(), + ), + ), + ), ); } } diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 7062163..16ba6f2 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -13,6 +13,7 @@ import '../../core/config/app_bubble_behavior.dart'; import '../../core/config/app_bubble_shape.dart'; import '../../core/utils/bubble_radius.dart'; import '../../core/utils/link_opener.dart'; +import '../../core/utils/text_format.dart'; import '../../core/utils/webview_support.dart'; import '../../core/config/app_link_preview.dart'; import 'custom_notification.dart'; @@ -31,6 +32,7 @@ import 'attachment/bubbles/photo_bubble.dart'; import 'attachment/bubbles/video_bubble.dart'; import 'attachment/bubbles/file_bubble.dart'; import 'attachment/bubbles/forwarded_bubble.dart'; +import 'lottie_image.dart'; final Expando _contentTypeCache = Expando(); @@ -211,6 +213,17 @@ class MessageBubble extends StatelessWidget { return a.first is StickerAttachment; } + static const int _jumboAnimojiLimit = 4; + + List? get _jumboAnimojiUrls { + if (message.attachments?.isNotEmpty ?? false) return null; + return animojiOnlyLottieUrls( + message.text, + message.formatRanges, + limit: _jumboAnimojiLimit, + ); + } + MessageType get _contentType { if (_hasShareAttachment) return _computeContentType(); return _contentTypeCache[message] ??= _computeContentType(); @@ -454,7 +467,10 @@ class MessageBubble extends StatelessWidget { final topMargin = _topMarginFor(contentType, shape); final bottomMargin = _bottomMarginFor(contentType, shape); - final padding = _paddingFor(contentType, shape); + final jumboAnimoji = _jumboAnimojiUrls; + final padding = jumboAnimoji != null + ? EdgeInsets.zero + : _paddingFor(contentType, shape); final showAvatarSlot = !isMe; final showAvatar = @@ -469,7 +485,7 @@ class MessageBubble extends StatelessWidget { final maxBubbleWidth = math.min(MediaQuery.sizeOf(context).width * 0.75, 560.0); final keyboard = _inlineKeyboard; final isVideoNote = _isVideoNote; - final noBubbleBackground = isVideoNote || _isSticker; + final noBubbleBackground = isVideoNote || _isSticker || jumboAnimoji != null; final bubbleColor = noBubbleBackground ? Colors.transparent : (isMe ? cs.primaryContainer : cs.surfaceContainerHighest); @@ -508,7 +524,7 @@ class MessageBubble extends StatelessWidget { Widget withReply(Widget content) { if (reply == null) return content; final quote = _buildReplyQuote(context, cs, textColor, reply); - if (contentType != MessageType.text) { + if (contentType != MessageType.text || jumboAnimoji != null) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -810,6 +826,8 @@ class MessageBubble extends StatelessWidget { } Widget _buildContent(BubbleContext ctx) { + final jumbo = _jumboAnimojiUrls; + if (jumbo != null) return _buildJumboAnimojiContent(ctx, jumbo); switch (ctx.contentType) { case MessageType.control: return _buildControlContent(ctx.cs); @@ -822,6 +840,86 @@ class MessageBubble extends StatelessWidget { } } + Widget _buildJumboAnimojiContent(BubbleContext ctx, List urls) { + final n = urls.length; + final size = switch (n) { + 1 => 96.0, + 2 => 76.0, + 3 => 64.0, + _ => 56.0, + }; + final cache = (size * 2).round(); + + final animations = Stack( + children: [ + Wrap( + spacing: 2, + runSpacing: 2, + alignment: ctx.isMe ? WrapAlignment.end : WrapAlignment.start, + children: [ + for (final url in urls) + SizedBox( + width: size, + height: size, + child: LottieImage( + lottieUrl: url, + size: size, + memCacheWidth: cache, + eager: true, + ), + ), + ], + ), + Positioned( + bottom: BubbleContext.compactTimePadding, + right: BubbleContext.compactTimePadding, + child: _buildJumboAnimojiMeta(ctx), + ), + ], + ); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: ctx.isMe + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [animations, _buildReactionsBarFor(ctx.cs, ctx.reactionInfo)], + ); + } + + Widget _buildJumboAnimojiMeta(BubbleContext ctx) { + final status = ctx.overrideStatus ?? ctx.message.status; + final statusVisual = messageStatusVisual(status, dimColor: Colors.white); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + ctx.clockText, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + if (ctx.isMe) ...[ + const SizedBox(width: 3), + Icon(statusVisual.icon, size: 13, color: statusVisual.color), + ], + if (ctx.message.deleted) ...[ + const SizedBox(width: 3), + const Icon(Symbols.delete, size: 12, color: Colors.white), + ], + ], + ), + ); + } + Widget _buildReactionsBar(ColorScheme cs) { final info = message.payload?['reactionInfo']; return _buildReactionsBarFor(cs, info is Map ? info : null); diff --git a/lib/frontend/widgets/rich_message_controller.dart b/lib/frontend/widgets/rich_message_controller.dart index ef65efd..70a82ba 100644 --- a/lib/frontend/widgets/rich_message_controller.dart +++ b/lib/frontend/widgets/rich_message_controller.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import '../../core/utils/text_format.dart'; +import '../../models/animoji.dart'; +import 'lottie_image.dart'; const List composerFormats = [ TextFormat.strong, @@ -16,11 +18,114 @@ class _Interval { _Interval(this.start, this.end); } +class _AnimojiEntity { + final int uid; + int offset; + final String emoji; + final String lottieUrl; + final int entityId; + + _AnimojiEntity({ + required this.uid, + required this.offset, + required this.emoji, + required this.lottieUrl, + required this.entityId, + }); +} + class RichMessageController extends TextEditingController { + static const String _animojiPlaceholder = ''; + final Map> _intervals = {}; + final List<_AnimojiEntity> _animoji = []; + int _entitySeq = 0; RichMessageController({super.text}); + void insertAnimoji(Animoji animoji) { + final lottie = animoji.lottieUrl ?? animoji.lottiePlayUrl; + if (lottie == null || lottie.isEmpty) return; + + final selection = value.selection; + final oldText = value.text; + final start = selection.isValid ? selection.start : oldText.length; + final end = selection.isValid ? selection.end : oldText.length; + final newText = oldText.replaceRange(start, end, _animojiPlaceholder); + + value = TextEditingValue( + text: newText, + selection: TextSelection.collapsed( + offset: start + _animojiPlaceholder.length, + ), + ); + + _animoji.add( + _AnimojiEntity( + uid: _entitySeq++, + offset: start, + emoji: animoji.emoji, + lottieUrl: lottie, + entityId: animoji.id, + ), + ); + _animoji.sort((a, b) => a.offset.compareTo(b.offset)); + notifyListeners(); + } + + ({String text, List> elements}) buildContent() { + final src = value.text; + if (_animoji.isEmpty) { + return (text: src, elements: elementsForSend()); + } + + final entities = [..._animoji]..sort((a, b) => a.offset.compareTo(b.offset)); + + final sb = StringBuffer(); + var last = 0; + for (final e in entities) { + if (e.offset < last || e.offset >= src.length) continue; + sb.write(src.substring(last, e.offset)); + sb.write(e.emoji); + last = e.offset + _animojiPlaceholder.length; + } + sb.write(src.substring(last)); + final glyphText = sb.toString(); + + int glyphOffset(int p) { + var shift = 0; + for (final e in entities) { + if (e.offset < p && e.offset < src.length) { + shift += e.emoji.length - _animojiPlaceholder.length; + } + } + return p + shift; + } + + final elements = >[]; + for (final e in entities) { + if (e.offset >= src.length) continue; + elements.add({ + 'type': 'ANIMOJI', + 'from': glyphOffset(e.offset), + 'length': e.emoji.length, + 'entityId': e.entityId, + 'attributes': {'animojiLottieUrl': e.lottieUrl}, + }); + } + for (final range in _toFormatRanges()) { + final from = glyphOffset(range.start); + final to = glyphOffset(range.end); + if (to <= from) continue; + elements.add({ + 'type': textFormatToServer(range.format), + 'from': from, + 'length': to - from, + }); + } + return (text: glyphText, elements: elements); + } + @override set value(TextEditingValue newValue) { final oldText = value.text; @@ -95,7 +200,7 @@ class RichMessageController extends TextEditingController { } void _remap(String oldText, String newText) { - if (_intervals.isEmpty) return; + if (_intervals.isEmpty && _animoji.isEmpty) return; final oldLen = oldText.length; final newLen = newText.length; @@ -126,6 +231,15 @@ class RichMessageController extends TextEditingController { return changeStart; } + if (_animoji.isNotEmpty) { + _animoji.removeWhere( + (e) => e.offset >= changeStart && e.offset < oldChangeEnd, + ); + for (final e in _animoji) { + if (e.offset >= oldChangeEnd) e.offset += delta; + } + } + final empty = []; _intervals.forEach((format, list) { for (final interval in list) { @@ -204,26 +318,66 @@ class RichMessageController extends TextEditingController { }) { final baseStyle = style ?? const TextStyle(); final content = text; - if (!hasFormatting || content.isEmpty) { + if ((!hasFormatting && _animoji.isEmpty) || content.isEmpty) { return TextSpan(style: baseStyle, text: content); } final ranges = _toFormatRanges(); - final baseColor = baseStyle.color; final quoteColor = baseColor?.withValues(alpha: 0.85); final segments = segmentizeFormats(content, ranges); - final spans = [ - for (final segment in segments) - TextSpan( - text: content.substring(segment.start, segment.end), - style: applyTextFormats( - baseStyle, - segment.formats, - quoteColor: quoteColor, + final entityByOffset = {for (final e in _animoji) e.offset: e}; + final box = (baseStyle.fontSize ?? 16) * 1.4; + + final spans = []; + for (final segment in segments) { + final segStyle = applyTextFormats( + baseStyle, + segment.formats, + quoteColor: quoteColor, + ); + var runStart = segment.start; + var i = segment.start; + while (i < segment.end) { + final entity = entityByOffset[i]; + if (entity == null) { + i++; + continue; + } + if (runStart < i) { + spans.add( + TextSpan(text: content.substring(runStart, i), style: segStyle), + ); + } + spans.add(_animojiSpan(entity, box)); + i += _animojiPlaceholder.length; + runStart = i; + } + if (runStart < segment.end) { + spans.add( + TextSpan( + text: content.substring(runStart, segment.end), + style: segStyle, ), - ), - ]; + ); + } + } return TextSpan(style: baseStyle, children: spans); } + + WidgetSpan _animojiSpan(_AnimojiEntity entity, double box) { + return WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: SizedBox( + key: ValueKey('composer-animoji-${entity.uid}'), + width: box, + height: box, + child: LottieImage( + lottieUrl: entity.lottieUrl, + size: box, + memCacheWidth: 120, + ), + ), + ); + } } diff --git a/lib/frontend/widgets/segmented_pill_toggle.dart b/lib/frontend/widgets/segmented_pill_toggle.dart new file mode 100644 index 0000000..bbecd8c --- /dev/null +++ b/lib/frontend/widgets/segmented_pill_toggle.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; + +class SegmentedPillToggle extends StatelessWidget { + final List labels; + final int selected; + final ValueChanged onChanged; + final double segmentWidth; + final double height; + + const SegmentedPillToggle({ + super.key, + required this.labels, + required this.selected, + required this.onChanged, + this.segmentWidth = 88, + this.height = 34, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + const pad = 3.0; + final sel = selected.clamp(0, labels.length - 1); + + return Container( + height: height, + padding: const EdgeInsets.all(pad), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(height / 2), + ), + child: Stack( + children: [ + AnimatedPositioned( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + left: sel * segmentWidth, + top: 0, + bottom: 0, + width: segmentWidth, + child: DecoratedBox( + decoration: BoxDecoration( + color: cs.primary, + borderRadius: BorderRadius.circular((height - 2 * pad) / 2), + ), + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(labels.length, (i) { + final active = i == sel; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onChanged(i), + child: SizedBox( + width: segmentWidth, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: active ? cs.onPrimary : cs.onSurfaceVariant, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + child: Text(labels[i]), + ), + ), + ), + ); + }), + ), + ], + ), + ); + } +} diff --git a/lib/frontend/widgets/sticker_panel.dart b/lib/frontend/widgets/sticker_panel.dart index 27130ff..0fedcdb 100644 --- a/lib/frontend/widgets/sticker_panel.dart +++ b/lib/frontend/widgets/sticker_panel.dart @@ -1,14 +1,19 @@ +import 'dart:async'; import 'dart:ui' as ui; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../../core/utils/debouncer.dart'; import '../../core/utils/emoji_keyword_index.dart'; import '../../main.dart' show stickersModule; +import '../../models/animoji.dart'; import '../../models/sticker.dart'; +import 'emoji_panel.dart'; +import 'segmented_pill_toggle.dart'; import 'small_spinner.dart'; import 'lottie_image.dart'; import 'sticker_peek.dart'; @@ -43,11 +48,13 @@ class _Section { class StickerPanel extends StatefulWidget { final double height; final void Function(StickerItem sticker) onStickerTap; + final void Function(Animoji animoji)? onEmojiTap; const StickerPanel({ super.key, required this.height, required this.onStickerTap, + this.onEmojiTap, }); @override @@ -59,6 +66,12 @@ class _StickerPanelState extends State static const double _tabBarHeight = 52; static const double _headerHeight = 34; static const double _searchFieldHeight = 50; + static const double _toggleBarHeight = 48; + static const int _modeEmoji = 0; + static const int _modeStickers = 1; + static const String _modePrefKey = 'komet_panel_mode'; + static int _persistedMode = _modeStickers; + static bool _persistedModeLoaded = false; final ScrollController _scroll = ScrollController(); final ValueNotifier _scrolling = ValueNotifier(false); @@ -70,6 +83,8 @@ class _StickerPanelState extends State late final AnimationController _shimmer; bool _loading = true; Object? _error; + late int _mode; + bool _modeUserChosen = false; int _selectedTab = 0; List<_Section> _sections = const []; List _heights = const []; @@ -81,6 +96,8 @@ class _StickerPanelState extends State @override void initState() { super.initState(); + _mode = widget.onEmojiTap == null ? _modeStickers : _persistedMode; + if (!_persistedModeLoaded) unawaited(_loadPersistedMode()); _shimmer = AnimationController( vsync: this, duration: const Duration(milliseconds: 900), @@ -244,51 +261,15 @@ class _StickerPanelState extends State ), ), ), - child: _loading - ? Center(child: SmallSpinner()) - : _error != null || _sections.isEmpty - ? Center( - child: Text( - _error != null - ? 'Не удалось загрузить стикеры' - : 'Нет стикеров', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), - ), - ) - : ScrollConfiguration( - behavior: const _DragScrollBehavior(), - child: LayoutBuilder( - builder: (context, constraints) { - final width = constraints.maxWidth; - final columns = (width / 84).floor().clamp(4, 8); - final cell = width / columns; - - final heights = []; - final offsets = []; - var acc = _searchFieldHeight; - for (final s in _sections) { - final rows = (s.stickerIds.length / columns).ceil(); - final h = _headerHeight + rows * cell; - offsets.add(acc); - heights.add(h); - acc += h; - } - _heights = heights; - _offsets = offsets; - - return Column( - children: [ - _buildTabBar(cs), - Divider( - height: 1, - thickness: 1, - color: cs.outlineVariant.withValues(alpha: 0.3), - ), - Expanded(child: _buildContent(cs, columns, cell)), - ], - ); - }, - ), + child: Column( + children: [ + Expanded( + child: _mode == _modeEmoji && widget.onEmojiTap != null + ? EmojiPanel(onEmojiTap: widget.onEmojiTap!) + : _buildStickerBody(cs), + ), + if (widget.onEmojiTap != null) _buildToggleBar(cs), + ], ), ), ), @@ -296,6 +277,105 @@ class _StickerPanelState extends State ); } + Widget _buildStickerBody(ColorScheme cs) { + if (_loading) return Center(child: SmallSpinner()); + if (_error != null || _sections.isEmpty) { + return Center( + child: Text( + _error != null ? 'Не удалось загрузить стикеры' : 'Нет стикеров', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + ); + } + return ScrollConfiguration( + behavior: const _DragScrollBehavior(), + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final columns = (width / 84).floor().clamp(4, 8); + final cell = width / columns; + + final heights = []; + final offsets = []; + var acc = _searchFieldHeight; + for (final s in _sections) { + final rows = (s.stickerIds.length / columns).ceil(); + final h = _headerHeight + rows * cell; + offsets.add(acc); + heights.add(h); + acc += h; + } + _heights = heights; + _offsets = offsets; + + return Column( + children: [ + _buildTabBar(cs), + Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.3), + ), + Expanded(child: _buildContent(cs, columns, cell)), + ], + ); + }, + ), + ); + } + + Future _loadPersistedMode() async { + try { + final prefs = await SharedPreferences.getInstance(); + final value = prefs.getInt(_modePrefKey); + _persistedModeLoaded = true; + if (value != _modeEmoji && value != _modeStickers) return; + _persistedMode = value!; + if (!mounted || _modeUserChosen || widget.onEmojiTap == null) return; + if (_mode != value) setState(() => _mode = value); + } catch (_) { + _persistedModeLoaded = true; + } + } + + void _setMode(int mode) { + if (mode == _mode) return; + _modeUserChosen = true; + _persistedMode = mode; + setState(() => _mode = mode); + unawaited(_persistMode(mode)); + } + + Future _persistMode(int mode) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_modePrefKey, mode); + } catch (_) {} + } + + Widget _buildToggleBar(ColorScheme cs) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.3), + ), + SizedBox( + height: _toggleBarHeight, + child: Center( + child: SegmentedPillToggle( + labels: const ['Эмодзи', 'Стикеры'], + selected: _mode, + onChanged: _setMode, + ), + ), + ), + ], + ); + } + Widget _buildTabBar(ColorScheme cs) { return SizedBox( height: _tabBarHeight, diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 9173b07..48cf7f7 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -2,6 +2,12 @@ cmake_minimum_required(VERSION 3.13) project(runner LANGUAGES CXX) +# Capture whether the install prefix is still CMake's default before any +# add_subdirectory() runs. Third-party libraries (rlottie) call project() +# again, which resets CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT to false and +# would otherwise defeat the bundle-directory redirect below. +set(RUNNER_PREFIX_IS_DEFAULT ${CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT}) + # The name of the executable created for the application. Change this to change # the on-disk name of your application. set(BINARY_NAME "Komet") @@ -92,7 +98,7 @@ add_subdirectory("${RLOTTIE_DIR}" "${CMAKE_BINARY_DIR}/rlottie") # By default, "installing" just makes a relocatable bundle in the build # directory. set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") -if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) +if(RUNNER_PREFIX_IS_DEFAULT OR CMAKE_INSTALL_PREFIX STREQUAL "/usr/local") set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) endif() diff --git a/test/animoji_jumbo_test.dart b/test/animoji_jumbo_test.dart new file mode 100644 index 0000000..d23efad --- /dev/null +++ b/test/animoji_jumbo_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:komet/core/utils/text_format.dart'; + +FormatRange _animoji(int start, int length, String url) => FormatRange( + format: TextFormat.animoji, + start: start, + length: length, + attributes: {'animojiLottieUrl': url}, +); + +void main() { + test('single animoji-only message is jumbo', () { + expect(animojiOnlyLottieUrls('❤️', [_animoji(0, 2, 'L1')]), ['L1']); + }); + + test('several animoji with no other text are jumbo, in order', () { + final urls = animojiOnlyLottieUrls('❤️🔥', [ + _animoji(2, 2, 'L2'), + _animoji(0, 2, 'L1'), + ]); + expect(urls, ['L1', 'L2']); + }); + + test('animoji mixed with real text is NOT jumbo', () { + expect( + animojiOnlyLottieUrls('animoji message🤣', [_animoji(15, 2, 'L1')]), + isNull, + ); + }); + + test('plain emoji without an ANIMOJI element is NOT jumbo', () { + expect(animojiOnlyLottieUrls('😀', const []), isNull); + }); + + test('more than the limit is NOT jumbo', () { + final ranges = [ + for (var i = 0; i < 5; i++) _animoji(i * 2, 2, 'L$i'), + ]; + expect(animojiOnlyLottieUrls('❤️❤️❤️❤️❤️', ranges), isNull); + }); + + test('whitespace between animoji is allowed', () { + expect( + animojiOnlyLottieUrls('❤️ ❤️', [_animoji(0, 2, 'L1'), _animoji(3, 2, 'L2')]), + ['L1', 'L2'], + ); + }); +} diff --git a/test/rich_message_controller_animoji_test.dart b/test/rich_message_controller_animoji_test.dart new file mode 100644 index 0000000..bf2ec13 --- /dev/null +++ b/test/rich_message_controller_animoji_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:komet/frontend/widgets/rich_message_controller.dart'; +import 'package:komet/models/animoji.dart'; + +Animoji _a(int id, String emoji, String lottie) => + Animoji(id: id, emoji: emoji, lottieUrl: lottie); + +void main() { + test('standalone animoji builds one ANIMOJI element at offset 0', () { + final c = RichMessageController(); + c.insertAnimoji(_a(125, '❤️', 'L1')); + + final content = c.buildContent(); + expect(content.text, '❤️'); + expect(content.elements, [ + { + 'type': 'ANIMOJI', + 'from': 0, + 'length': 2, + 'entityId': 125, + 'attributes': {'animojiLottieUrl': 'L1'}, + }, + ]); + }); + + test('animoji appended after text gets the correct utf16 offset', () { + final c = RichMessageController(); + c.value = const TextEditingValue( + text: 'test', + selection: TextSelection.collapsed(offset: 4), + ); + c.insertAnimoji(_a(7, '🤣', 'L2')); + + final content = c.buildContent(); + expect(content.text, 'test🤣'); + expect(content.elements.single['from'], 4); + expect(content.elements.single['length'], 2); + expect(content.elements.single['type'], 'ANIMOJI'); + }); + + test('multiple animoji with surrounding text keep glyph offsets in order', () { + final c = RichMessageController(); + c.value = const TextEditingValue( + text: 'a', + selection: TextSelection.collapsed(offset: 1), + ); + c.insertAnimoji(_a(1, '❤️', 'L1')); + // caret now after first placeholder; type "b" + final t1 = c.value.text; // "a" + c.value = TextEditingValue( + text: '${t1}b', + selection: TextSelection.collapsed(offset: t1.length + 1), + ); + c.insertAnimoji(_a(2, '🔥', 'L3')); + + final content = c.buildContent(); + expect(content.text, 'a❤️b🔥'); + + final froms = content.elements + .where((e) => e['type'] == 'ANIMOJI') + .map((e) => e['from']) + .toList(); + expect(froms, [1, 4]); + }); + + testWidgets('built span plain text matches controller text (caret invariant)', ( + tester, + ) async { + late BuildContext ctx; + await tester.pumpWidget( + WidgetsApp( + color: const Color(0xFF000000), + builder: (context, _) { + ctx = context; + return const SizedBox(); + }, + ), + ); + + final c = RichMessageController(); + c.value = const TextEditingValue( + text: 'hi', + selection: TextSelection.collapsed(offset: 2), + ); + c.insertAnimoji(_a(1, '❤️', 'L1')); + c.value = TextEditingValue( + text: '${c.value.text}!', + selection: TextSelection.collapsed(offset: c.value.text.length + 1), + ); + + final span = c.buildTextSpan( + context: ctx, + style: const TextStyle(fontSize: 16), + withComposing: false, + ); + expect(span.toPlainText(), c.text); + }); + + test('deleting the placeholder char drops the entity', () { + final c = RichMessageController(); + c.insertAnimoji(_a(1, '❤️', 'L1')); + expect(c.value.text.length, 1); + // backspace: remove the placeholder + c.value = const TextEditingValue( + text: '', + selection: TextSelection.collapsed(offset: 0), + ); + final content = c.buildContent(); + expect(content.text, ''); + expect(content.elements, isEmpty); + }); +}