feat(chat): 表情/贴纸/附件面板全量实现(#51~#56)

## 贴纸(#51)
- `StickerMessageBubble`:typ=5,CDN 图片,120×120pt max,无气泡背景,圆角 8pt
- `_buildBubble()` switch 新增 `case 5:`,isMedia 添加 typ=5

## 附件面板(#52~#55)
- `AttachmentPanelSheet`:6 格 BottomSheet(拍照/相册/视频/文件/录音/红包)
- 返回 `AttachmentOption` enum,由 `_ChatDetailPageState` 分发后续行为
- 拍照(#53):`image_picker.pickImage(camera)` → `SendImageUseCase`
- 视频(#54):`image_picker.pickVideo()` → `SendVideoUseCase`(新建)
- 文件/录音/红包:SnackBar 占位「暂未支持」
- `SendVideoUseCase`:上传 + `jsonEncode({url,thumb,size})` → typ=4

## 表情面板(#56)
- `EmojiPanel`:4 分类(常用/人物/自然/物件),每类 ~64 个 Unicode emoji
- 点击插入到 `TextEditingController` 当前光标位置(支持多码点 emoji)
- ⌫ 退格按钮按 rune 删除(正确处理多码点 emoji)
- `_InputBar` 新增表情按钮(😊)和附件按钮,原 `onAttach` 拆分为 `onAttachPanel` + `onEmoji`

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
pp-bot
2026-03-24 21:06:24 +09:00
parent 8d5059add1
commit bb9f1aa956
6 changed files with 702 additions and 8 deletions

View File

@@ -8,6 +8,7 @@ import 'package:im_app/features/chat/di/message_provider.dart';
import 'package:im_app/features/chat/usecases/fetch_history_use_case.dart';
import 'package:im_app/features/chat/usecases/send_image_usecase.dart';
import 'package:im_app/features/chat/usecases/send_message_use_case.dart';
import 'package:im_app/features/chat/usecases/send_video_usecase.dart';
/// ## DI 装配Chat 服务层
///
@@ -75,3 +76,13 @@ final sendImageUseCaseProvider = Provider<SendImageUseCase>((ref) {
sendMessage: ref.read(sendMessageUseCaseProvider),
);
});
// ── SendVideoUseCase ──────────────────────────────────────────────────────────
/// 视频上传并发送消息用例 Provider#54
final sendVideoUseCaseProvider = Provider<SendVideoUseCase>((ref) {
return SendVideoUseCase(
apiClient: ref.read(networkSdkApiProvider),
sendMessage: ref.read(sendMessageUseCaseProvider),
);
});

View File

@@ -0,0 +1,79 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:networks_sdk/networks_sdk.dart';
import 'package:im_app/data/remote/upload_file_request.dart';
import 'package:im_app/features/chat/usecases/send_message_use_case.dart';
/// 视频上传并发送消息用例typ=4Gitea issue #54
///
/// 对应 iOS PhotosPicker 单视频选取 + UploadService + sendMessage(typ=4)
///
/// ## 流程
/// 1. 读取文件大小
/// 2. [UploadFileRequest] 上传视频文件到 CDN
/// 3. `jsonEncode({"url":url,"thumb":"","size":N})` → [SendMessageUseCase] typ=4
///
/// ## 说明
/// - 缩略图thumb当前为空字符串生成视频首帧需要 `video_thumbnail` 等额外包
/// - [VideoMessageBubble] 已有,无需改动
class SendVideoUseCase {
final NetworksSdkApi _apiClient;
final SendMessageUseCase _sendMessage;
SendVideoUseCase({
required NetworksSdkApi apiClient,
required SendMessageUseCase sendMessage,
}) : _apiClient = apiClient,
_sendMessage = sendMessage;
/// 上传并发送视频消息
///
/// [filePath]:本地视频文件路径(由 `image_picker.pickVideo()` 返回的 XFile.path
/// [chatId]:目标会话 ID
Future<void> execute({
required String filePath,
required int chatId,
int chatType = 1,
}) async {
// 1. 文件大小
final file = File(filePath);
int size = 0;
try {
size = await file.length();
} catch (e) {
debugPrint('[SendVideoUseCase] 获取文件大小失败: $e');
}
// 2. 上传
String uploadedUrl = '';
try {
final result = await _apiClient.executeRequest(
UploadFileRequest(filePath: filePath),
);
uploadedUrl = result?.url ?? '';
} catch (e) {
debugPrint('[SendVideoUseCase] upload error: $e');
rethrow;
}
if (uploadedUrl.isEmpty) {
throw Exception('[SendVideoUseCase] upload returned empty url');
}
// 3. 发送 typ=4 消息
final content = jsonEncode({
'url': uploadedUrl,
'thumb': '', // 视频缩略图(待后续接入 video_thumbnail
'size': size,
});
await _sendMessage.execute(
chatId: chatId,
content: content,
typ: 4,
);
}
}

View File

@@ -9,8 +9,14 @@ import 'package:im_app/features/chat/view/widgets/audio_message_bubble.dart';
import 'package:im_app/features/chat/view/widgets/file_message_bubble.dart';
import 'package:im_app/features/chat/view/widgets/image_grid_bubble.dart';
import 'package:im_app/features/chat/view/widgets/image_message_bubble.dart';
import 'package:image_picker/image_picker.dart';
import 'package:im_app/features/chat/di/chat_service_providers.dart';
import 'package:im_app/features/chat/view/widgets/attachment_panel_sheet.dart';
import 'package:im_app/features/chat/view/widgets/emoji_panel.dart';
import 'package:im_app/features/chat/view/widgets/image_picker_sheet.dart';
import 'package:im_app/features/chat/view/widgets/red_envelope_bubble.dart';
import 'package:im_app/features/chat/view/widgets/sticker_message_bubble.dart';
import 'package:im_app/features/chat/view/widgets/video_message_bubble.dart';
/// 聊天详情页(#28 / #35 / #37
@@ -94,6 +100,113 @@ class _ChatDetailPageState extends ConsumerState<ChatDetailPage> {
ImagePickerSheet.show(context, ref, chatId: _chatId);
}
// ── 附件面板(#52~#55 ──────────────────────────────────────────────────────
Future<void> _showAttachmentPanel() async {
final opt = await AttachmentPanelSheet.show(context);
if (opt == null || !mounted) return;
switch (opt) {
case AttachmentOption.camera:
await _pickFromCamera();
case AttachmentOption.gallery:
if (mounted) _showImagePicker();
case AttachmentOption.video:
await _pickVideo();
case AttachmentOption.file:
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('文件发送暂未支持,即将上线')),
);
}
case AttachmentOption.voice:
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('录音发送暂未支持,即将上线')),
);
}
case AttachmentOption.redEnvelope:
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('红包发送暂未支持,即将上线')),
);
}
}
}
/// 拍照后发送(#53调用摄像头复用 SendImageUseCase
Future<void> _pickFromCamera() async {
final picker = ImagePicker();
final file = await picker.pickImage(
source: ImageSource.camera,
maxWidth: 1920,
maxHeight: 1920,
imageQuality: 85,
);
if (file == null || !mounted) return;
try {
await ref
.read(sendImageUseCaseProvider)
.execute(filePath: file.path, chatId: _chatId);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('发送失败: $e')));
}
}
}
/// 视频选取后发送(#54image_picker.pickVideo → SendVideoUseCase
Future<void> _pickVideo() async {
final picker = ImagePicker();
final file = await picker.pickVideo(source: ImageSource.gallery);
if (file == null || !mounted) return;
try {
await ref
.read(sendVideoUseCaseProvider)
.execute(filePath: file.path, chatId: _chatId);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('视频发送失败: $e')));
}
}
}
// ── 表情面板(#56 ──────────────────────────────────────────────────────────
void _showEmojiPanel() {
FocusScope.of(context).unfocus();
EmojiPanel.show(
context,
onEmojiSelected: _insertEmoji,
onBackspace: _deleteLastChar,
);
}
void _insertEmoji(String emoji) {
final ctrl = _inputCtrl;
final text = ctrl.text;
final sel = ctrl.selection;
final pos = sel.isValid ? sel.baseOffset : text.length;
final safePos = pos.clamp(0, text.length);
final newText =
text.substring(0, safePos) + emoji + text.substring(safePos);
ctrl.text = newText;
ctrl.selection =
TextSelection.collapsed(offset: safePos + emoji.length);
}
void _deleteLastChar() {
final ctrl = _inputCtrl;
final text = ctrl.text;
if (text.isEmpty) return;
// Remove one rune (handles multi-codepoint emoji correctly)
final runes = text.runes.toList();
final newText = String.fromCharCodes(runes.sublist(0, runes.length - 1));
ctrl.text = newText;
ctrl.selection = TextSelection.collapsed(offset: newText.length);
}
@override
Widget build(BuildContext context) {
final state = ref.watch(chatDetailViewModelProvider(_chatId));
@@ -162,7 +275,8 @@ class _ChatDetailPageState extends ConsumerState<ChatDetailPage> {
controller: _inputCtrl,
isSending: state.isSending,
onSend: _send,
onAttach: _showImagePicker,
onAttachPanel: _showAttachmentPanel,
onEmoji: _showEmojiPanel,
),
],
),
@@ -318,8 +432,8 @@ class _MessageBubble extends StatelessWidget {
final content = message.content ?? '';
final typ = message.typ ?? 1;
final isMedia = typ == 2 || typ == 3 || typ == 4 || typ == 6 ||
typ == 8 || typ == 24;
final isMedia = typ == 2 || typ == 3 || typ == 4 || typ == 5 ||
typ == 6 || typ == 8 || typ == 24;
final bubble = _buildBubble(context, cs, content, typ);
@@ -350,6 +464,8 @@ class _MessageBubble extends StatelessWidget {
switch (typ) {
case 2:
return ImageMessageBubble(rawContent: content);
case 5:
return StickerMessageBubble(rawContent: content);
case 3:
return AudioMessageBubble(rawContent: content);
case 4:
@@ -413,13 +529,17 @@ class _InputBar extends StatelessWidget {
required this.controller,
required this.isSending,
required this.onSend,
required this.onAttach,
required this.onAttachPanel,
required this.onEmoji,
});
final TextEditingController controller;
final bool isSending;
final VoidCallback onSend;
final VoidCallback onAttach;
/// 附件面板(#52相机/相册/视频/文件/录音/红包
final VoidCallback onAttachPanel;
/// 表情面板(#56
final VoidCallback onEmoji;
@override
Widget build(BuildContext context) {
@@ -428,11 +548,17 @@ class _InputBar extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
children: [
// 附件按钮(#33
// 表情按钮(#56
IconButton(
onPressed: onAttach,
onPressed: onEmoji,
icon: const Icon(Icons.emoji_emotions_outlined),
tooltip: '表情',
),
// 附件面板按钮(#52
IconButton(
onPressed: onAttachPanel,
icon: const Icon(Icons.add_circle_outline_rounded),
tooltip: '发送图片',
tooltip: '附件',
),
Expanded(
child: TextField(

View File

@@ -0,0 +1,192 @@
import 'package:flutter/material.dart';
/// 附件选择面板选项
///
/// 每个选项对应一个操作类型,由调用方在 [AttachmentPanelSheet.show] 的
/// Future 返回值中决定后续行为。
enum AttachmentOption {
camera,
gallery,
video,
file,
voice,
redEnvelope,
}
/// 附件选择面板Gitea issue #52
///
/// 对应 iOS ChatView.swift AttachmentPickerSheet 6 格网格。
///
/// ## 选项
///
/// | 图标 | 标题 | 颜色 | 动作 |
/// |------|------|------|------|
/// | camera | 拍照 | #5667FF | [AttachmentOption.camera] |
/// | photo_library | 相册 | #0BB8A9 | [AttachmentOption.gallery] |
/// | videocam | 视频 | #FF5FA2 | [AttachmentOption.video] |
/// | insert_drive_file | 文件 | #FF8B5E | [AttachmentOption.file] |
/// | mic | 录音 | #8A5CF6 | [AttachmentOption.voice] |
/// | redpacket / gift | 红包 | #E8600A | [AttachmentOption.redEnvelope] |
///
/// ## 使用
///
/// ```dart
/// final opt = await AttachmentPanelSheet.show(context);
/// if (opt == null) return;
/// switch (opt) {
/// case AttachmentOption.camera: _pickFromCamera(); break;
/// case AttachmentOption.gallery: _showImagePicker(); break;
/// ...
/// }
/// ```
class AttachmentPanelSheet extends StatelessWidget {
const AttachmentPanelSheet({super.key});
static Future<AttachmentOption?> show(BuildContext context) {
return showModalBottomSheet<AttachmentOption>(
context: context,
backgroundColor: Colors.transparent,
builder: (_) => const AttachmentPanelSheet(),
);
}
static const _items = [
_AttachItem(
option: AttachmentOption.camera,
icon: Icons.camera_alt_rounded,
label: '拍照',
color: Color(0xFF5667FF),
),
_AttachItem(
option: AttachmentOption.gallery,
icon: Icons.photo_library_rounded,
label: '相册',
color: Color(0xFF0BB8A9),
),
_AttachItem(
option: AttachmentOption.video,
icon: Icons.videocam_rounded,
label: '视频',
color: Color(0xFFFF5FA2),
),
_AttachItem(
option: AttachmentOption.file,
icon: Icons.insert_drive_file_rounded,
label: '文件',
color: Color(0xFFFF8B5E),
),
_AttachItem(
option: AttachmentOption.voice,
icon: Icons.mic_rounded,
label: '录音',
color: Color(0xFF8A5CF6),
),
_AttachItem(
option: AttachmentOption.redEnvelope,
icon: Icons.card_giftcard_rounded,
label: '红包',
color: Color(0xFFE8600A),
),
];
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 拖拽指示条
Center(
child: Container(
width: 36,
height: 4,
margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.outline
.withOpacity(0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
// 2 行 × 3 列网格
GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 3,
childAspectRatio: 1.1,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
children: _items
.map(
(item) => _AttachButton(
item: item,
onTap: () => Navigator.of(context).pop(item.option),
),
)
.toList(),
),
],
),
);
}
}
// ── 数据模型 ──────────────────────────────────────────────────────────────────
class _AttachItem {
const _AttachItem({
required this.option,
required this.icon,
required this.label,
required this.color,
});
final AttachmentOption option;
final IconData icon;
final String label;
final Color color;
}
// ── 单项按钮 ──────────────────────────────────────────────────────────────────
class _AttachButton extends StatelessWidget {
const _AttachButton({required this.item, required this.onTap});
final _AttachItem item;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: item.color.withOpacity(0.12),
borderRadius: BorderRadius.circular(16),
),
child: Icon(item.icon, color: item.color, size: 28),
),
const SizedBox(height: 6),
Text(
item.label,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
}
}

View File

@@ -0,0 +1,201 @@
import 'package:flutter/material.dart';
/// 表情快捷面板Gitea issue #56
///
/// 点击表情后通过 [onEmojiSelected] 回调插入到输入框。
/// [onBackspace] 用于删除输入框最后一个字符(含 emoji
///
/// 使用方式:
/// ```dart
/// EmojiPanel.show(context,
/// onEmojiSelected: (e) => _insertEmoji(e),
/// onBackspace: _deleteLastChar,
/// );
/// ```
///
/// ## 表情分类
///
/// - 常用:笑脸 / 情绪
/// - 人物:手势 / 活动
/// - 自然:动物 / 植物
/// - 物件:食物 / 符号
class EmojiPanel extends StatefulWidget {
const EmojiPanel({
super.key,
required this.onEmojiSelected,
required this.onBackspace,
});
final void Function(String emoji) onEmojiSelected;
final VoidCallback onBackspace;
static Future<void> show(
BuildContext context, {
required void Function(String emoji) onEmojiSelected,
required VoidCallback onBackspace,
}) {
return showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (_) => EmojiPanel(
onEmojiSelected: onEmojiSelected,
onBackspace: onBackspace,
),
);
}
// ── 表情数据 ─────────────────────────────────────────────────────────────────
static const List<String> _common = [
'😀','😃','😄','😁','😆','😅','😂','🤣','😊','😇','🙂','🙃',
'😉','😌','😍','🥰','😘','😗','😋','😛','😝','😜','🤪','😎',
'🥳','😏','😒','😞','😔','😟','😕','🙁','😣','😖','😫','😩',
'🥺','😢','😭','😤','😠','😡','🤬','😳','🥵','🥶','😱','😨',
'😰','😥','😓','🤗','🤔','🤭','🤫','🤥','😶','😐','😑','😬',
'🙄','😯','😮','😲','🥱','😴','🤐','🥴','🤢','🤮','🤧','😷',
'🤒','🤕','🥸','😵','🤠','🥹','😈','👿','👹','👺','🤡','👻',
'💀','☠️','👾','🤖','😺','😸','😹','😻','😼','😽','🙀','😿',
];
static const List<String> _people = [
'👋','🤚','🖐️','','🖖','👌','🤌','🤏','✌️','🤞','🤟','🤘',
'👈','👉','👆','🖕','👇','☝️','👍','👎','','👊','🤛','🤜',
'👏','🙌','🫶','🤝','🙏','✍️','💅','🤳','💪','🦾','🦿','🦵',
'🧑','👦','👧','🧒','👱','👴','👵','🧓','👮','👷','💂','🕵️',
'🧑‍⚕️','🧑‍🏫','🧑‍🍳','🧑‍🔧','🧑‍💻','🧑‍🎤','🧑‍🎨','🧑‍✈️','🧑‍🚀','🧑‍🏭',
'👫','👬','👭','💑','💏','👨‍👩‍👦','👨‍👩‍👧','🧑‍🤝‍🧑',
'🚶','🧍','🏃','💃','🕺','🤸','⛹️','🏋️','🤼','🤺',
];
static const List<String> _nature = [
'🐶','🐱','🐭','🐹','🐰','🦊','🐻','🐼','🐻‍❄️','🐨','🐯','🦁',
'🐮','🐷','🐸','🐵','🙈','🙉','🙊','🐔','🐧','🐦','🦅','🦆',
'🦉','🦇','🐝','🪱','🐛','🦋','🐌','🐞','🐜','🪲','🦟','🦗',
'🌸','🌺','🌻','🌹','🌷','💐','🍀','🌿','🌱','🌲','🌳','🌴',
'🌵','🎋','🎍','☘️','🍁','🍂','🍃','🪴','','🌈','☀️','🌤️',
'','🌥️','☁️','🌦️','🌧️','⛈️','🌩️','🌨️','❄️','☃️','🔥','🌊',
];
static const List<String> _objects = [
'🍎','🍊','🍋','🍇','🍓','🫐','🍒','🍑','🥭','🍍','🥥','🍌',
'🍕','🍔','🍟','🌭','🥪','🌮','🌯','🫔','🥙','🧆','🍜','🍝',
'','🍵','🧃','🥤','🧋','🍺','🍻','🥂','🍷','🍸','🍹','🧉',
'🎉','🎊','🎈','🎁','🎀','🪅','🎆','🎇','','🌟','','💫',
'❤️','🧡','💛','💚','💙','💜','🖤','🤍','🤎','💔','❤️‍🔥','💕',
'💞','💓','💗','💖','💘','💝','💟','❣️','💌','💋','💯','🔥',
'👑','💎','🏆','🥇','🎖️','🏅','🎗️','🎫','🎟️','🎪','🎭','🎨',
];
static const List<List<String>> _tabs = [_common, _people, _nature, _objects];
static const List<String> _tabLabels = ['😀 常用', '🙋 人物', '🌿 自然', '🍕 物件'];
@override
State<EmojiPanel> createState() => _EmojiPanelState();
}
class _EmojiPanelState extends State<EmojiPanel>
with SingleTickerProviderStateMixin {
late final TabController _tabCtrl;
@override
void initState() {
super.initState();
_tabCtrl = TabController(length: EmojiPanel._tabs.length, vsync: this);
}
@override
void dispose() {
_tabCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
height: 280,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
),
child: Column(
children: [
// ── 拖拽指示条 ────────────────────────────────────────────────────────
const SizedBox(height: 8),
Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.outline.withOpacity(0.4),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 4),
// ── Tab 分类 ──────────────────────────────────────────────────────────
TabBar(
controller: _tabCtrl,
tabs: EmojiPanel._tabLabels
.map((l) => Tab(text: l))
.toList(),
labelStyle: const TextStyle(fontSize: 12),
unselectedLabelStyle: const TextStyle(fontSize: 12),
indicatorSize: TabBarIndicatorSize.label,
dividerColor: Colors.transparent,
),
// ── 表情网格 ──────────────────────────────────────────────────────────
Expanded(
child: TabBarView(
controller: _tabCtrl,
children: EmojiPanel._tabs.map((emojis) {
return GridView.builder(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 8,
childAspectRatio: 1,
),
itemCount: emojis.length,
itemBuilder: (_, i) {
return InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => widget.onEmojiSelected(emojis[i]),
child: Center(
child: Text(
emojis[i],
style: const TextStyle(fontSize: 22),
),
),
);
},
);
}).toList(),
),
),
// ── 底部:退格按钮 ────────────────────────────────────────────────────
Padding(
padding: const EdgeInsets.fromLTRB(0, 0, 12, 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
InkWell(
borderRadius: BorderRadius.circular(8),
onTap: widget.onBackspace,
child: const Padding(
padding: EdgeInsets.all(8),
child: Icon(Icons.backspace_outlined, size: 22),
),
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,85 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:im_app/core/services/cdn_url_resolver.dart';
/// 贴纸消息气泡typ = 5
///
/// 对应 Gitea issue #51 / iOS StickerMessageBubble
///
/// ## 数据格式
///
/// rawContent JSON`{ "url": "sticker/xxx.webp", "width": 120, "height": 120 }`
///
/// ## 尺寸规则iOS 对齐)
///
/// - 最大显示 120×120pt等比缩放
/// - **无气泡背景**borderless
/// - 圆角 8pt
///
/// ## 使用
///
/// ```dart
/// StickerMessageBubble(rawContent: message.content ?? '')
/// ```
class StickerMessageBubble extends StatelessWidget {
const StickerMessageBubble({super.key, required this.rawContent});
final String rawContent;
static const double _maxSize = 120.0;
@override
Widget build(BuildContext context) {
final parsed = _parse(rawContent);
final url = parsed['url'] as String? ?? '';
final resolvedUrl = url.isNotEmpty ? CdnUrlResolver.resolve(url) : '';
if (resolvedUrl.isEmpty) {
return _placeholder();
}
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: _maxSize,
height: _maxSize,
child: Image.network(
resolvedUrl,
fit: BoxFit.contain,
loadingBuilder: (_, child, loadingProgress) {
if (loadingProgress == null) return child;
return const Center(
child: SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
},
errorBuilder: (_, __, ___) => _placeholder(),
),
),
);
}
Widget _placeholder() {
return const SizedBox(
width: _maxSize,
height: _maxSize,
child: Center(
child: Icon(Icons.image_not_supported_outlined,
size: 40, color: Colors.grey),
),
);
}
Map<String, dynamic> _parse(String raw) {
try {
return jsonDecode(raw) as Map<String, dynamic>;
} catch (_) {
return {};
}
}
}