diff --git a/apps/im_app/lib/features/chat/di/chat_service_providers.dart b/apps/im_app/lib/features/chat/di/chat_service_providers.dart index e0177c1..b749fa4 100644 --- a/apps/im_app/lib/features/chat/di/chat_service_providers.dart +++ b/apps/im_app/lib/features/chat/di/chat_service_providers.dart @@ -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((ref) { sendMessage: ref.read(sendMessageUseCaseProvider), ); }); + +// ── SendVideoUseCase ────────────────────────────────────────────────────────── + +/// 视频上传并发送消息用例 Provider(#54) +final sendVideoUseCaseProvider = Provider((ref) { + return SendVideoUseCase( + apiClient: ref.read(networkSdkApiProvider), + sendMessage: ref.read(sendMessageUseCaseProvider), + ); +}); diff --git a/apps/im_app/lib/features/chat/usecases/send_video_usecase.dart b/apps/im_app/lib/features/chat/usecases/send_video_usecase.dart new file mode 100644 index 0000000..6469be8 --- /dev/null +++ b/apps/im_app/lib/features/chat/usecases/send_video_usecase.dart @@ -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=4,Gitea 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 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, + ); + } +} diff --git a/apps/im_app/lib/features/chat/view/chat_detail_page.dart b/apps/im_app/lib/features/chat/view/chat_detail_page.dart index c61d0e5..4c0249e 100644 --- a/apps/im_app/lib/features/chat/view/chat_detail_page.dart +++ b/apps/im_app/lib/features/chat/view/chat_detail_page.dart @@ -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 { ImagePickerSheet.show(context, ref, chatId: _chatId); } + // ── 附件面板(#52~#55) ────────────────────────────────────────────────────── + + Future _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 _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'))); + } + } + } + + /// 视频选取后发送(#54):image_picker.pickVideo → SendVideoUseCase + Future _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 { 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( diff --git a/apps/im_app/lib/features/chat/view/widgets/attachment_panel_sheet.dart b/apps/im_app/lib/features/chat/view/widgets/attachment_panel_sheet.dart new file mode 100644 index 0000000..4a74954 --- /dev/null +++ b/apps/im_app/lib/features/chat/view/widgets/attachment_panel_sheet.dart @@ -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 show(BuildContext context) { + return showModalBottomSheet( + 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, + ), + ], + ), + ); + } +} diff --git a/apps/im_app/lib/features/chat/view/widgets/emoji_panel.dart b/apps/im_app/lib/features/chat/view/widgets/emoji_panel.dart new file mode 100644 index 0000000..8e92cd0 --- /dev/null +++ b/apps/im_app/lib/features/chat/view/widgets/emoji_panel.dart @@ -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 show( + BuildContext context, { + required void Function(String emoji) onEmojiSelected, + required VoidCallback onBackspace, + }) { + return showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (_) => EmojiPanel( + onEmojiSelected: onEmojiSelected, + onBackspace: onBackspace, + ), + ); + } + + // ── 表情数据 ───────────────────────────────────────────────────────────────── + + static const List _common = [ + '😀','😃','😄','😁','😆','😅','😂','🤣','😊','😇','🙂','🙃', + '😉','😌','😍','🥰','😘','😗','😋','😛','😝','😜','🤪','😎', + '🥳','😏','😒','😞','😔','😟','😕','🙁','😣','😖','😫','😩', + '🥺','😢','😭','😤','😠','😡','🤬','😳','🥵','🥶','😱','😨', + '😰','😥','😓','🤗','🤔','🤭','🤫','🤥','😶','😐','😑','😬', + '🙄','😯','😮','😲','🥱','😴','🤐','🥴','🤢','🤮','🤧','😷', + '🤒','🤕','🥸','😵','🤠','🥹','😈','👿','👹','👺','🤡','👻', + '💀','☠️','👾','🤖','😺','😸','😹','😻','😼','😽','🙀','😿', + ]; + + static const List _people = [ + '👋','🤚','🖐️','✋','🖖','👌','🤌','🤏','✌️','🤞','🤟','🤘', + '👈','👉','👆','🖕','👇','☝️','👍','👎','✊','👊','🤛','🤜', + '👏','🙌','🫶','🤝','🙏','✍️','💅','🤳','💪','🦾','🦿','🦵', + '🧑','👦','👧','🧒','👱','👴','👵','🧓','👮','👷','💂','🕵️', + '🧑‍⚕️','🧑‍🏫','🧑‍🍳','🧑‍🔧','🧑‍💻','🧑‍🎤','🧑‍🎨','🧑‍✈️','🧑‍🚀','🧑‍🏭', + '👫','👬','👭','💑','💏','👨‍👩‍👦','👨‍👩‍👧','🧑‍🤝‍🧑', + '🚶','🧍','🏃','💃','🕺','🤸','⛹️','🏋️','🤼','🤺', + ]; + + static const List _nature = [ + '🐶','🐱','🐭','🐹','🐰','🦊','🐻','🐼','🐻‍❄️','🐨','🐯','🦁', + '🐮','🐷','🐸','🐵','🙈','🙉','🙊','🐔','🐧','🐦','🦅','🦆', + '🦉','🦇','🐝','🪱','🐛','🦋','🐌','🐞','🐜','🪲','🦟','🦗', + '🌸','🌺','🌻','🌹','🌷','💐','🍀','🌿','🌱','🌲','🌳','🌴', + '🌵','🎋','🎍','☘️','🍁','🍂','🍃','🪴','⚡','🌈','☀️','🌤️', + '⛅','🌥️','☁️','🌦️','🌧️','⛈️','🌩️','🌨️','❄️','☃️','🔥','🌊', + ]; + + static const List _objects = [ + '🍎','🍊','🍋','🍇','🍓','🫐','🍒','🍑','🥭','🍍','🥥','🍌', + '🍕','🍔','🍟','🌭','🥪','🌮','🌯','🫔','🥙','🧆','🍜','🍝', + '☕','🍵','🧃','🥤','🧋','🍺','🍻','🥂','🍷','🍸','🍹','🧉', + '🎉','🎊','🎈','🎁','🎀','🪅','🎆','🎇','✨','🌟','⭐','💫', + '❤️','🧡','💛','💚','💙','💜','🖤','🤍','🤎','💔','❤️‍🔥','💕', + '💞','💓','💗','💖','💘','💝','💟','❣️','💌','💋','💯','🔥', + '👑','💎','🏆','🥇','🎖️','🏅','🎗️','🎫','🎟️','🎪','🎭','🎨', + ]; + + static const List> _tabs = [_common, _people, _nature, _objects]; + static const List _tabLabels = ['😀 常用', '🙋 人物', '🌿 自然', '🍕 物件']; + + @override + State createState() => _EmojiPanelState(); +} + +class _EmojiPanelState extends State + 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), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/im_app/lib/features/chat/view/widgets/sticker_message_bubble.dart b/apps/im_app/lib/features/chat/view/widgets/sticker_message_bubble.dart new file mode 100644 index 0000000..2cf44af --- /dev/null +++ b/apps/im_app/lib/features/chat/view/widgets/sticker_message_bubble.dart @@ -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 _parse(String raw) { + try { + return jsonDecode(raw) as Map; + } catch (_) { + return {}; + } + } +}