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:
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 视频选取后发送(#54):image_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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
201
apps/im_app/lib/features/chat/view/widgets/emoji_panel.dart
Normal file
201
apps/im_app/lib/features/chat/view/widgets/emoji_panel.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user