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:
@@ -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