feat: 多图宫格气泡全量实现 (#36~#38)

- #36 ImageGridBubble: 2 列×116pt / 3 列×78pt, 3pt 间距, 8pt 外层/4pt 单格圆角, tap→ImageViewerPage
- #37 ChatDisplayItem + _buildDisplayItems: 连续 typ=2 同 sendId Δt<5s 分组为宫格,iOS ChatView.buildDisplayItems parity
- #38 SendImageUseCase.sendBatch: Future.wait 并行上传 → 顺序快速发送,ImagePickerSheet 改用 sendBatch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
pp-bot
2026-03-24 14:09:27 +09:00
parent b971900263
commit 2354e92c64
5 changed files with 551 additions and 40 deletions

View File

@@ -8,17 +8,21 @@ 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';
/// 图片上传并发送消息用例(#33
/// 图片上传并发送消息用例(#33 / #38
///
/// 对应 iOS `ImageProcessor.swift` + `ChatView.swift sendImageMessage()`
///
/// ## 执行流程
/// ## 单图流程 [execute]
/// 1. 读取图片字节 → [Uint8List]
/// 2. `dart:ui.ImageDescriptor.encoded()` → 解析压缩后宽高(零外部依赖)
/// 3. 写入临时文件 → [UploadFileRequest] FormData → [UploadResult.url]
/// 4. `jsonEncode({"url":url,"width":w,"height":h})` → [SendMessageUseCase] typ=2
/// 5. 删除临时文件
///
/// ## 多图批量流程 [sendBatch]#38
/// 1. **并行**上传所有图片([Future.wait]
/// 2. 快速顺序发送消息 → sendTime 各差毫秒级,满足宫格分组 < 5s 条件
///
/// 发送中进度通过 [onProgress] 回调传出0.0~1.0
/// 用于 [ImageMessageBubble] 渲染进度环。
class SendImageUseCase {
@@ -98,6 +102,67 @@ class SendImageUseCase {
}
}
/// 批量并行上传并快速顺序发送(#38
///
/// 并行上传保证所有消息 sendTime 在毫秒级内,满足宫格分组条件(< 5 秒)。
///
/// 返回失败的文件路径列表;空列表表示全部成功。
Future<List<String>> sendBatch({
required List<String> filePaths,
required int chatId,
}) async {
if (filePaths.isEmpty) return [];
// 1. 并行上传所有图片
final results = await Future.wait(
filePaths.map((p) => _uploadOnly(p)),
eagerError: false,
);
// 2. 顺序快速发送(上传成功的)
final failed = <String>[];
for (var i = 0; i < filePaths.length; i++) {
final content = results[i];
if (content == null) {
failed.add(filePaths[i]);
continue;
}
try {
await _sendMessage.execute(chatId: chatId, content: content, typ: 2);
} catch (e) {
debugPrint('[SendImageUseCase.sendBatch] send error: $e');
failed.add(filePaths[i]);
}
}
return failed;
}
/// 仅上传,返回 `jsonEncode({url,width,height})` 或 null失败
Future<String?> _uploadOnly(String filePath) async {
try {
final bytes = await File(filePath).readAsBytes();
final (w, h) = await _resolveSize(bytes);
final ext = _extOf(filePath);
final tempFile = File(
'${Directory.systemTemp.path}/upload_${DateTime.now().microsecondsSinceEpoch}$ext',
);
await tempFile.writeAsBytes(bytes);
try {
final result = await _apiClient.executeRequest(
UploadFileRequest(filePath: tempFile.path),
);
final url = result?.url ?? '';
if (url.isEmpty) return null;
return '{"url":"$url","width":$w,"height":$h}';
} finally {
try { await tempFile.delete(); } catch (_) {}
}
} catch (e) {
debugPrint('[SendImageUseCase._uploadOnly] $filePath: $e');
return null;
}
}
String _extOf(String path) {
final dot = path.lastIndexOf('.');
if (dot == -1 || dot == path.length - 1) return '.jpg';