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

@@ -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 |
| 39 | 3 列 | 78 × 78 pt | 3 pt |
- **外层圆角**8 ptClipRRect
- **单格圆角**4 pt
- **最后一行靠左**:不足 3 格时用透明占位补齐(仅 3 列布局)
- **总宽度**2 列 = 235 pt3 列 = 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

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/data/remote/upload_file_request.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';
/// 图片上传并发送消息用例(#33 /// 图片上传并发送消息用例(#33 / #38
/// ///
/// 对应 iOS `ImageProcessor.swift` + `ChatView.swift sendImageMessage()` /// 对应 iOS `ImageProcessor.swift` + `ChatView.swift sendImageMessage()`
/// ///
/// ## 执行流程 /// ## 单图流程 [execute]
/// 1. 读取图片字节 → [Uint8List] /// 1. 读取图片字节 → [Uint8List]
/// 2. `dart:ui.ImageDescriptor.encoded()` → 解析压缩后宽高(零外部依赖) /// 2. `dart:ui.ImageDescriptor.encoded()` → 解析压缩后宽高(零外部依赖)
/// 3. 写入临时文件 → [UploadFileRequest] FormData → [UploadResult.url] /// 3. 写入临时文件 → [UploadFileRequest] FormData → [UploadResult.url]
/// 4. `jsonEncode({"url":url,"width":w,"height":h})` → [SendMessageUseCase] typ=2 /// 4. `jsonEncode({"url":url,"width":w,"height":h})` → [SendMessageUseCase] typ=2
/// 5. 删除临时文件 /// 5. 删除临时文件
/// ///
/// ## 多图批量流程 [sendBatch]#38
/// 1. **并行**上传所有图片([Future.wait]
/// 2. 快速顺序发送消息 → sendTime 各差毫秒级,满足宫格分组 < 5s 条件
///
/// 发送中进度通过 [onProgress] 回调传出0.0~1.0 /// 发送中进度通过 [onProgress] 回调传出0.0~1.0
/// 用于 [ImageMessageBubble] 渲染进度环。 /// 用于 [ImageMessageBubble] 渲染进度环。
class SendImageUseCase { 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) { String _extOf(String path) {
final dot = path.lastIndexOf('.'); final dot = path.lastIndexOf('.');
if (dot == -1 || dot == path.length - 1) return '.jpg'; if (dot == -1 || dot == path.length - 1) return '.jpg';

View File

@@ -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/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/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_message_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/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/video_message_bubble.dart'; import 'package:im_app/features/chat/view/widgets/video_message_bubble.dart';
/// 聊天详情页(#28 / #35 /// 聊天详情页(#28 / #35 / #37
/// ///
/// 接收 [conversationId]chatId 字符串)和 [title](会话名称)。 /// 接收 [conversationId]chatId 字符串)和 [title](会话名称)。
/// 通过 [ChatDetailViewModel] 监听 DB 消息 Stream实时渲染气泡列表。 /// 通过 [ChatDetailViewModel] 监听 DB 消息 Stream实时渲染气泡列表。
/// 底部输入框调用 [ChatDetailViewModel.sendMessage] 发送文本消息。 /// 底部输入框调用 [ChatDetailViewModel.sendMessage] 发送文本消息。
/// ///
/// [_MessageBubble] 按 [Message.typ] 路由到对应气泡组件(#35 /// ## 消息显示项ChatDisplayItem#37
/// - typ=1 → 纯文本 ///
/// - typ=2 → [ImageMessageBubble] /// [_buildDisplayItems] 在渲染前对消息列表做预处理:
/// - typ=3 → [AudioMessageBubble] /// 把同一发送者、5 秒内的连续 typ=2 图片消息2-9 条)合并为一个
/// - typ=4/24 → [VideoMessageBubble] /// [_ChatDisplayItem.grid],交由 [ImageGridBubble] 渲染iOS parity
/// - typ=6 → [FileMessageBubble] ///
/// - typ=8 → [RedEnvelopeBubble] /// ## 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 { class ChatDetailPage extends ConsumerStatefulWidget {
const ChatDetailPage({ const ChatDetailPage({
super.key, super.key,
@@ -105,18 +114,22 @@ class _ChatDetailPageState extends ConsumerState<ChatDetailPage> {
WidgetsBinding.instance.addPostFrameCallback( WidgetsBinding.instance.addPostFrameCallback(
(_) => _scrollToBottom(), (_) => _scrollToBottom(),
); );
final displayItems = _buildDisplayItems(msgs);
return ListView.builder( return ListView.builder(
controller: _scrollCtrl, controller: _scrollCtrl,
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 12, horizontal: 12,
vertical: 8, vertical: 8,
), ),
itemCount: msgs.length, itemCount: displayItems.length,
itemBuilder: (context, i) => _MessageBubble( itemBuilder: (context, i) {
message: msgs[i], final item = displayItems[i];
isMine: msgs[i].sendId == currentUid, return _DisplayItemWidget(
chatId: _chatId, item: item,
), currentUid: currentUid,
chatId: _chatId,
);
},
); );
}, },
loading: () => loading: () =>
@@ -157,6 +170,125 @@ class _ChatDetailPageState extends ConsumerState<ChatDetailPage> {
} }
} }
// ── ChatDisplayItem (#37) ─────────────────────────────────────────────────────
/// 渲染层显示项
///
/// - [single]:单条消息
/// - [grid]2-9 条连续同发送者 typ=2 图片消息(宫格)
class _ChatDisplayItem {
const _ChatDisplayItem.single(this.message) : grid = null;
const _ChatDisplayItem.imageGrid(List<Message> images)
: message = images.first,
grid = images;
/// 代表消息(用于作者/时间/对齐)
final Message message;
/// 非 null 时表示宫格分组
final List<Message>? grid;
bool get isGrid => grid != null;
}
/// 连续图片消息分组逻辑iOS ChatView.buildDisplayItems 对齐,#37
///
/// 分组条件:
/// - 连续 typ=2 消息
/// - 同一 sendId
/// - 相邻消息 sendTime 差 < 5 秒
/// - 最多 9 条一组
List<_ChatDisplayItem> _buildDisplayItems(List<Message> msgs) {
final items = <_ChatDisplayItem>[];
int i = 0;
while (i < msgs.length) {
final curr = msgs[i];
if ((curr.typ ?? 1) == 2) {
final batch = <Message>[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 ──────────────────────────────────────────────────────── // ── 消息气泡路由(#35 ────────────────────────────────────────────────────────
class _MessageBubble extends StatelessWidget { class _MessageBubble extends StatelessWidget {
@@ -176,11 +308,10 @@ class _MessageBubble extends StatelessWidget {
final content = message.content ?? ''; final content = message.content ?? '';
final typ = message.typ ?? 1; final typ = message.typ ?? 1;
// 媒体类型气泡不需要外层 Container 装饰
final isMedia = typ == 2 || typ == 3 || typ == 4 || typ == 6 || final isMedia = typ == 2 || typ == 3 || typ == 4 || typ == 6 ||
typ == 8 || typ == 24; typ == 8 || typ == 24;
final bubble = _buildBubble(context, cs, content, typ, isMedia); final bubble = _buildBubble(context, cs, content, typ);
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),
@@ -190,17 +321,7 @@ class _MessageBubble extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
if (!isMine) ...[ if (!isMine) ...[
CircleAvatar( _Avatar(sendId: message.sendId ?? 0),
radius: 16,
backgroundColor: cs.secondaryContainer,
child: Text(
(message.sendId ?? 0).toString().substring(0, 1),
style: TextStyle(
fontSize: 12,
color: cs.onSecondaryContainer,
),
),
),
const SizedBox(width: 8), const SizedBox(width: 8),
], ],
isMedia ? bubble : Flexible(child: bubble), isMedia ? bubble : Flexible(child: bubble),
@@ -215,7 +336,6 @@ class _MessageBubble extends StatelessWidget {
ColorScheme cs, ColorScheme cs,
String content, String content,
int typ, int typ,
bool isMedia,
) { ) {
switch (typ) { switch (typ) {
case 2: 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 { class _InputBar extends StatelessWidget {

View File

@@ -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 × 29 条连续消息,#36
///
/// 对应 iOS `ImageGridBubble.swift`issue #428
///
/// ## 布局规则iOS 对齐)
///
/// | 图片数 | 列数 | 单格尺寸 | 间距 |
/// |--------|------|----------|------|
/// | 2 | 2 列 | 116 × 116 pt | 3 pt |
/// | 39 | 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});
/// 29 条 typ=2 消息(已经过 _buildDisplayItems 分组)
final List<Message> 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<String, dynamic> _parseContent(String raw) {
try {
return jsonDecode(raw) as Map<String, dynamic>;
} 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),
),
),
),
),
);
}
}

View File

@@ -102,21 +102,19 @@ class _ImagePickerSheetState extends ConsumerState<ImagePickerSheet> {
setState(() => _isSending = true); setState(() => _isSending = true);
final useCase = ref.read(sendImageUseCaseProvider); final useCase = ref.read(sendImageUseCaseProvider);
final errors = <String>[]; final filePaths = _selected.map((f) => f.path).toList();
for (final file in List<XFile>.from(_selected)) { // 多图并行上传 + 快速顺序发送(#38满足宫格分组 <5s 条件
try { final failed = await useCase.sendBatch(
await useCase.execute(filePath: file.path, chatId: widget.chatId); filePaths: filePaths,
} catch (e) { chatId: widget.chatId,
errors.add(e.toString()); );
}
}
if (mounted) { if (mounted) {
Navigator.pop(context); Navigator.pop(context);
if (errors.isNotEmpty) { if (failed.isNotEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${errors.length} 张图片发送失败')), SnackBar(content: Text('${failed.length} 张图片发送失败')),
); );
} }
} }