From 2354e92c6443e27e2def602a67a4be321fcc5a81 Mon Sep 17 00:00:00 2001 From: pp-bot Date: Tue, 24 Mar 2026 14:09:27 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A4=9A=E5=9B=BE=E5=AE=AB=E6=A0=BC?= =?UTF-8?q?=E6=B0=94=E6=B3=A1=E5=85=A8=E9=87=8F=E5=AE=9E=E7=8E=B0=20(#36~#?= =?UTF-8?q?38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #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 --- Doc/image_grid_architecture.md | 144 +++++++++++++ .../chat/usecases/send_image_usecase.dart | 69 +++++- .../features/chat/view/chat_detail_page.dart | 196 +++++++++++++++--- .../chat/view/widgets/image_grid_bubble.dart | 164 +++++++++++++++ .../chat/view/widgets/image_picker_sheet.dart | 18 +- 5 files changed, 551 insertions(+), 40 deletions(-) create mode 100644 Doc/image_grid_architecture.md create mode 100644 apps/im_app/lib/features/chat/view/widgets/image_grid_bubble.dart diff --git a/Doc/image_grid_architecture.md b/Doc/image_grid_architecture.md new file mode 100644 index 0000000..13455b6 --- /dev/null +++ b/Doc/image_grid_architecture.md @@ -0,0 +1,144 @@ +# 多图宫格气泡架构文档 + +对应 Gitea issues #36–#38 + +--- + +## 1. 背景与目标 + +iOS 老项目 (`ImageGridBubble.swift`, `ChatView.buildDisplayItems()`) 已实现: +- 接收端:连续同发送者图片消息自动合并为宫格气泡 +- 发送端:多图并行上传 → 快速连续发送 → 满足宫格分组条件 + +Flutter 新项目补齐以下能力: + +| 功能 | Issue | iOS 对应 | +|------|-------|---------| +| 宫格气泡展示 | #36 | `ImageGridBubble.swift` | +| 消息分组逻辑 | #37 | `ChatView.buildDisplayItems()` | +| 多图并行发送 | #38 | `PhotosPicker + parallel upload` | + +--- + +## 2. 消息格式 + +多图不引入新的 typ。每张图片仍为独立的 **typ=2** 消息: + +```json +{"url":"Image/xxx.jpg","width":1024,"height":768} +``` + +分组完全在 **客户端渲染层** 完成,服务端/DB 无感知。 + +--- + +## 3. 宫格布局规则(iOS 对齐) + +| 图片数 | 列数 | 单格尺寸 | 间距 | +|--------|------|----------|------| +| 2 | 2 列 | 116 × 116 pt | 3 pt | +| 3–9 | 3 列 | 78 × 78 pt | 3 pt | + +- **外层圆角**:8 pt(ClipRRect) +- **单格圆角**:4 pt +- **最后一行靠左**:不足 3 格时用透明占位补齐(仅 3 列布局) +- **总宽度**:2 列 = 235 pt;3 列 = 240 pt + +--- + +## 4. 分组算法(iOS ChatView.buildDisplayItems 对齐) + +``` +for i in msgs: + if msgs[i].typ == 2: + batch = [msgs[i]] + j = i + 1 + while j < len(msgs) and len(batch) < 9: + next = msgs[j] + if next.typ == 2 + and next.sendId == batch[0].sendId + and (next.sendTime - msgs[j-1].sendTime) < 5: + batch.append(next) + j++ + else: + break + if len(batch) >= 2: + emit ChatDisplayItem(grid=batch) + i = j + continue + emit ChatDisplayItem(single=msgs[i]) + i++ +``` + +关键规则: +- 同一 `sendId`(不区分我发/他发) +- 连续相邻消息时间差 < **5 秒**(不是与第一条比,是与前一条比) +- 最多 **9 条** 一组,第 10 条另起新组 +- typ 不为 2 立即截断分组 + +--- + +## 5. 并行上传设计(#38) + +``` +用户点击「发送」 + └─ SendImageUseCase.sendBatch(filePaths, chatId) + ├─ Future.wait([upload(img1), upload(img2), ..., upload(imgN)]) + │ └─ 每个 upload: readBytes → dart:ui.ImageDescriptor → UploadFileRequest → url + └─ for url in results (顺序): + └─ SendMessageUseCase.execute(chatId, jsonEncode({url,w,h}), typ=2) + └─ 乐观写入 sendTime = now()(各条仅相差毫秒) +``` + +**关键**:并行上传确保所有 `sendTime` 在同一时刻附近(< 1 秒差), +满足分组条件(< 5 秒)。 + +--- + +## 6. 数据流:发送多图 + +``` +ImagePickerSheet._sendAll() + └─ sendImageUseCaseProvider.sendBatch(filePaths, chatId) + ├─ 并行上传 → [(url1,w1,h1), (url2,w2,h2), ...] + └─ 顺序发送 → [typ=2 msg1, typ=2 msg2, ...] + └─ DB Stream → messagesByChatIdProvider + └─ _buildDisplayItems() + └─ [msg1, msg2] 同 sendId + typ=2 + Δt<5s + └─ ChatDisplayItem(grid=[msg1, msg2]) + └─ ImageGridBubble(messages: [msg1, msg2]) +``` + +--- + +## 7. 数据流:接收多图 + +``` +WS/HTTP → DB → messagesByChatIdProvider Stream + └─ _buildDisplayItems(msgs) + └─ 连续 typ=2 同 sendId Δt<5s → ChatDisplayItem(grid) + └─ ImageGridBubble + ├─ GridCell(Image.network + ClipRRect 4pt) + └─ onTap → ImageViewerPage.open(urls: allUrls, initialIndex: i) +``` + +--- + +## 8. 新增/修改文件清单 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `lib/features/chat/view/widgets/image_grid_bubble.dart` | 新增 | #36 宫格气泡组件 | +| `lib/features/chat/view/chat_detail_page.dart` | 修改 | #37 ChatDisplayItem + buildDisplayItems | +| `lib/features/chat/usecases/send_image_usecase.dart` | 修改 | #38 sendBatch() 并行上传 | +| `lib/features/chat/view/widgets/image_picker_sheet.dart` | 修改 | #38 改用 sendBatch() | +| `Doc/image_grid_architecture.md` | 新增 | 本文档 | + +--- + +## 9. 待完成 + +- 图片 sticker (typ=5) 展示 +- GIF 消息 (typ=25) 支持(目前 typ=25 走 ImageMessageBubble,后续可合并入宫格分组) +- 端到端加密图片(cipher_guard_sdk 接入后) +- 宫格内视频支持(若后续 album 含 video) diff --git a/apps/im_app/lib/features/chat/usecases/send_image_usecase.dart b/apps/im_app/lib/features/chat/usecases/send_image_usecase.dart index 5da888e..842a09f 100644 --- a/apps/im_app/lib/features/chat/usecases/send_image_usecase.dart +++ b/apps/im_app/lib/features/chat/usecases/send_image_usecase.dart @@ -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> sendBatch({ + required List 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 = []; + 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 _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'; 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 595b347..434f07c 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 @@ -7,24 +7,33 @@ import 'package:im_app/features/chat/di/message_provider.dart'; import 'package:im_app/features/chat/presentation/chat_detail_view_model.dart'; 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: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/video_message_bubble.dart'; -/// 聊天详情页(#28 / #35) +/// 聊天详情页(#28 / #35 / #37) /// /// 接收 [conversationId](chatId 字符串)和 [title](会话名称)。 /// 通过 [ChatDetailViewModel] 监听 DB 消息 Stream,实时渲染气泡列表。 /// 底部输入框调用 [ChatDetailViewModel.sendMessage] 发送文本消息。 /// -/// [_MessageBubble] 按 [Message.typ] 路由到对应气泡组件(#35): -/// - typ=1 → 纯文本 -/// - typ=2 → [ImageMessageBubble] -/// - typ=3 → [AudioMessageBubble] -/// - typ=4/24 → [VideoMessageBubble] -/// - typ=6 → [FileMessageBubble] -/// - typ=8 → [RedEnvelopeBubble] +/// ## 消息显示项(ChatDisplayItem,#37) +/// +/// [_buildDisplayItems] 在渲染前对消息列表做预处理: +/// 把同一发送者、5 秒内的连续 typ=2 图片消息(2-9 条)合并为一个 +/// [_ChatDisplayItem.grid],交由 [ImageGridBubble] 渲染(iOS parity)。 +/// +/// ## BubbleKind 路由(#35) +/// +/// - typ=1 → 纯文本 +/// - typ=2 单条 → [ImageMessageBubble] +/// - typ=2 组合 → [ImageGridBubble] +/// - typ=3 → [AudioMessageBubble] +/// - typ=4/24 → [VideoMessageBubble] +/// - typ=6 → [FileMessageBubble] +/// - typ=8 → [RedEnvelopeBubble] class ChatDetailPage extends ConsumerStatefulWidget { const ChatDetailPage({ super.key, @@ -105,18 +114,22 @@ class _ChatDetailPageState extends ConsumerState { WidgetsBinding.instance.addPostFrameCallback( (_) => _scrollToBottom(), ); + final displayItems = _buildDisplayItems(msgs); return ListView.builder( controller: _scrollCtrl, padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 8, ), - itemCount: msgs.length, - itemBuilder: (context, i) => _MessageBubble( - message: msgs[i], - isMine: msgs[i].sendId == currentUid, - chatId: _chatId, - ), + itemCount: displayItems.length, + itemBuilder: (context, i) { + final item = displayItems[i]; + return _DisplayItemWidget( + item: item, + currentUid: currentUid, + chatId: _chatId, + ); + }, ); }, loading: () => @@ -157,6 +170,125 @@ class _ChatDetailPageState extends ConsumerState { } } +// ── ChatDisplayItem (#37) ───────────────────────────────────────────────────── + +/// 渲染层显示项 +/// +/// - [single]:单条消息 +/// - [grid]:2-9 条连续同发送者 typ=2 图片消息(宫格) +class _ChatDisplayItem { + const _ChatDisplayItem.single(this.message) : grid = null; + const _ChatDisplayItem.imageGrid(List images) + : message = images.first, + grid = images; + + /// 代表消息(用于作者/时间/对齐) + final Message message; + + /// 非 null 时表示宫格分组 + final List? grid; + + bool get isGrid => grid != null; +} + +/// 连续图片消息分组逻辑(iOS ChatView.buildDisplayItems 对齐,#37) +/// +/// 分组条件: +/// - 连续 typ=2 消息 +/// - 同一 sendId +/// - 相邻消息 sendTime 差 < 5 秒 +/// - 最多 9 条一组 +List<_ChatDisplayItem> _buildDisplayItems(List msgs) { + final items = <_ChatDisplayItem>[]; + int i = 0; + + while (i < msgs.length) { + final curr = msgs[i]; + if ((curr.typ ?? 1) == 2) { + final batch = [curr]; + int j = i + 1; + + while (j < msgs.length && batch.length < 9) { + final next = msgs[j]; + final prev = msgs[j - 1]; + final timeDiff = ((next.sendTime ?? 0) - (prev.sendTime ?? 0)).abs(); + + if ((next.typ ?? 1) == 2 && + next.sendId == curr.sendId && + timeDiff < 5) { + batch.add(next); + j++; + } else { + break; + } + } + + if (batch.length >= 2) { + items.add(_ChatDisplayItem.imageGrid(batch)); + i = j; + continue; + } + } + + items.add(_ChatDisplayItem.single(curr)); + i++; + } + + return items; +} + +// ── 显示项渲染 ──────────────────────────────────────────────────────────────── + +class _DisplayItemWidget extends StatelessWidget { + const _DisplayItemWidget({ + required this.item, + required this.currentUid, + required this.chatId, + }); + + final _ChatDisplayItem item; + final int currentUid; + final int chatId; + + @override + Widget build(BuildContext context) { + final isMine = (item.message.sendId ?? 0) == currentUid; + + Widget bubble; + if (item.isGrid) { + bubble = ImageGridBubble(messages: item.grid!); + } else { + bubble = _MessageBubble( + message: item.message, + isMine: isMine, + chatId: chatId, + ); + } + + if (item.isGrid) { + // 宫格气泡:对齐到发送方侧,无外层 Container + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: + isMine ? MainAxisAlignment.end : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (!isMine) ...[ + _Avatar(sendId: item.message.sendId ?? 0), + const SizedBox(width: 8), + ], + bubble, + if (isMine) const SizedBox(width: 8), + ], + ), + ); + } + + return bubble; + } +} + // ── 消息气泡路由(#35) ──────────────────────────────────────────────────────── class _MessageBubble extends StatelessWidget { @@ -176,11 +308,10 @@ class _MessageBubble extends StatelessWidget { final content = message.content ?? ''; final typ = message.typ ?? 1; - // 媒体类型气泡不需要外层 Container 装饰 final isMedia = typ == 2 || typ == 3 || typ == 4 || typ == 6 || typ == 8 || typ == 24; - final bubble = _buildBubble(context, cs, content, typ, isMedia); + final bubble = _buildBubble(context, cs, content, typ); return Padding( padding: const EdgeInsets.symmetric(vertical: 4), @@ -190,17 +321,7 @@ class _MessageBubble extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, children: [ if (!isMine) ...[ - CircleAvatar( - radius: 16, - backgroundColor: cs.secondaryContainer, - child: Text( - (message.sendId ?? 0).toString().substring(0, 1), - style: TextStyle( - fontSize: 12, - color: cs.onSecondaryContainer, - ), - ), - ), + _Avatar(sendId: message.sendId ?? 0), const SizedBox(width: 8), ], isMedia ? bubble : Flexible(child: bubble), @@ -215,7 +336,6 @@ class _MessageBubble extends StatelessWidget { ColorScheme cs, String content, int typ, - bool isMedia, ) { switch (typ) { case 2: @@ -256,6 +376,26 @@ class _MessageBubble extends StatelessWidget { } } +// ── 头像 ────────────────────────────────────────────────────────────────────── + +class _Avatar extends StatelessWidget { + const _Avatar({required this.sendId}); + final int sendId; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return CircleAvatar( + radius: 16, + backgroundColor: cs.secondaryContainer, + child: Text( + sendId.toString().substring(0, 1), + style: TextStyle(fontSize: 12, color: cs.onSecondaryContainer), + ), + ); + } +} + // ── 输入栏(含附件按钮) ────────────────────────────────────────────────────── class _InputBar extends StatelessWidget { diff --git a/apps/im_app/lib/features/chat/view/widgets/image_grid_bubble.dart b/apps/im_app/lib/features/chat/view/widgets/image_grid_bubble.dart new file mode 100644 index 0000000..0498ebc --- /dev/null +++ b/apps/im_app/lib/features/chat/view/widgets/image_grid_bubble.dart @@ -0,0 +1,164 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; + +import 'package:im_app/core/services/cdn_url_resolver.dart'; +import 'package:im_app/domain/entities/message.dart'; +import 'package:im_app/features/chat/view/image_viewer_page.dart'; + +/// 多图宫格气泡(typ=2 × 2–9 条连续消息,#36) +/// +/// 对应 iOS `ImageGridBubble.swift`(issue #428) +/// +/// ## 布局规则(iOS 对齐) +/// +/// | 图片数 | 列数 | 单格尺寸 | 间距 | +/// |--------|------|----------|------| +/// | 2 | 2 列 | 116 × 116 pt | 3 pt | +/// | 3–9 | 3 列 | 78 × 78 pt | 3 pt | +/// +/// - 外层圆角 8 pt,单格圆角 4 pt +/// - 最后一行不足时用透明占位保持靠左对齐(3 列布局) +/// - 点击单格 → [ImageViewerPage](initialIndex 对应格子序号) +/// +/// ## 使用 +/// +/// ```dart +/// ImageGridBubble(messages: [msg1, msg2, msg3]) +/// ``` +class ImageGridBubble extends StatelessWidget { + const ImageGridBubble({super.key, required this.messages}); + + /// 2–9 条 typ=2 消息(已经过 _buildDisplayItems 分组) + final List messages; + + // ── 布局参数 ──────────────────────────────────────────────────────────────── + + int get _columns => messages.length == 2 ? 2 : 3; + double get _cellSize => messages.length == 2 ? 116.0 : 78.0; + static const _gap = 3.0; + + @override + Widget build(BuildContext context) { + final cols = _columns; + final size = _cellSize; + final n = messages.length; + final rowCount = (n + cols - 1) ~/ cols; + + // Resolve all CDN URLs upfront + final urls = messages.map((m) { + final parsed = _parseContent(m.content ?? ''); + final raw = parsed['url'] as String? ?? ''; + return raw.isNotEmpty ? CdnUrlResolver.resolve(raw) : ''; + }).toList(); + + final validUrls = urls.where((u) => u.isNotEmpty).toList(); + + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: List.generate(rowCount, (row) { + final start = row * cols; + final end = min(start + cols, n); + final cellsInRow = end - start; + + return Padding( + padding: EdgeInsets.only(top: row > 0 ? _gap : 0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ...List.generate(cellsInRow, (k) { + final idx = start + k; + return Padding( + padding: EdgeInsets.only(left: k > 0 ? _gap : 0), + child: _GridCell( + url: urls[idx], + size: size, + onTap: () => ImageViewerPage.open( + context, + urls: validUrls, + initialIndex: validUrls.indexOf(urls[idx]).clamp(0, validUrls.length - 1), + ), + ), + ); + }), + // 最后一行靠左:不足 cols 个时透明占位(仅 3 列布局) + if (cols == 3) + ...List.generate(cols - cellsInRow, (_) { + return Padding( + padding: const EdgeInsets.only(left: _gap), + child: SizedBox(width: size, height: size), + ); + }), + ], + ), + ); + }), + ), + ); + } + + Map _parseContent(String raw) { + try { + return jsonDecode(raw) as Map; + } catch (_) { + return {}; + } + } +} + +// ── 单格 ───────────────────────────────────────────────────────────────────── + +class _GridCell extends StatelessWidget { + const _GridCell({ + required this.url, + required this.size, + required this.onTap, + }); + + final String url; + final double size; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: url.isNotEmpty ? onTap : null, + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: SizedBox( + width: size, + height: size, + child: url.isNotEmpty + ? Image.network( + url, + fit: BoxFit.cover, + loadingBuilder: (_, child, progress) { + if (progress == null) return child; + return Container(color: Colors.grey.shade200); + }, + errorBuilder: (_, __, ___) => Container( + color: Colors.grey.shade300, + child: const Center( + child: Icon( + Icons.broken_image_outlined, + color: Colors.grey, + size: 20, + ), + ), + ), + ) + : Container( + color: Colors.grey.shade300, + child: const Center( + child: Icon(Icons.image_outlined, color: Colors.grey, size: 20), + ), + ), + ), + ), + ); + } +} diff --git a/apps/im_app/lib/features/chat/view/widgets/image_picker_sheet.dart b/apps/im_app/lib/features/chat/view/widgets/image_picker_sheet.dart index d9a6a5a..b2f557a 100644 --- a/apps/im_app/lib/features/chat/view/widgets/image_picker_sheet.dart +++ b/apps/im_app/lib/features/chat/view/widgets/image_picker_sheet.dart @@ -102,21 +102,19 @@ class _ImagePickerSheetState extends ConsumerState { setState(() => _isSending = true); final useCase = ref.read(sendImageUseCaseProvider); - final errors = []; + final filePaths = _selected.map((f) => f.path).toList(); - for (final file in List.from(_selected)) { - try { - await useCase.execute(filePath: file.path, chatId: widget.chatId); - } catch (e) { - errors.add(e.toString()); - } - } + // 多图并行上传 + 快速顺序发送(#38),满足宫格分组 <5s 条件 + final failed = await useCase.sendBatch( + filePaths: filePaths, + chatId: widget.chatId, + ); if (mounted) { Navigator.pop(context); - if (errors.isNotEmpty) { + if (failed.isNotEmpty) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('${errors.length} 张图片发送失败')), + SnackBar(content: Text('${failed.length} 张图片发送失败')), ); } }