feat: 图片预览与发送全量实现 (#31~#35)

- #31 ImageMessageBubble: typ=2 气泡,max 220pt / min 80pt 尺寸规则,进度环叠层
- #32 ImageViewerPage: photo_view 全屏查看,PhotoViewGallery 多图滑动,保存+分享工具栏
- #33 ImagePickerSheet + SendImageUseCase: 相册/相机选图(最多9张),裁剪,dart:ui 解析宽高,FormData CDN 上传,typ=2 发送
- #34 image_cropper 接入:_PreviewTile 点击裁剪,iOS TOCropViewController 对齐
- #35 _MessageBubble BubbleKind 路由:typ switch → 各媒体气泡,输入栏新增附件按钮

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
pp-bot
2026-03-24 13:20:56 +09:00
parent 23fc6b0c86
commit b971900263
8 changed files with 1082 additions and 27 deletions

View File

@@ -0,0 +1,182 @@
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/features/chat/view/image_viewer_page.dart';
/// 图片消息气泡typ = 2
///
/// 对应 Gitea issue #31 / iOS ImageMessageBubble
///
/// ## 数据格式
///
/// rawContent JSON`{ "url": "Image/xxx.jpg", "width": 1024, "height": 768 }`
///
/// ## 尺寸规则iOS 对齐)
///
/// - 最长边 ≤ 220pt
/// - 最短边 ≥ 80pt
/// - 保持原始宽高比
/// - 圆角 12pt
///
/// ## 使用
///
/// ```dart
/// ImageMessageBubble(
/// rawContent: message.content ?? '',
/// uploadProgress: 0.6, // 发送中时传入进度
/// )
/// ```
class ImageMessageBubble extends StatelessWidget {
const ImageMessageBubble({
super.key,
required this.rawContent,
this.uploadProgress,
});
final String rawContent;
/// 上传进度0.0~1.0发送中时显示进度环null 表示已接收非上传中
final double? uploadProgress;
@override
Widget build(BuildContext context) {
final parsed = _parse(rawContent);
final url = parsed['url'] as String? ?? '';
final rawW = (parsed['width'] as num?)?.toInt() ?? 0;
final rawH = (parsed['height'] as num?)?.toInt() ?? 0;
final displaySize = _computeDisplaySize(rawW, rawH);
final resolvedUrl = url.isNotEmpty ? CdnUrlResolver.resolve(url) : '';
return GestureDetector(
onTap: resolvedUrl.isNotEmpty
? () => ImageViewerPage.open(context, urls: [resolvedUrl])
: null,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: displaySize.width,
height: displaySize.height,
child: Stack(
fit: StackFit.expand,
children: [
// ── 图片内容 ─────────────────────────────────────────────────────
if (resolvedUrl.isNotEmpty)
Image.network(
resolvedUrl,
fit: BoxFit.cover,
loadingBuilder: (_, child, loadingProgress) {
if (loadingProgress == null) return child;
return Container(
color: Colors.grey.shade200,
child: Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
strokeWidth: 2,
),
),
);
},
errorBuilder: (_, __, ___) => Container(
color: Colors.grey.shade300,
child: const Center(
child: Icon(
Icons.broken_image_outlined,
color: Colors.grey,
size: 32,
),
),
),
)
else
Container(
color: Colors.grey.shade300,
child: const Center(
child: Icon(
Icons.image_outlined,
color: Colors.grey,
size: 32,
),
),
),
// ── 上传进度覆盖层 ───────────────────────────────────────────────
if (uploadProgress != null)
Container(
color: Colors.black.withOpacity(0.35),
child: Center(
child: SizedBox(
width: 40,
height: 40,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
value: uploadProgress,
color: Colors.white,
backgroundColor: Colors.white30,
strokeWidth: 3,
),
Text(
'${((uploadProgress ?? 0) * 100).toInt()}%',
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
),
],
),
),
),
);
}
/// 计算展示尺寸iOS ImageMessageBubble 对齐)
Size _computeDisplaySize(int w, int h) {
const maxLong = 220.0;
const minShort = 80.0;
if (w <= 0 || h <= 0) return const Size(150, 150);
double fw = w.toDouble();
double fh = h.toDouble();
// 最长边缩放至 ≤ 220
final longest = max(fw, fh);
if (longest > maxLong) {
final scale = maxLong / longest;
fw *= scale;
fh *= scale;
}
// 最短边扩展至 ≥ 80
final shortest = min(fw, fh);
if (shortest < minShort) {
final scale = minShort / shortest;
fw *= scale;
fh *= scale;
}
return Size(fw, fh);
}
Map<String, dynamic> _parse(String raw) {
try {
return jsonDecode(raw) as Map<String, dynamic>;
} catch (_) {
return {};
}
}
}

View File

