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:
@@ -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 {};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user