feat: добавил отображение + отправку animoji. Добавил матовое стекло для панели ответа при кастомизационном выборе 'прозрачность'

This commit is contained in:
Jganenokk
2026-07-11 23:46:31 +07:00
parent dfece96a5e
commit 3c6de2c496
15 changed files with 1228 additions and 91 deletions
+37
View File
@@ -1,3 +1,5 @@
import 'package:shared_preferences/shared_preferences.dart';
import '../api.dart'; import '../api.dart';
import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/opcode_map.dart';
import '../../core/utils/logger.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<int, Animoji> _byId = {}; final Map<int, Animoji> _byId = {};
List<int> _orderedIds = []; List<int> _orderedIds = [];
List<int> _recentIds = [];
bool _recentsLoaded = false;
Future<void>? _loading; Future<void>? _loading;
bool get isLoaded => _orderedIds.isNotEmpty; bool get isLoaded => _orderedIds.isNotEmpty;
@@ -26,8 +33,38 @@ class AnimojiModule {
List<Animoji> get animojis => List<Animoji> get animojis =>
_orderedIds.map((id) => _byId[id]).whereType<Animoji>().toList(); _orderedIds.map((id) => _byId[id]).whereType<Animoji>().toList();
List<Animoji> get recentAnimojis =>
_recentIds.map((id) => _byId[id]).whereType<Animoji>().toList();
List<String> get emojis => animojis.map((a) => a.emoji).toList(); List<String> get emojis => animojis.map((a) => a.emoji).toList();
Future<void> 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<int>().toList();
} catch (_) {}
}
Future<void> 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<Animoji> get quickAnimojis { List<Animoji> get quickAnimojis {
final list = animojis; final list = animojis;
return list.length <= 6 ? list : list.sublist(0, 6); return list.length <= 6 ? list : list.sublist(0, 6);
+46 -1
View File
@@ -8,6 +8,7 @@ enum TextFormat {
monospaced, monospaced,
quote, quote,
link, link,
animoji,
} }
const Map<TextFormat, String> _formatToServer = { const Map<TextFormat, String> _formatToServer = {
@@ -18,6 +19,7 @@ const Map<TextFormat, String> _formatToServer = {
TextFormat.monospaced: 'MONOSPACED', TextFormat.monospaced: 'MONOSPACED',
TextFormat.quote: 'QUOTE', TextFormat.quote: 'QUOTE',
TextFormat.link: 'LINK', TextFormat.link: 'LINK',
TextFormat.animoji: 'ANIMOJI',
}; };
final Map<String, TextFormat> _serverToFormat = { final Map<String, TextFormat> _serverToFormat = {
@@ -49,6 +51,11 @@ class FormatRange {
return value is String ? value : null; return value is String ? value : null;
} }
String? get animojiUrl {
final value = attributes?['animojiLottieUrl'];
return value is String && value.isNotEmpty ? value : null;
}
Map<String, dynamic> toServer() => { Map<String, dynamic> toServer() => {
'type': textFormatToServer(format), 'type': textFormatToServer(format),
'from': start, 'from': start,
@@ -87,6 +94,34 @@ List<Map<String, dynamic>> serializeFormatElements(
Iterable<FormatRange> ranges, Iterable<FormatRange> ranges,
) => [for (final range in ranges) range.toServer()]; ) => [for (final range in ranges) range.toServer()];
List<String>? animojiOnlyLottieUrls(
String? text,
List<FormatRange> 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) { int _asInt(dynamic value) {
if (value is int) return value; if (value is int) return value;
if (value is String) return int.tryParse(value) ?? 0; if (value is String) return int.tryParse(value) ?? 0;
@@ -98,12 +133,14 @@ class FormatSegment {
final int end; final int end;
final Set<TextFormat> formats; final Set<TextFormat> formats;
final String? url; final String? url;
final String? animojiUrl;
const FormatSegment({ const FormatSegment({
required this.start, required this.start,
required this.end, required this.end,
required this.formats, required this.formats,
this.url, this.url,
this.animojiUrl,
}); });
} }
@@ -142,14 +179,22 @@ List<FormatSegment> segmentizeFormats(String text, List<FormatRange> ranges) {
if (end <= start) continue; if (end <= start) continue;
final formats = <TextFormat>{}; final formats = <TextFormat>{};
String? url; String? url;
String? animojiUrl;
for (final range in clamped) { for (final range in clamped) {
if (range.start <= start && range.end >= end) { if (range.start <= start && range.end >= end) {
formats.add(range.format); formats.add(range.format);
if (range.format == TextFormat.link) url ??= range.url; if (range.format == TextFormat.link) url ??= range.url;
if (range.format == TextFormat.animoji) animojiUrl ??= range.animojiUrl;
} }
} }
segments.add( segments.add(
FormatSegment(start: start, end: end, formats: formats, url: url), FormatSegment(
start: start,
end: end,
formats: formats,
url: url,
animojiUrl: animojiUrl,
),
); );
} }
return segments; return segments;
@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:komet/backend/modules/messages.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/core/config/app_colors.dart';
import 'package:komet/frontend/screens/chats/chat/upload_status.dart'; import 'package:komet/frontend/screens/chats/chat/upload_status.dart';
import 'package:komet/frontend/screens/chats/chat/video_note_controller.dart'; import 'package:komet/frontend/screens/chats/chat/video_note_controller.dart';
@@ -17,6 +18,7 @@ class ComposerInputBar extends StatelessWidget {
const ComposerInputBar({ const ComposerInputBar({
super.key, super.key,
required this.chatType, required this.chatType,
required this.chrome,
required this.attachAnim, required this.attachAnim,
required this.replyTo, required this.replyTo,
required this.myId, required this.myId,
@@ -40,6 +42,7 @@ class ComposerInputBar extends StatelessWidget {
}); });
final String chatType; final String chatType;
final ChatChromeStyle chrome;
final Animation<double> attachAnim; final Animation<double> attachAnim;
final ValueListenable<CachedMessage?> replyTo; final ValueListenable<CachedMessage?> replyTo;
final int myId; final int myId;
@@ -418,7 +421,7 @@ class ComposerInputBar extends StatelessWidget {
attachments: reply.attachments, attachments: reply.attachments,
); );
final preview = info.previewText(); final preview = info.previewText();
return Padding( final row = Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 8, 2), padding: const EdgeInsets.fromLTRB(16, 6, 8, 2),
child: Row( child: Row(
children: [ 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,
),
),
);
}, },
); );
} }
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:komet/frontend/screens/chats/chat/sticker_panel_controller.dart'; import 'package:komet/frontend/screens/chats/chat/sticker_panel_controller.dart';
import 'package:komet/frontend/widgets/sticker_panel.dart'; import 'package:komet/frontend/widgets/sticker_panel.dart';
import 'package:komet/models/animoji.dart';
import 'package:komet/models/sticker.dart'; import 'package:komet/models/sticker.dart';
class StickerPanelView extends StatelessWidget { class StickerPanelView extends StatelessWidget {
@@ -9,10 +10,12 @@ class StickerPanelView extends StatelessWidget {
super.key, super.key,
required this.stickers, required this.stickers,
required this.onStickerTap, required this.onStickerTap,
this.onEmojiTap,
}); });
final StickerPanelController stickers; final StickerPanelController stickers;
final void Function(StickerItem sticker) onStickerTap; final void Function(StickerItem sticker) onStickerTap;
final void Function(Animoji animoji)? onEmojiTap;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -21,6 +24,7 @@ class StickerPanelView extends StatelessWidget {
child: StickerPanel( child: StickerPanel(
height: stickers.panelHeight, height: stickers.panelHeight,
onStickerTap: onStickerTap, onStickerTap: onStickerTap,
onEmojiTap: onEmojiTap,
), ),
builder: (context, child) { builder: (context, child) {
final t = Curves.easeOutCubic.transform( final t = Curves.easeOutCubic.transform(
+26 -16
View File
@@ -497,7 +497,6 @@ class _ChatScreenState extends State<ChatScreen>
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
chats.chatsChanged.addListener(_onChatsBump); chats.chatsChanged.addListener(_onChatsBump);
_messageController.addListener(_onTextChanged); _messageController.addListener(_onTextChanged);
_messageFocusNode.addListener(_onComposerFocusChanged);
_scrollController.addListener(_onScrollForDate); _scrollController.addListener(_onScrollForDate);
_scrollController.addListener(_maybeLoadMoreHistory); _scrollController.addListener(_maybeLoadMoreHistory);
_scrollController.addListener(_recordScrollPixels); _scrollController.addListener(_recordScrollPixels);
@@ -1368,7 +1367,6 @@ class _ChatScreenState extends State<ChatScreen>
_search.dispose(); _search.dispose();
_selectedIds.dispose(); _selectedIds.dispose();
_messageController.dispose(); _messageController.dispose();
_messageFocusNode.removeListener(_onComposerFocusChanged);
_messageFocusNode.dispose(); _messageFocusNode.dispose();
_stickers.dispose(); _stickers.dispose();
_scrollController.dispose(); _scrollController.dispose();
@@ -1413,7 +1411,11 @@ class _ChatScreenState extends State<ChatScreen>
void _saveDraft() { void _saveDraft() {
if (_myId == 0) return; if (_myId == 0) return;
unawaited( 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<ChatScreen>
), ),
ComposerInputBar( ComposerInputBar(
chatType: widget.chatType, chatType: widget.chatType,
chrome: _effectiveChrome,
attachAnim: _attachAnim, attachAnim: _attachAnim,
replyTo: _replyTo, replyTo: _replyTo,
myId: _myId, myId: _myId,
@@ -1875,7 +1878,11 @@ class _ChatScreenState extends State<ChatScreen>
isMuted: chat?.isMuted ?? false, isMuted: chat?.isMuted ?? false,
onToggleMute: _toggleChatMute, onToggleMute: _toggleChatMute,
), ),
StickerPanelView(stickers: _stickers, onStickerTap: _sendSticker), StickerPanelView(
stickers: _stickers,
onStickerTap: _sendSticker,
onEmojiTap: _insertAnimoji,
),
], ],
), ),
), ),
@@ -2016,9 +2023,10 @@ class _ChatScreenState extends State<ChatScreen>
return; return;
} }
final rawText = controller.text; final content = controller.buildContent();
final rawText = content.text;
final newText = rawText.trim(); final newText = rawText.trim();
final elements = _trimmedElements(controller, rawText, newText); final elements = _trimmedElements(content.elements, rawText, newText);
controller.dispose(); controller.dispose();
final oldElements = serializeFormatElements( final oldElements = serializeFormatElements(
@@ -2791,6 +2799,8 @@ class _ChatScreenState extends State<ChatScreen>
return 'Цитата'; return 'Цитата';
case TextFormat.link: case TextFormat.link:
return 'Ссылка'; return 'Ссылка';
case TextFormat.animoji:
return 'Animoji';
} }
} }
@@ -2842,11 +2852,10 @@ class _ChatScreenState extends State<ChatScreen>
} }
List<Map<String, dynamic>> _trimmedElements( List<Map<String, dynamic>> _trimmedElements(
RichMessageController controller, List<Map<String, dynamic>> raw,
String rawText, String rawText,
String text, String text,
) { ) {
final raw = controller.elementsForSend();
if (raw.isEmpty) return const []; if (raw.isEmpty) return const [];
final leading = rawText.length - rawText.trimLeft().length; final leading = rawText.length - rawText.trimLeft().length;
final result = <Map<String, dynamic>>[]; final result = <Map<String, dynamic>>[];
@@ -2866,7 +2875,8 @@ class _ChatScreenState extends State<ChatScreen>
} }
Future<void> _sendMessage() async { Future<void> _sendMessage() async {
final rawText = _messageController.text; final content = _messageController.buildContent();
final rawText = content.text;
final text = rawText.trim(); final text = rawText.trim();
if (text.isEmpty || _myId == 0) return; if (text.isEmpty || _myId == 0) return;
@@ -2911,7 +2921,7 @@ class _ChatScreenState extends State<ChatScreen>
} }
_replyTo.value = null; _replyTo.value = null;
final elements = _trimmedElements(_messageController, rawText, text); final elements = _trimmedElements(content.elements, rawText, text);
final Map<String, dynamic>? composedPayload = final Map<String, dynamic>? composedPayload =
(replyPayload == null && elements.isEmpty) (replyPayload == null && elements.isEmpty)
? null ? null
@@ -5117,12 +5127,6 @@ class _ChatScreenState extends State<ChatScreen>
_stickers.showPanel.value = true; _stickers.showPanel.value = true;
} }
void _onComposerFocusChanged() {
if (_messageFocusNode.hasFocus && _stickers.showPanel.value) {
_stickers.hide();
}
}
Future<void> _sendSticker(StickerItem sticker) async { Future<void> _sendSticker(StickerItem sticker) async {
_stickers.hide(); _stickers.hide();
await _sendAttachMessage([ await _sendAttachMessage([
@@ -5136,6 +5140,12 @@ class _ChatScreenState extends State<ChatScreen>
], () => messagesModule.sendStickerMessage(widget.chatId, sticker.id)); ], () => messagesModule.sendStickerMessage(widget.chatId, sticker.id));
} }
void _insertAnimoji(Animoji animoji) {
_messageController.insertAnimoji(animoji);
unawaited(animojiModule.noteUsed(animoji));
Haptics.selection();
}
Future<void> _shareLocation() async { Future<void> _shareLocation() async {
final position = await _resolveCurrentPosition(); final position = await _resolveCurrentPosition();
if (position == null || !mounted) return; if (position == null || !mounted) return;
+332
View File
@@ -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<PointerDeviceKind> get dragDevices => const {
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
PointerDeviceKind.trackpad,
PointerDeviceKind.stylus,
PointerDeviceKind.invertedStylus,
};
}
class _EmojiSection {
final String title;
final IconData icon;
final List<Animoji> 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<EmojiPanel> createState() => _EmojiPanelState();
}
class _EmojiPanelState extends State<EmojiPanel> {
static const double _tabBarHeight = 46;
static const double _headerHeight = 30;
final ScrollController _scroll = ScrollController();
final ValueNotifier<bool> _scrolling = ValueNotifier(false);
bool _loading = true;
Object? _error;
int _selectedTab = 0;
List<_EmojiSection> _sections = const [];
List<double> _heights = const [];
List<double> _offsets = const [];
@override
void initState() {
super.initState();
_scroll.addListener(_onScroll);
_load();
}
@override
void dispose() {
_scroll.removeListener(_onScroll);
_scroll.dispose();
_scrolling.dispose();
super.dispose();
}
Future<void> _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 = <double>[];
final offsets = <double>[];
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<ScrollNotification>(
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,
),
),
);
}
}
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import '../../core/utils/link_opener.dart'; import '../../core/utils/link_opener.dart';
import '../../core/utils/text_format.dart'; import '../../core/utils/text_format.dart';
import 'link_text.dart'; import 'link_text.dart';
import 'lottie_image.dart';
class FormattedMessageText extends StatefulWidget { class FormattedMessageText extends StatefulWidget {
final String text; final String text;
@@ -122,6 +123,36 @@ class _FormattedMessageTextState extends State<FormattedMessageText> {
quoteColor: quoteColor, quoteColor: quoteColor,
); );
final content = widget.text.substring(segment.start, segment.end); 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) { if (segment.url != null) {
final url = segment.url!; final url = segment.url!;
final recognizer = TapGestureRecognizer() final recognizer = TapGestureRecognizer()
+91 -11
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
@@ -68,6 +69,8 @@ class LottiePlayer extends StatefulWidget {
final String? fallbackUrl; final String? fallbackUrl;
final double? size; final double? size;
final int? memCacheWidth; final int? memCacheWidth;
final bool shimmer;
final bool eager;
const LottiePlayer({ const LottiePlayer({
super.key, super.key,
@@ -75,6 +78,8 @@ class LottiePlayer extends StatefulWidget {
this.fallbackUrl, this.fallbackUrl,
this.size, this.size,
this.memCacheWidth, this.memCacheWidth,
this.shimmer = true,
this.eager = false,
}); });
@override @override
@@ -95,6 +100,9 @@ class _LottiePlayerState extends State<LottiePlayer>
int? _px; int? _px;
bool _started = false; bool _started = false;
bool _showedFrames = false; bool _showedFrames = false;
Timer? _deferTimer;
static const Duration _maxLoadDefer = Duration(milliseconds: 700);
double _speed = 1.0; double _speed = 1.0;
double _targetSpeed = 1.0; double _targetSpeed = 1.0;
@@ -103,7 +111,8 @@ class _LottiePlayerState extends State<LottiePlayer>
bool get _isScrolling => _scrollState?.value ?? false; bool get _isScrolling => _scrollState?.value ?? false;
bool get _canLoad => bool get _canLoad =>
!_isScrolling && !LottieLoadGovernor.instance.throttled.value; !_isScrolling &&
(widget.eager || !LottieLoadGovernor.instance.throttled.value);
@override @override
void initState() { void initState() {
@@ -130,6 +139,8 @@ class _LottiePlayerState extends State<LottiePlayer>
if (oldWidget.lottieUrl != widget.lottieUrl) { if (oldWidget.lottieUrl != widget.lottieUrl) {
_ticker.stop(); _ticker.stop();
_releaseClip(); _releaseClip();
_deferTimer?.cancel();
_deferTimer = null;
_started = false; _started = false;
_showedFrames = false; _showedFrames = false;
_playheadMs = 0.0; _playheadMs = 0.0;
@@ -141,6 +152,7 @@ class _LottiePlayerState extends State<LottiePlayer>
@override @override
void dispose() { void dispose() {
_deferTimer?.cancel();
LottieLoadGovernor.instance.throttled.removeListener(_onGateChanged); LottieLoadGovernor.instance.throttled.removeListener(_onGateChanged);
_scrollState?.removeListener(_onGateChanged); _scrollState?.removeListener(_onGateChanged);
_ticker.dispose(); _ticker.dispose();
@@ -217,13 +229,25 @@ class _LottiePlayerState extends State<LottiePlayer>
final dpr = MediaQuery.devicePixelRatioOf(context); final dpr = MediaQuery.devicePixelRatioOf(context);
final raw = (box * dpr.clamp(1.0, 2.0)).clamp(96.0, 384.0); final raw = (box * dpr.clamp(1.0, 2.0)).clamp(96.0, 384.0);
_px = (raw / 32).ceil() * 32; _px = (raw / 32).ceil() * 32;
if (_started || !_canLoad) return; if (_started) return;
_startLoad(); 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() { void _startLoad() {
final px = _px; final px = _px;
if (_started || px == null) return; if (_started || px == null) return;
_deferTimer?.cancel();
_deferTimer = null;
_started = true; _started = true;
RlottieEngine.instance.acquire(widget.lottieUrl, px).then((clip) { RlottieEngine.instance.acquire(widget.lottieUrl, px).then((clip) {
if (clip == null) return; if (clip == null) return;
@@ -281,8 +305,11 @@ class _LottiePlayerState extends State<LottiePlayer>
Widget _staticFallback(double box) { Widget _staticFallback(double box) {
final url = widget.fallbackUrl ?? ''; final url = widget.fallbackUrl ?? '';
final blank = SizedBox(width: box, height: box); if (url.isEmpty) {
if (url.isEmpty) return blank; return widget.shimmer
? LottieShimmer(size: box)
: SizedBox(width: box, height: box);
}
return CachedNetworkImage( return CachedNetworkImage(
imageUrl: url, imageUrl: url,
width: box, width: box,
@@ -290,8 +317,8 @@ class _LottiePlayerState extends State<LottiePlayer>
fit: BoxFit.contain, fit: BoxFit.contain,
memCacheWidth: widget.memCacheWidth, memCacheWidth: widget.memCacheWidth,
fadeInDuration: const Duration(milliseconds: 120), fadeInDuration: const Duration(milliseconds: 120),
placeholder: (_, _) => blank, placeholder: (_, _) => LottieShimmer(size: box),
errorWidget: (_, _, _) => blank, errorWidget: (_, _, _) => SizedBox(width: box, height: box),
); );
} }
} }
@@ -301,6 +328,8 @@ class LottieImage extends StatelessWidget {
final String? lottieUrl; final String? lottieUrl;
final double? size; final double? size;
final int? memCacheWidth; final int? memCacheWidth;
final bool shimmer;
final bool eager;
const LottieImage({ const LottieImage({
super.key, super.key,
@@ -308,6 +337,8 @@ class LottieImage extends StatelessWidget {
this.lottieUrl, this.lottieUrl,
this.size, this.size,
this.memCacheWidth, this.memCacheWidth,
this.shimmer = true,
this.eager = false,
}); });
@override @override
@@ -318,6 +349,8 @@ class LottieImage extends StatelessWidget {
fallbackUrl: url, fallbackUrl: url,
size: size, size: size,
memCacheWidth: memCacheWidth, memCacheWidth: memCacheWidth,
shimmer: shimmer,
eager: eager,
); );
} }
return _static(); return _static();
@@ -325,8 +358,7 @@ class LottieImage extends StatelessWidget {
Widget _static() { Widget _static() {
final src = url ?? ''; final src = url ?? '';
final blank = SizedBox(width: size, height: size); if (src.isEmpty) return SizedBox(width: size, height: size);
if (src.isEmpty) return blank;
return CachedNetworkImage( return CachedNetworkImage(
imageUrl: src, imageUrl: src,
width: size, width: size,
@@ -334,8 +366,56 @@ class LottieImage extends StatelessWidget {
fit: BoxFit.contain, fit: BoxFit.contain,
memCacheWidth: memCacheWidth, memCacheWidth: memCacheWidth,
fadeInDuration: const Duration(milliseconds: 120), fadeInDuration: const Duration(milliseconds: 120),
placeholder: (_, _) => blank, placeholder: (_, _) => LottieShimmer(size: size),
errorWidget: (_, _, _) => blank, errorWidget: (_, _, _) => SizedBox(width: size, height: size),
);
}
}
class LottieShimmer extends StatefulWidget {
final double? size;
const LottieShimmer({super.key, this.size});
@override
State<LottieShimmer> createState() => _LottieShimmerState();
}
class _LottieShimmerState extends State<LottieShimmer>
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(),
),
),
),
); );
} }
} }
+101 -3
View File
@@ -13,6 +13,7 @@ import '../../core/config/app_bubble_behavior.dart';
import '../../core/config/app_bubble_shape.dart'; import '../../core/config/app_bubble_shape.dart';
import '../../core/utils/bubble_radius.dart'; import '../../core/utils/bubble_radius.dart';
import '../../core/utils/link_opener.dart'; import '../../core/utils/link_opener.dart';
import '../../core/utils/text_format.dart';
import '../../core/utils/webview_support.dart'; import '../../core/utils/webview_support.dart';
import '../../core/config/app_link_preview.dart'; import '../../core/config/app_link_preview.dart';
import 'custom_notification.dart'; import 'custom_notification.dart';
@@ -31,6 +32,7 @@ import 'attachment/bubbles/photo_bubble.dart';
import 'attachment/bubbles/video_bubble.dart'; import 'attachment/bubbles/video_bubble.dart';
import 'attachment/bubbles/file_bubble.dart'; import 'attachment/bubbles/file_bubble.dart';
import 'attachment/bubbles/forwarded_bubble.dart'; import 'attachment/bubbles/forwarded_bubble.dart';
import 'lottie_image.dart';
final Expando<MessageType> _contentTypeCache = Expando<MessageType>(); final Expando<MessageType> _contentTypeCache = Expando<MessageType>();
@@ -211,6 +213,17 @@ class MessageBubble extends StatelessWidget {
return a.first is StickerAttachment; return a.first is StickerAttachment;
} }
static const int _jumboAnimojiLimit = 4;
List<String>? get _jumboAnimojiUrls {
if (message.attachments?.isNotEmpty ?? false) return null;
return animojiOnlyLottieUrls(
message.text,
message.formatRanges,
limit: _jumboAnimojiLimit,
);
}
MessageType get _contentType { MessageType get _contentType {
if (_hasShareAttachment) return _computeContentType(); if (_hasShareAttachment) return _computeContentType();
return _contentTypeCache[message] ??= _computeContentType(); return _contentTypeCache[message] ??= _computeContentType();
@@ -454,7 +467,10 @@ class MessageBubble extends StatelessWidget {
final topMargin = _topMarginFor(contentType, shape); final topMargin = _topMarginFor(contentType, shape);
final bottomMargin = _bottomMarginFor(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 showAvatarSlot = !isMe;
final showAvatar = final showAvatar =
@@ -469,7 +485,7 @@ class MessageBubble extends StatelessWidget {
final maxBubbleWidth = math.min(MediaQuery.sizeOf(context).width * 0.75, 560.0); final maxBubbleWidth = math.min(MediaQuery.sizeOf(context).width * 0.75, 560.0);
final keyboard = _inlineKeyboard; final keyboard = _inlineKeyboard;
final isVideoNote = _isVideoNote; final isVideoNote = _isVideoNote;
final noBubbleBackground = isVideoNote || _isSticker; final noBubbleBackground = isVideoNote || _isSticker || jumboAnimoji != null;
final bubbleColor = noBubbleBackground final bubbleColor = noBubbleBackground
? Colors.transparent ? Colors.transparent
: (isMe ? cs.primaryContainer : cs.surfaceContainerHighest); : (isMe ? cs.primaryContainer : cs.surfaceContainerHighest);
@@ -508,7 +524,7 @@ class MessageBubble extends StatelessWidget {
Widget withReply(Widget content) { Widget withReply(Widget content) {
if (reply == null) return content; if (reply == null) return content;
final quote = _buildReplyQuote(context, cs, textColor, reply); final quote = _buildReplyQuote(context, cs, textColor, reply);
if (contentType != MessageType.text) { if (contentType != MessageType.text || jumboAnimoji != null) {
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -810,6 +826,8 @@ class MessageBubble extends StatelessWidget {
} }
Widget _buildContent(BubbleContext ctx) { Widget _buildContent(BubbleContext ctx) {
final jumbo = _jumboAnimojiUrls;
if (jumbo != null) return _buildJumboAnimojiContent(ctx, jumbo);
switch (ctx.contentType) { switch (ctx.contentType) {
case MessageType.control: case MessageType.control:
return _buildControlContent(ctx.cs); return _buildControlContent(ctx.cs);
@@ -822,6 +840,86 @@ class MessageBubble extends StatelessWidget {
} }
} }
Widget _buildJumboAnimojiContent(BubbleContext ctx, List<String> 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) { Widget _buildReactionsBar(ColorScheme cs) {
final info = message.payload?['reactionInfo']; final info = message.payload?['reactionInfo'];
return _buildReactionsBarFor(cs, info is Map ? info : null); return _buildReactionsBarFor(cs, info is Map ? info : null);
+167 -13
View File
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/utils/text_format.dart'; import '../../core/utils/text_format.dart';
import '../../models/animoji.dart';
import 'lottie_image.dart';
const List<TextFormat> composerFormats = [ const List<TextFormat> composerFormats = [
TextFormat.strong, TextFormat.strong,
@@ -16,11 +18,114 @@ class _Interval {
_Interval(this.start, this.end); _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 { class RichMessageController extends TextEditingController {
static const String _animojiPlaceholder = '';
final Map<TextFormat, List<_Interval>> _intervals = {}; final Map<TextFormat, List<_Interval>> _intervals = {};
final List<_AnimojiEntity> _animoji = [];
int _entitySeq = 0;
RichMessageController({super.text}); 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<Map<String, dynamic>> 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 = <Map<String, dynamic>>[];
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 @override
set value(TextEditingValue newValue) { set value(TextEditingValue newValue) {
final oldText = value.text; final oldText = value.text;
@@ -95,7 +200,7 @@ class RichMessageController extends TextEditingController {
} }
void _remap(String oldText, String newText) { void _remap(String oldText, String newText) {
if (_intervals.isEmpty) return; if (_intervals.isEmpty && _animoji.isEmpty) return;
final oldLen = oldText.length; final oldLen = oldText.length;
final newLen = newText.length; final newLen = newText.length;
@@ -126,6 +231,15 @@ class RichMessageController extends TextEditingController {
return changeStart; 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 = <TextFormat>[]; final empty = <TextFormat>[];
_intervals.forEach((format, list) { _intervals.forEach((format, list) {
for (final interval in list) { for (final interval in list) {
@@ -204,26 +318,66 @@ class RichMessageController extends TextEditingController {
}) { }) {
final baseStyle = style ?? const TextStyle(); final baseStyle = style ?? const TextStyle();
final content = text; final content = text;
if (!hasFormatting || content.isEmpty) { if ((!hasFormatting && _animoji.isEmpty) || content.isEmpty) {
return TextSpan(style: baseStyle, text: content); return TextSpan(style: baseStyle, text: content);
} }
final ranges = _toFormatRanges(); final ranges = _toFormatRanges();
final baseColor = baseStyle.color; final baseColor = baseStyle.color;
final quoteColor = baseColor?.withValues(alpha: 0.85); final quoteColor = baseColor?.withValues(alpha: 0.85);
final segments = segmentizeFormats(content, ranges); final segments = segmentizeFormats(content, ranges);
final spans = <InlineSpan>[ final entityByOffset = {for (final e in _animoji) e.offset: e};
for (final segment in segments) final box = (baseStyle.fontSize ?? 16) * 1.4;
TextSpan(
text: content.substring(segment.start, segment.end), final spans = <InlineSpan>[];
style: applyTextFormats( for (final segment in segments) {
baseStyle, final segStyle = applyTextFormats(
segment.formats, baseStyle,
quoteColor: quoteColor, 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); 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,
),
),
);
}
} }
@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
class SegmentedPillToggle extends StatelessWidget {
final List<String> labels;
final int selected;
final ValueChanged<int> 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]),
),
),
),
);
}),
),
],
),
);
}
}
+125 -45
View File
@@ -1,14 +1,19 @@
import 'dart:async';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../core/utils/debouncer.dart'; import '../../core/utils/debouncer.dart';
import '../../core/utils/emoji_keyword_index.dart'; import '../../core/utils/emoji_keyword_index.dart';
import '../../main.dart' show stickersModule; import '../../main.dart' show stickersModule;
import '../../models/animoji.dart';
import '../../models/sticker.dart'; import '../../models/sticker.dart';
import 'emoji_panel.dart';
import 'segmented_pill_toggle.dart';
import 'small_spinner.dart'; import 'small_spinner.dart';
import 'lottie_image.dart'; import 'lottie_image.dart';
import 'sticker_peek.dart'; import 'sticker_peek.dart';
@@ -43,11 +48,13 @@ class _Section {
class StickerPanel extends StatefulWidget { class StickerPanel extends StatefulWidget {
final double height; final double height;
final void Function(StickerItem sticker) onStickerTap; final void Function(StickerItem sticker) onStickerTap;
final void Function(Animoji animoji)? onEmojiTap;
const StickerPanel({ const StickerPanel({
super.key, super.key,
required this.height, required this.height,
required this.onStickerTap, required this.onStickerTap,
this.onEmojiTap,
}); });
@override @override
@@ -59,6 +66,12 @@ class _StickerPanelState extends State<StickerPanel>
static const double _tabBarHeight = 52; static const double _tabBarHeight = 52;
static const double _headerHeight = 34; static const double _headerHeight = 34;
static const double _searchFieldHeight = 50; 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 ScrollController _scroll = ScrollController();
final ValueNotifier<bool> _scrolling = ValueNotifier(false); final ValueNotifier<bool> _scrolling = ValueNotifier(false);
@@ -70,6 +83,8 @@ class _StickerPanelState extends State<StickerPanel>
late final AnimationController _shimmer; late final AnimationController _shimmer;
bool _loading = true; bool _loading = true;
Object? _error; Object? _error;
late int _mode;
bool _modeUserChosen = false;
int _selectedTab = 0; int _selectedTab = 0;
List<_Section> _sections = const []; List<_Section> _sections = const [];
List<double> _heights = const []; List<double> _heights = const [];
@@ -81,6 +96,8 @@ class _StickerPanelState extends State<StickerPanel>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_mode = widget.onEmojiTap == null ? _modeStickers : _persistedMode;
if (!_persistedModeLoaded) unawaited(_loadPersistedMode());
_shimmer = AnimationController( _shimmer = AnimationController(
vsync: this, vsync: this,
duration: const Duration(milliseconds: 900), duration: const Duration(milliseconds: 900),
@@ -244,51 +261,15 @@ class _StickerPanelState extends State<StickerPanel>
), ),
), ),
), ),
child: _loading child: Column(
? Center(child: SmallSpinner()) children: [
: _error != null || _sections.isEmpty Expanded(
? Center( child: _mode == _modeEmoji && widget.onEmojiTap != null
child: Text( ? EmojiPanel(onEmojiTap: widget.onEmojiTap!)
_error != null : _buildStickerBody(cs),
? 'Не удалось загрузить стикеры' ),
: 'Нет стикеров', if (widget.onEmojiTap != null) _buildToggleBar(cs),
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 = <double>[];
final offsets = <double>[];
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)),
],
);
},
),
), ),
), ),
), ),
@@ -296,6 +277,105 @@ class _StickerPanelState extends State<StickerPanel>
); );
} }
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 = <double>[];
final offsets = <double>[];
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<void> _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<void> _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) { Widget _buildTabBar(ColorScheme cs) {
return SizedBox( return SizedBox(
height: _tabBarHeight, height: _tabBarHeight,
+7 -1
View File
@@ -2,6 +2,12 @@
cmake_minimum_required(VERSION 3.13) cmake_minimum_required(VERSION 3.13)
project(runner LANGUAGES CXX) 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 name of the executable created for the application. Change this to change
# the on-disk name of your application. # the on-disk name of your application.
set(BINARY_NAME "Komet") 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 # By default, "installing" just makes a relocatable bundle in the build
# directory. # directory.
set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") 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) set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
endif() endif()
+49
View File
@@ -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'],
);
});
}
@@ -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);
});
}