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,176 @@
# 图片预览与编辑架构文档
对应 Gitea issues #31#35
---
## 1. 背景与目标
iOS 老项目 (`ImageMessageBubble.swift`, `ImageGridBubble.swift`, `ImageProcessor.swift`) 已实现图片收发、全屏查看和编辑。Flutter 新项目目前 `_MessageBubble` 对所有消息类型均渲染为纯文本,需补齐以下能力:
| 功能 | Issue | iOS 对应 |
|------|-------|---------|
| typ=2 图片气泡展示 | #31 | `ImageMessageBubble.swift` |
| 全屏查看 + 保存 + 分享 | #32 | `ImageFullscreenView.swift` |
| 相册/相机选图 + CDN 上传 | #33 | `ChatView.swift photosPicker` |
| 图片编辑(裁剪/旋转) | #34 | `ImageProcessor.swift` + `TOCropViewController` |
| 消息气泡 BubbleKind 路由 | #35 | `ChatMessageBubble.swift switch` |
---
## 2. 消息格式
### typ=2 单张图片
```json
{"url":"Image/xxx.jpg","width":1024,"height":768}
```
- `url`CDN 相对路径,经 `CdnUrlResolver.resolve()` 转为完整 URL
- `width` / `height`:原始像素尺寸,用于计算显示比例
---
## 3. 架构层次
```
┌─────────────────────────────────────────────────────────────────┐
│ UI Layer │
│ ImagePickerSheet → (image_picker + image_cropper) │
│ ImageMessageBubble → ImageViewerPage (photo_view) │
│ _MessageBubble routing (typ switch → MediaBubble) │
├─────────────────────────────────────────────────────────────────┤
│ UseCase Layer │
│ SendImageUseCase │
│ ├─ readAsBytes() → dart:ui ImageDescriptor (width/height) │
│ ├─ UploadFileRequest (POST /app/api/upload/file) │
│ └─ SendMessageUseCase.execute(typ=2, content=JSON) │
├─────────────────────────────────────────────────────────────────┤
│ Data Layer │
│ UploadFileRequest → UploadResult.url (CDN path) │
│ CdnUrlResolver.resolve(path) → full URL │
└─────────────────────────────────────────────────────────────────┘
```
---
## 4. 关键设计决策
### 4.1 图片尺寸计算iOS 对齐)
| 参数 | 值 |
|------|---|
| 最长边上限 | 220 pt |
| 最短边下限 | 80 pt |
| 宽高比 | 保持原比例 |
| 圆角 | 12 pt |
```dart
// 伪码
final longest = max(w, h);
if (longest > 220) { w *= 220/longest; h *= 220/longest; }
final shortest = min(w, h);
if (shortest < 80) { w *= 80/shortest; h *= 80/shortest; }
```
### 4.2 图片压缩策略iOS ImageProcessor 对齐)
| 参数 | 值 |
|------|---|
| 最大边长 | 1920 px |
| JPEG 质量 | 85 |
| 方式 | `image_picker` 内置 `maxWidth/maxHeight/imageQuality` |
不引入 `flutter_image_compress`——`image_picker` 的内置参数已满足 iOS 等价逻辑。
### 4.3 尺寸解析
上传前使用 `dart:ui.ImageDescriptor.encoded(ImmutableBuffer)` 解析压缩后尺寸,零外部依赖。
### 4.4 CDN 上传(单步 FormData
沿用现有 `UploadFileRequest`FormData → `/app/api/upload/file`),返回 `UploadResult.url`CDN 相对路径)。
> 注意iOS 使用三步 presign→S3 PUT→finishFlutter 项目因后端已提供单步上传端点,使用更简单的 FormData 模式。
### 4.5 全屏查看器iOS ImageFullscreenView 对齐)
| iOS | Flutter |
|-----|---------|
| `UIScrollView` pinch-to-zoom | `photo_view` PhotoView |
| 双击 2.5x | `PhotoView.enableDoubleTapZoom` |
| 底部保存/分享 | `image_gallery_saver_plus` + `share_plus` |
| 多图滑动 | `PhotoViewGallery` |
### 4.6 图片编辑iOS TOCropViewController 对齐)
`image_cropper` 在 iOS 上也调用 `TOCropViewController`(通过 Plugin BridgeUI 效果一致。
Flutter 侧无需自建裁剪页,直接调用:
```dart
await ImageCropper().cropImage(sourcePath: ..., compressQuality: 85, ...)
```
---
## 5. 数据流:发送图片
```
用户点击附件图标
└─ ImagePickerSheet.show()
├─ 拍照: ImagePicker.pickImage(camera, maxW=1920, q=85)
└─ 相册: ImagePicker.pickMultiImage(maxW=1920, q=85, limit=9)
├─ (可选) _PreviewTile 点击 → ImageCropper.cropImage()
└─ "发送" → SendImageUseCase.execute(imageFile, chatId)
├─ readAsBytes() → Uint8List
├─ dart:ui.ImageDescriptor → (width, height)
├─ 写临时文件 → UploadFileRequest → UploadResult.url
├─ jsonEncode({"url":url,"width":w,"height":h})
└─ SendMessageUseCase(chatId, content, typ=2)
├─ 乐观写入 MessageRepository (DB Stream → UI)
└─ HTTP POST /app/api/chat/send-message
```
## 6. 数据流:接收/查看图片
```
WS / HTTP 历史 → DB → MessageRepository Stream
└─ messagesByChatIdProvider(chatId)
└─ ListView → _MessageBubble(message)
└─ message.typ == 2
└─ ImageMessageBubble(rawContent)
├─ parse JSON → url, width, height
├─ CdnUrlResolver.resolve(url) → fullUrl
├─ Image.network(fullUrl, fit: cover)
└─ GestureTap → ImageViewerPage(urls: [fullUrl])
└─ PhotoView pinch-to-zoom
├─ 保存 → ImageGallerySaverPlus.saveNetworkImage(url)
└─ 分享 → Share.share(url)
```
---
## 7. 新增文件清单
| 文件 | 说明 |
|------|------|
| `lib/features/chat/view/widgets/image_message_bubble.dart` | typ=2 气泡(#31 |
| `lib/features/chat/view/image_viewer_page.dart` | 全屏查看(#32 |
| `lib/features/chat/view/widgets/image_picker_sheet.dart` | 选图底部弹窗(#33 |
| `lib/features/chat/usecases/send_image_usecase.dart` | 上传+发送(#33 |
## 8. 修改文件清单
| 文件 | 修改内容 |
|------|---------|
| `pubspec.yaml` | 新增 photo_view / image_picker / image_cropper / image_gallery_saver_plus / share_plus |
| `lib/features/chat/di/chat_service_providers.dart` | 新增 sendImageUseCaseProvider |
| `lib/features/chat/view/chat_detail_page.dart` | 输入栏附件按钮 + _MessageBubble typ 路由(#35 |
---
## 9. 待完成
- 多图网格气泡 `ImageGridBubble`(微信 2-9 图 layout待 typ 确认)
- 图片 sticker (typ=5) 展示
- GIF 消息 (typ=25) 支持
- 端到端加密图片(`MediaCrypto` decrypt待 cipher_guard_sdk 接入)

View File

@@ -6,6 +6,7 @@ import 'package:im_app/core/services/ws_message_service.dart';
import 'package:im_app/features/chat/di/chat_provider.dart';
import 'package:im_app/features/chat/di/message_provider.dart';
import 'package:im_app/features/chat/usecases/fetch_history_use_case.dart';
import 'package:im_app/features/chat/usecases/send_image_usecase.dart';
import 'package:im_app/features/chat/usecases/send_message_use_case.dart';
/// ## DI 装配Chat 服务层
@@ -64,3 +65,13 @@ final fetchHistoryUseCaseProvider = Provider<FetchHistoryUseCase>((ref) {
messageRepo: ref.read(messageRepositoryProvider),
);
});
// ── SendImageUseCase ──────────────────────────────────────────────────────────
/// 图片上传并发送消息用例 Provider#33
final sendImageUseCaseProvider = Provider<SendImageUseCase>((ref) {
return SendImageUseCase(
apiClient: ref.read(networkSdkApiProvider),
sendMessage: ref.read(sendMessageUseCaseProvider),
);
});

View File

@@ -0,0 +1,106 @@
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
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
///
/// 对应 iOS `ImageProcessor.swift` + `ChatView.swift sendImageMessage()`
///
/// ## 执行流程
/// 1. 读取图片字节 → [Uint8List]
/// 2. `dart:ui.ImageDescriptor.encoded()` → 解析压缩后宽高(零外部依赖)
/// 3. 写入临时文件 → [UploadFileRequest] FormData → [UploadResult.url]
/// 4. `jsonEncode({"url":url,"width":w,"height":h})` → [SendMessageUseCase] typ=2
/// 5. 删除临时文件
///
/// 发送中进度通过 [onProgress] 回调传出0.0~1.0
/// 用于 [ImageMessageBubble] 渲染进度环。
class SendImageUseCase {
final NetworksSdkApi _apiClient;
final SendMessageUseCase _sendMessage;
SendImageUseCase({
required NetworksSdkApi apiClient,
required SendMessageUseCase sendMessage,
}) : _apiClient = apiClient,
_sendMessage = sendMessage;
Future<void> execute({
required String filePath,
required int chatId,
int chatType = 1,
void Function(double)? onProgress,
}) async {
// 1. 读取字节
final bytes = await File(filePath).readAsBytes();
// 2. 解析宽高
final (width, height) = await _resolveSize(bytes);
// 3. 写临时文件image_picker 有时返回没有扩展名的路径,确保临时文件有扩展名)
final ext = _extOf(filePath);
final tempFile = File(
'${Directory.systemTemp.path}/upload_${DateTime.now().millisecondsSinceEpoch}$ext',
);
await tempFile.writeAsBytes(bytes);
onProgress?.call(0.1);
// 4. 上传
String uploadedUrl = '';
try {
final result = await _apiClient.executeRequest(
UploadFileRequest(filePath: tempFile.path),
);
uploadedUrl = result?.url ?? '';
} catch (e) {
debugPrint('[SendImageUseCase] upload error: $e');
rethrow;
} finally {
// 5. 删临时文件(无论成败)
try {
await tempFile.delete();
} catch (_) {}
}
if (uploadedUrl.isEmpty) {
throw Exception('[SendImageUseCase] upload returned empty url');
}
onProgress?.call(0.9);
// 6. 发送 typ=2 消息
final content = '{"url":"$uploadedUrl","width":$width,"height":$height}';
await _sendMessage.execute(chatId: chatId, content: content, typ: 2);
onProgress?.call(1.0);
}
/// 使用 [dart:ui.ImageDescriptor] 解析字节宽高(零额外依赖)
Future<(int, int)> _resolveSize(Uint8List bytes) async {
try {
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
final descriptor = await ui.ImageDescriptor.encoded(buffer);
final w = descriptor.width;
final h = descriptor.height;
descriptor.dispose();
buffer.dispose();
return (w, h);
} catch (e) {
debugPrint('[SendImageUseCase] resolveSize error: $e');
return (0, 0);
}
}
String _extOf(String path) {
final dot = path.lastIndexOf('.');
if (dot == -1 || dot == path.length - 1) return '.jpg';
return path.substring(dot);
}
}

View File

@@ -5,22 +5,40 @@ import 'package:im_app/app/di/app_providers.dart';
import 'package:im_app/domain/entities/message.dart';
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_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
///
/// 接收 [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]
class ChatDetailPage extends ConsumerStatefulWidget {
const ChatDetailPage({
super.key,
required this.conversationId,
required this.title,
this.chatType = 1,
});
final String conversationId;
final String title;
/// 1=单聊 2=群聊,用于小程序路由等场景
final int chatType;
@override
ConsumerState<ChatDetailPage> createState() => _ChatDetailPageState();
}
@@ -50,7 +68,6 @@ class _ChatDetailPageState extends ConsumerState<ChatDetailPage> {
ref
.read(chatDetailViewModelProvider(_chatId).notifier)
.sendMessage(text);
// Scroll to bottom after a brief delay so the new message is rendered
Future.delayed(const Duration(milliseconds: 100), _scrollToBottom);
}
@@ -64,9 +81,12 @@ class _ChatDetailPageState extends ConsumerState<ChatDetailPage> {
}
}
void _showImagePicker() {
ImagePickerSheet.show(context, ref, chatId: _chatId);
}
@override
Widget build(BuildContext context) {
final vm = ref.watch(chatDetailViewModelProvider(_chatId).notifier);
final state = ref.watch(chatDetailViewModelProvider(_chatId));
final messagesAsync = ref.watch(messagesByChatIdProvider(_chatId));
final currentUid = ref.watch(authNotifierProvider).currentUid ?? 0;
@@ -95,13 +115,13 @@ class _ChatDetailPageState extends ConsumerState<ChatDetailPage> {
itemBuilder: (context, i) => _MessageBubble(
message: msgs[i],
isMine: msgs[i].sendId == currentUid,
chatId: _chatId,
),
);
},
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (e, _) =>
Center(child: Text('加载失败: $e')),
error: (e, _) => Center(child: Text('加载失败: $e')),
),
),
@@ -129,6 +149,7 @@ class _ChatDetailPageState extends ConsumerState<ChatDetailPage> {
controller: _inputCtrl,
isSending: state.isSending,
onSend: _send,
onAttach: _showImagePicker,
),
],
),
@@ -136,21 +157,30 @@ class _ChatDetailPageState extends ConsumerState<ChatDetailPage> {
}
}
// ── 消息气泡 ──────────────────────────────────────────────────────────────────
// ── 消息气泡路由(#35 ────────────────────────────────────────────────────────
class _MessageBubble extends StatelessWidget {
const _MessageBubble({
required this.message,
required this.isMine,
required this.chatId,
});
final Message message;
final bool isMine;
final int chatId;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
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);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
@@ -173,45 +203,73 @@ class _MessageBubble extends StatelessWidget {
),
const SizedBox(width: 8),
],
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isMine ? cs.primaryContainer : cs.surfaceContainerHighest,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: Radius.circular(isMine ? 16 : 4),
bottomRight: Radius.circular(isMine ? 4 : 16),
),
),
child: Text(
content,
style: TextStyle(
color: isMine ? cs.onPrimaryContainer : cs.onSurface,
),
),
),
),
isMedia ? bubble : Flexible(child: bubble),
if (isMine) const SizedBox(width: 8),
],
),
);
}
Widget _buildBubble(
BuildContext context,
ColorScheme cs,
String content,
int typ,
bool isMedia,
) {
switch (typ) {
case 2:
return ImageMessageBubble(rawContent: content);
case 3:
return AudioMessageBubble(rawContent: content);
case 4:
case 24:
return VideoMessageBubble(rawContent: content);
case 6:
return FileMessageBubble(rawContent: content);
case 8:
return RedEnvelopeBubble(
messageId: message.id,
rawContent: content,
chatId: chatId,
);
default:
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isMine ? cs.primaryContainer : cs.surfaceContainerHighest,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: Radius.circular(isMine ? 16 : 4),
bottomRight: Radius.circular(isMine ? 4 : 16),
),
),
child: Text(
content,
style: TextStyle(
color: isMine ? cs.onPrimaryContainer : cs.onSurface,
),
),
);
}
}
}
// ── 输入栏 ────────────────────────────────────────────────────────────────────
// ── 输入栏(含附件按钮) ──────────────────────────────────────────────────────
class _InputBar extends StatelessWidget {
const _InputBar({
required this.controller,
required this.isSending,
required this.onSend,
required this.onAttach,
});
final TextEditingController controller;
final bool isSending;
final VoidCallback onSend;
final VoidCallback onAttach;
@override
Widget build(BuildContext context) {
@@ -220,6 +278,12 @@ class _InputBar extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
children: [
// 附件按钮(#33
IconButton(
onPressed: onAttach,
icon: const Icon(Icons.add_circle_outline_rounded),
tooltip: '发送图片',
),
Expanded(
child: TextField(
controller: controller,

View File

@@ -0,0 +1,196 @@
import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart';
import 'package:image_gallery_saver_plus/image_gallery_saver_plus.dart';
import 'package:share_plus/share_plus.dart';
/// 全屏图片查看页(#32
///
/// 对应 iOS `ImageFullscreenView.swift`
///
/// 支持:
/// - 单图 / 多图滑动([PhotoViewGallery]
/// - Pinch-to-zoom1x5x双击 2.5x
/// - 底部工具栏:保存到相册 + 分享
///
/// ## 使用
///
/// ```dart
/// ImageViewerPage.open(context, urls: [url1, url2], initialIndex: 0);
/// ```
class ImageViewerPage extends StatefulWidget {
const ImageViewerPage({
super.key,
required this.urls,
this.initialIndex = 0,
});
final List<String> urls;
final int initialIndex;
/// 打开全屏图片查看页
static void open(
BuildContext context, {
required List<String> urls,
int initialIndex = 0,
}) {
Navigator.push(
context,
MaterialPageRoute<void>(
builder: (_) => ImageViewerPage(urls: urls, initialIndex: initialIndex),
fullscreenDialog: true,
),
);
}
@override
State<ImageViewerPage> createState() => _ImageViewerPageState();
}
class _ImageViewerPageState extends State<ImageViewerPage> {
late int _currentIndex;
bool _isSaving = false;
@override
void initState() {
super.initState();
_currentIndex = widget.initialIndex;
}
String get _currentUrl => widget.urls[_currentIndex];
Future<void> _saveToGallery() async {
if (_isSaving) return;
setState(() => _isSaving = true);
try {
final result = await ImageGallerySaverPlus.saveNetworkImage(_currentUrl);
final success = result['isSuccess'] as bool? ?? false;
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(success ? '已保存到相册' : '保存失败')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('保存失败')));
}
} finally {
if (mounted) setState(() => _isSaving = false);
}
}
void _share() {
Share.share(_currentUrl);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
extendBodyBehindAppBar: true,
appBar: AppBar(
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
elevation: 0,
title: widget.urls.length > 1
? Text('${_currentIndex + 1} / ${widget.urls.length}')
: null,
),
body: Stack(
children: [
// ── 图片区域 ──────────────────────────────────────────────────────────
widget.urls.length == 1
? PhotoView(
imageProvider: NetworkImage(widget.urls.first),
minScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.covered * 5,
initialScale: PhotoViewComputedScale.contained,
enableDoubleTapZoom: true,
backgroundDecoration:
const BoxDecoration(color: Colors.black),
errorBuilder: (_, __, ___) => const Center(
child: Icon(
Icons.broken_image_outlined,
color: Colors.white54,
size: 48,
),
),
)
: PhotoViewGallery.builder(
itemCount: widget.urls.length,
pageController:
PageController(initialPage: widget.initialIndex),
onPageChanged: (i) => setState(() => _currentIndex = i),
backgroundDecoration:
const BoxDecoration(color: Colors.black),
builder: (_, i) => PhotoViewGalleryPageOptions(
imageProvider: NetworkImage(widget.urls[i]),
minScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.covered * 5,
initialScale: PhotoViewComputedScale.contained,
errorBuilder: (_, __, ___) => const Center(
child: Icon(
Icons.broken_image_outlined,
color: Colors.white54,
size: 48,
),
),
),
),
// ── 底部工具栏 ────────────────────────────────────────────────────────
Positioned(
left: 0,
right: 0,
bottom: 0,
child: SafeArea(
child: Container(
color: Colors.black54,
padding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
// 保存
TextButton.icon(
onPressed: _isSaving ? null : _saveToGallery,
icon: _isSaving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
color: Colors.white, strokeWidth: 2),
)
: const Icon(
Icons.download_rounded,
color: Colors.white,
),
label: const Text(
'保存',
style: TextStyle(color: Colors.white),
),
),
// 分享
TextButton.icon(
onPressed: _share,
icon: const Icon(
Icons.share_rounded,
color: Colors.white,
),
label: const Text(
'分享',
style: TextStyle(color: Colors.white),
),
),
],
),
),
),
),
],
),
);
}
}

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),
),
),
),
],
);
}
}

View File

@@ -103,6 +103,21 @@ dependencies:
# 小程序 WebView#25
webview_flutter: ^4.8.0
# 图片预览 — 全屏 pinch-to-zoom#32
photo_view: ^0.15.0
# 图片选取 — 相册/相机(#33
image_picker: ^1.1.2
# 图片编辑 — 裁剪/旋转(#34
image_cropper: ^5.0.1
# 图片保存到相册(#32
image_gallery_saver_plus: ^3.0.5
# 分享(#32
share_plus: ^10.0.0
dev_dependencies:
flutter_test: