feat: 图片预览与发送全量实现 (#31~#35)

- #31 ImageMessageBubble: typ=2 气泡,max 220pt / min 80pt 尺寸规则,进度环叠层
- #32 ImageViewerPage: photo_view 全屏查看,PhotoViewGallery 多图滑动,保存+分享工具栏
- #33 ImagePickerSheet + SendImageUseCase: 相册/相机选图(最多9张),裁剪,dart:ui 解析宽高,FormData CDN 上传,typ=2 发送
- #34 image_cropper 接入:_PreviewTile 点击裁剪,iOS TOCropViewController 对齐
- #35 _MessageBubble BubbleKind 路由:typ switch → 各媒体气泡,输入栏新增附件按钮

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
pp-bot
2026-03-24 13:20:56 +09:00
parent 23fc6b0c86
commit b971900263
8 changed files with 1082 additions and 27 deletions

View File

@@ -0,0 +1,106 @@
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;
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';
/// 图片上传并发送消息用例(#33
///
/// 对应 iOS `ImageProcessor.swift` + `ChatView.swift sendImageMessage()`
///
/// ## 执行流程
/// 1. 读取图片字节 → [Uint8List]
/// 2. `dart:ui.ImageDescriptor.encoded()` → 解析压缩后宽高(零外部依赖)
/// 3. 写入临时文件 → [UploadFileRequest] FormData → [UploadResult.url]
/// 4. `jsonEncode({"url":url,"width":w,"height":h})` → [SendMessageUseCase] typ=2
/// 5. 删除临时文件
///
/// 发送中进度通过 [onProgress] 回调传出0.0~1.0
/// 用于 [ImageMessageBubble] 渲染进度环。
class SendImageUseCase {
final NetworksSdkApi _apiClient;
final SendMessageUseCase _sendMessage;
SendImageUseCase({
required NetworksSdkApi apiClient,
required SendMessageUseCase sendMessage,
}) : _apiClient = apiClient,
_sendMessage = sendMessage;
Future<void> execute({
required String filePath,
required int chatId,
int chatType = 1,
void Function(double)? onProgress,
}) async {
// 1. 读取字节
final bytes = await File(filePath).readAsBytes();
// 2. 解析宽高
final (width, height) = await _resolveSize(bytes);
// 3. 写临时文件image_picker 有时返回没有扩展名的路径,确保临时文件有扩展名)
final ext = _extOf(filePath);
final tempFile = File(
'${Directory.systemTemp.path}/upload_${DateTime.now().millisecondsSinceEpoch}$ext',
);
await tempFile.writeAsBytes(bytes);
onProgress?.call(0.1);
// 4. 上传
String uploadedUrl = '';
try {
final result = await _apiClient.executeRequest(
UploadFileRequest(filePath: tempFile.path),
);
uploadedUrl = result?.url ?? '';
} catch (e) {
debugPrint('[SendImageUseCase] upload error: $e');
rethrow;
} finally {
// 5. 删临时文件(无论成败)
try {
await tempFile.delete();
} catch (_) {}
}
if (uploadedUrl.isEmpty) {
throw Exception('[SendImageUseCase] upload returned empty url');
}
onProgress?.call(0.9);
// 6. 发送 typ=2 消息
final content = '{"url":"$uploadedUrl","width":$width,"height":$height}';
await _sendMessage.execute(chatId: chatId, content: content, typ: 2);
onProgress?.call(1.0);
}
/// 使用 [dart:ui.ImageDescriptor] 解析字节宽高(零额外依赖)
Future<(int, int)> _resolveSize(Uint8List bytes) async {
try {
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
final descriptor = await ui.ImageDescriptor.encoded(buffer);
final w = descriptor.width;
final h = descriptor.height;
descriptor.dispose();
buffer.dispose();
return (w, h);
} catch (e) {
debugPrint('[SendImageUseCase] resolveSize error: $e');
return (0, 0);
}
}
String _extOf(String path) {
final dot = path.lastIndexOf('.');
if (dot == -1 || dot == path.length - 1) return '.jpg';
return path.substring(dot);
}
}