feat: добавил отображение + отправку animoji. Добавил матовое стекло для панели ответа при кастомизационном выборе 'прозрачность'
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
if (_canLoad) {
|
||||||
_startLoad();
|
_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(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
final segStyle = applyTextFormats(
|
||||||
baseStyle,
|
baseStyle,
|
||||||
segment.formats,
|
segment.formats,
|
||||||
quoteColor: quoteColor,
|
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]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,18 +261,33 @@ 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
|
||||||
|
? EmojiPanel(onEmojiTap: widget.onEmojiTap!)
|
||||||
|
: _buildStickerBody(cs),
|
||||||
|
),
|
||||||
|
if (widget.onEmojiTap != null) _buildToggleBar(cs),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStickerBody(ColorScheme cs) {
|
||||||
|
if (_loading) return Center(child: SmallSpinner());
|
||||||
|
if (_error != null || _sections.isEmpty) {
|
||||||
|
return Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
_error != null
|
_error != null ? 'Не удалось загрузить стикеры' : 'Нет стикеров',
|
||||||
? 'Не удалось загрузить стикеры'
|
|
||||||
: 'Нет стикеров',
|
|
||||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
: ScrollConfiguration(
|
}
|
||||||
|
return ScrollConfiguration(
|
||||||
behavior: const _DragScrollBehavior(),
|
behavior: const _DragScrollBehavior(),
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
@@ -289,10 +321,58 @@ class _StickerPanelState extends State<StickerPanel>
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user