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,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);
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} 张图片发送失败')),
);
}
}