@@ -0,0 +1,305 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:image_cropper/image_cropper.dart';
import 'package:im_app/features/chat/di/chat_service_providers.dart';
/// 图片选取底部弹窗(#33
///
/// 对应 iOS `ChatView.swift photosPicker`
///
/// 支持:
/// - 拍照([ImageSource.camera]
/// - 相册多选(最多 9 张iOS 对齐)
/// - 单张预览时点击可裁剪([image_cropper] → iOS TOCropViewController
/// - 点击「发送」→ [SendImageUseCase.execute]
///
/// ## 使用
///
/// ```dart
/// ImagePickerSheet.show(context, ref, chatId: chatId);
/// ```
class ImagePickerSheet extends ConsumerStatefulWidget {
const ImagePickerSheet({super.key, required this.chatId});
final int chatId;
static Future<void> show(
BuildContext context,
WidgetRef ref, {
required int chatId,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => ProviderScope(
parent: ProviderScope.containerOf(context),
child: ImagePickerSheet(chatId: chatId),
),
);
}
@override
ConsumerState<ImagePickerSheet> createState() => _ImagePickerSheetState();
}
class _ImagePickerSheetState extends ConsumerState<ImagePickerSheet> {
final _picker = ImagePicker();
final List<XFile> _selected = [];
bool _isSending = false;
Future<void> _pickFromCamera() async {
final file = await _picker.pickImage(
source: ImageSource.camera,
maxWidth: 1920,
maxHeight: 1920,
imageQuality: 85,
);
if (file != null && mounted) {
setState(() => _selected.add(file));
}
}
Future<void> _pickFromGallery() async {
final files = await _picker.pickMultiImage(
maxWidth: 1920,
maxHeight: 1920,
imageQuality: 85,
limit: 9,
);
if (files.isNotEmpty && mounted) {
// 最多 9 张
final room = 9 - _selected.length;
setState(() => _selected.addAll(files.take(room)));
}
}
Future<void> _cropImage(int index) async {
final file = _selected[index];
final cropped = await ImageCropper().cropImage(
sourcePath: file.path,
compressQuality: 85,
uiSettings: [
AndroidUiSettings(toolbarTitle: '裁剪图片'),
IOSUiSettings(title: '裁剪图片'),
],
);
if (cropped != null && mounted) {
setState(() => _selected[index] = XFile(cropped.path));
}
}
void _remove(int index) {
setState(() => _selected.removeAt(index));
}
Future<void> _sendAll() async {
if (_selected.isEmpty || _isSending) return;
setState(() => _isSending = true);
final useCase = ref.read(sendImageUseCaseProvider);
final errors = <String>[];
for (final file in List<XFile>.from(_selected)) {
try {
await useCase.execute(filePath: file.path, chatId: widget.chatId);
} catch (e) {
errors.add(e.toString());
}
}
if (mounted) {
Navigator.pop(context);
if (errors.isNotEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${errors.length} 张图片发送失败')),
);
}
}
}
@override
Widget build(BuildContext context) {
final hasImages = _selected.isNotEmpty;
return SafeArea(
child: Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ── 拖拽把手 ────────────────────────────────────────────────────────
const SizedBox(height: 8),
Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 12),
// ── 选图入口 ────────────────────────────────────────────────────────
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_SourceButton(
icon: Icons.camera_alt_outlined,
label: '拍照',
onTap: _pickFromCamera,
),
_SourceButton(
icon: Icons.photo_library_outlined,
label: '相册',
onTap: _pickFromGallery,
),
],
),
// ── 已选预览网格 ─────────────────────────────────────────────────────
if (hasImages) ...[
const Divider(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 6,
mainAxisSpacing: 6,
),
itemCount: _selected.length,
itemBuilder: (_, i) => _PreviewTile(
file: _selected[i],
onCrop: () => _cropImage(i),
onRemove: () => _remove(i),
),
),
),
],
// ── 发送按钮 ────────────────────────────────────────────────────────
if (hasImages) ...[
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _isSending ? null : _sendAll,
child: _isSending
? const SizedBox(
width: 20,
height: 20,
child:
CircularProgressIndicator(strokeWidth: 2),
)
: Text('发送 ${_selected.length}'),
),
),
),
],
const SizedBox(height: 16),
],
),
),
);
}
}
// ── 选图来源按钮 ───────────────────────────────────────────────────────────────
class _SourceButton extends StatelessWidget {
const _SourceButton({
required this.icon,
required this.label,
required this.onTap,
});
final IconData icon;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 32, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 6),
Text(label, style: const TextStyle(fontSize: 12)),
],
),
),
);
}
}
// ── 预览缩略图 Tile ────────────────────────────────────────────────────────────
class _PreviewTile extends StatelessWidget {
const _PreviewTile({
required this.file,
required this.onCrop,
required this.onRemove,
});
final XFile file;
final VoidCallback onCrop;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
// 缩略图
GestureDetector(
onTap: onCrop,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(
File(file.path),
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
color: Colors.grey.shade200,
child: const Icon(Icons.image, color: Colors.grey),
),
),
),
),
// 删除按钮
Positioned(
top: 2,
right: 2,
child: GestureDetector(
onTap: onRemove,
child: Container(
width: 20,
height: 20,
decoration: const BoxDecoration(
color: Colors.black54,
shape: BoxShape.circle,
),
child: const Icon(Icons.close, color: Colors.white, size: 14),
),
),
),
],
);
}
}