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/fetch_history_use_case.dart';
|
||||||
import 'package:im_app/features/chat/usecases/send_image_usecase.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_message_use_case.dart';
|
||||||
|
import 'package:im_app/features/chat/usecases/send_video_usecase.dart';
|
||||||
|
|
||||||
/// ## DI 装配:Chat 服务层
|
/// ## DI 装配:Chat 服务层
|
||||||
///
|
///
|
||||||
@@ -75,3 +76,13 @@ final sendImageUseCaseProvider = Provider<SendImageUseCase>((ref) {
|
|||||||
sendMessage: ref.read(sendMessageUseCaseProvider),
|
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/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_grid_bubble.dart';
|
||||||
import 'package:im_app/features/chat/view/widgets/image_message_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/image_picker_sheet.dart';
|
||||||
import 'package:im_app/features/chat/view/widgets/red_envelope_bubble.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';
|
import 'package:im_app/features/chat/view/widgets/video_message_bubble.dart';
|
||||||
|
|
||||||
/// 聊天详情页(#28 / #35 / #37)
|
/// 聊天详情页(#28 / #35 / #37)
|
||||||
@@ -94,6 +100,113 @@ class _ChatDetailPageState extends ConsumerState<ChatDetailPage> {
|
|||||||
ImagePickerSheet.show(context, ref, chatId: _chatId);
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final state = ref.watch(chatDetailViewModelProvider(_chatId));
|
final state = ref.watch(chatDetailViewModelProvider(_chatId));
|
||||||
@@ -162,7 +275,8 @@ class _ChatDetailPageState extends ConsumerState<ChatDetailPage> {
|
|||||||
controller: _inputCtrl,
|
controller: _inputCtrl,
|
||||||
isSending: state.isSending,
|
isSending: state.isSending,
|
||||||
onSend: _send,
|
onSend: _send,
|
||||||
onAttach: _showImagePicker,
|
onAttachPanel: _showAttachmentPanel,
|
||||||
|
onEmoji: _showEmojiPanel,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -318,8 +432,8 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
final content = message.content ?? '';
|
final content = message.content ?? '';
|
||||||
final typ = message.typ ?? 1;
|
final typ = message.typ ?? 1;
|
||||||
|
|
||||||
final isMedia = typ == 2 || typ == 3 || typ == 4 || typ == 6 ||
|
final isMedia = typ == 2 || typ == 3 || typ == 4 || typ == 5 ||
|
||||||
typ == 8 || typ == 24;
|
typ == 6 || typ == 8 || typ == 24;
|
||||||
|
|
||||||
final bubble = _buildBubble(context, cs, content, typ);
|
final bubble = _buildBubble(context, cs, content, typ);
|
||||||
|
|
||||||
@@ -350,6 +464,8 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
switch (typ) {
|
switch (typ) {
|
||||||
case 2:
|
case 2:
|
||||||
return ImageMessageBubble(rawContent: content);
|
return ImageMessageBubble(rawContent: content);
|
||||||
|
case 5:
|
||||||
|
return StickerMessageBubble(rawContent: content);
|
||||||
case 3:
|
case 3:
|
||||||
return AudioMessageBubble(rawContent: content);
|
return AudioMessageBubble(rawContent: content);
|
||||||
case 4:
|
case 4:
|
||||||
@@ -413,13 +529,17 @@ class _InputBar extends StatelessWidget {
|
|||||||
required this.controller,
|
required this.controller,
|
||||||
required this.isSending,
|
required this.isSending,
|
||||||
required this.onSend,
|
required this.onSend,
|
||||||
required this.onAttach,
|
required this.onAttachPanel,
|
||||||
|
required this.onEmoji,
|
||||||
});
|
});
|
||||||
|
|
||||||
final TextEditingController controller;
|
final TextEditingController controller;
|
||||||
final bool isSending;
|
final bool isSending;
|
||||||
final VoidCallback onSend;
|
final VoidCallback onSend;
|
||||||
final VoidCallback onAttach;
|
/// 附件面板(#52):相机/相册/视频/文件/录音/红包
|
||||||
|
final VoidCallback onAttachPanel;
|
||||||
|
/// 表情面板(#56)
|
||||||
|
final VoidCallback onEmoji;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -428,11 +548,17 @@ class _InputBar extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// 附件按钮(#33)
|
// 表情按钮(#56)
|
||||||
IconButton(
|
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),
|
icon: const Icon(Icons.add_circle_outline_rounded),
|
||||||
tooltip: '发送图片',
|
tooltip: '附件',
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
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