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:
144
Doc/image_grid_architecture.md
Normal file
144
Doc/image_grid_architecture.md
Normal 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 |
|
||||
| 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)
|
||||
@@ -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';
|
||||
|
||||
@@ -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<ChatDetailPage> {
|
||||
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<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) ────────────────────────────────────────────────────────
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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<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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -102,21 +102,19 @@ class _ImagePickerSheetState extends ConsumerState<ImagePickerSheet> {
|
||||
setState(() => _isSending = true);
|
||||
|
||||
final useCase = ref.read(sendImageUseCaseProvider);
|
||||
final errors = <String>[];
|
||||
final filePaths = _selected.map((f) => f.path).toList();
|
||||
|
||||
for (final file in List<XFile>.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} 张图片发送失败')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user