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:
176
Doc/image_preview_edit_architecture.md
Normal file
176
Doc/image_preview_edit_architecture.md
Normal 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→finish,Flutter 项目因后端已提供单步上传端点,使用更简单的 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 Bridge),UI 效果一致。
|
||||||
|
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 接入)
|
||||||
@@ -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/chat_provider.dart';
|
||||||
import 'package:im_app/features/chat/di/message_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/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';
|
import 'package:im_app/features/chat/usecases/send_message_use_case.dart';
|
||||||
|
|
||||||
/// ## DI 装配:Chat 服务层
|
/// ## DI 装配:Chat 服务层
|
||||||
@@ -64,3 +65,13 @@ final fetchHistoryUseCaseProvider = Provider<FetchHistoryUseCase>((ref) {
|
|||||||
messageRepo: ref.read(messageRepositoryProvider),
|
messageRepo: ref.read(messageRepositoryProvider),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── SendImageUseCase ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 图片上传并发送消息用例 Provider(#33)
|
||||||
|
final sendImageUseCaseProvider = Provider<SendImageUseCase>((ref) {
|
||||||
|
return SendImageUseCase(
|
||||||
|
apiClient: ref.read(networkSdkApiProvider),
|
||||||
|
sendMessage: ref.read(sendMessageUseCaseProvider),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
106
apps/im_app/lib/features/chat/usecases/send_image_usecase.dart
Normal file
106
apps/im_app/lib/features/chat/usecases/send_image_usecase.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/domain/entities/message.dart';
|
||||||
import 'package:im_app/features/chat/di/message_provider.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/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](会话名称)。
|
/// 接收 [conversationId](chatId 字符串)和 [title](会话名称)。
|
||||||
/// 通过 [ChatDetailViewModel] 监听 DB 消息 Stream,实时渲染气泡列表。
|
/// 通过 [ChatDetailViewModel] 监听 DB 消息 Stream,实时渲染气泡列表。
|
||||||
/// 底部输入框调用 [ChatDetailViewModel.sendMessage] 发送文本消息。
|
/// 底部输入框调用 [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 {
|
class ChatDetailPage extends ConsumerStatefulWidget {
|
||||||
const ChatDetailPage({
|
const ChatDetailPage({
|
||||||
super.key,
|
super.key,
|
||||||
required this.conversationId,
|
required this.conversationId,
|
||||||
required this.title,
|
required this.title,
|
||||||
|
this.chatType = 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String conversationId;
|
final String conversationId;
|
||||||
final String title;
|
final String title;
|
||||||
|
|
||||||
|
/// 1=单聊 2=群聊,用于小程序路由等场景
|
||||||
|
final int chatType;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<ChatDetailPage> createState() => _ChatDetailPageState();
|
ConsumerState<ChatDetailPage> createState() => _ChatDetailPageState();
|
||||||
}
|
}
|
||||||
@@ -50,7 +68,6 @@ class _ChatDetailPageState extends ConsumerState<ChatDetailPage> {
|
|||||||
ref
|
ref
|
||||||
.read(chatDetailViewModelProvider(_chatId).notifier)
|
.read(chatDetailViewModelProvider(_chatId).notifier)
|
||||||
.sendMessage(text);
|
.sendMessage(text);
|
||||||
// Scroll to bottom after a brief delay so the new message is rendered
|
|
||||||
Future.delayed(const Duration(milliseconds: 100), _scrollToBottom);
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final vm = ref.watch(chatDetailViewModelProvider(_chatId).notifier);
|
|
||||||
final state = ref.watch(chatDetailViewModelProvider(_chatId));
|
final state = ref.watch(chatDetailViewModelProvider(_chatId));
|
||||||
final messagesAsync = ref.watch(messagesByChatIdProvider(_chatId));
|
final messagesAsync = ref.watch(messagesByChatIdProvider(_chatId));
|
||||||
final currentUid = ref.watch(authNotifierProvider).currentUid ?? 0;
|
final currentUid = ref.watch(authNotifierProvider).currentUid ?? 0;
|
||||||
@@ -95,13 +115,13 @@ class _ChatDetailPageState extends ConsumerState<ChatDetailPage> {
|
|||||||
itemBuilder: (context, i) => _MessageBubble(
|
itemBuilder: (context, i) => _MessageBubble(
|
||||||
message: msgs[i],
|
message: msgs[i],
|
||||||
isMine: msgs[i].sendId == currentUid,
|
isMine: msgs[i].sendId == currentUid,
|
||||||
|
chatId: _chatId,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
loading: () =>
|
loading: () =>
|
||||||
const Center(child: CircularProgressIndicator()),
|
const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) =>
|
error: (e, _) => Center(child: Text('加载失败: $e')),
|
||||||
Center(child: Text('加载失败: $e')),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -129,6 +149,7 @@ class _ChatDetailPageState extends ConsumerState<ChatDetailPage> {
|
|||||||
controller: _inputCtrl,
|
controller: _inputCtrl,
|
||||||
isSending: state.isSending,
|
isSending: state.isSending,
|
||||||
onSend: _send,
|
onSend: _send,
|
||||||
|
onAttach: _showImagePicker,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -136,21 +157,30 @@ class _ChatDetailPageState extends ConsumerState<ChatDetailPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 消息气泡 ──────────────────────────────────────────────────────────────────
|
// ── 消息气泡路由(#35) ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class _MessageBubble extends StatelessWidget {
|
class _MessageBubble extends StatelessWidget {
|
||||||
const _MessageBubble({
|
const _MessageBubble({
|
||||||
required this.message,
|
required this.message,
|
||||||
required this.isMine,
|
required this.isMine,
|
||||||
|
required this.chatId,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Message message;
|
final Message message;
|
||||||
final bool isMine;
|
final bool isMine;
|
||||||
|
final int chatId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
final content = message.content ?? '';
|
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(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
@@ -173,8 +203,38 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
Flexible(
|
isMedia ? bubble : Flexible(child: bubble),
|
||||||
child: Container(
|
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),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isMine ? cs.primaryContainer : cs.surfaceContainerHighest,
|
color: isMine ? cs.primaryContainer : cs.surfaceContainerHighest,
|
||||||
@@ -191,27 +251,25 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
color: isMine ? cs.onPrimaryContainer : cs.onSurface,
|
color: isMine ? cs.onPrimaryContainer : cs.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
if (isMine) const SizedBox(width: 8),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 输入栏 ────────────────────────────────────────────────────────────────────
|
// ── 输入栏(含附件按钮) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
class _InputBar extends StatelessWidget {
|
class _InputBar extends StatelessWidget {
|
||||||
const _InputBar({
|
const _InputBar({
|
||||||
required this.controller,
|
required this.controller,
|
||||||
required this.isSending,
|
required this.isSending,
|
||||||
required this.onSend,
|
required this.onSend,
|
||||||
|
required this.onAttach,
|
||||||
});
|
});
|
||||||
|
|
||||||
final TextEditingController controller;
|
final TextEditingController controller;
|
||||||
final bool isSending;
|
final bool isSending;
|
||||||
final VoidCallback onSend;
|
final VoidCallback onSend;
|
||||||
|
final VoidCallback onAttach;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -220,6 +278,12 @@ class _InputBar extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
// 附件按钮(#33)
|
||||||
|
IconButton(
|
||||||
|
onPressed: onAttach,
|
||||||
|
icon: const Icon(Icons.add_circle_outline_rounded),
|
||||||
|
tooltip: '发送图片',
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
|
|||||||
196
apps/im_app/lib/features/chat/view/image_viewer_page.dart
Normal file
196
apps/im_app/lib/features/chat/view/image_viewer_page.dart
Normal 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-zoom(1x–5x),双击 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,6 +103,21 @@ dependencies:
|
|||||||
# 小程序 WebView(#25)
|
# 小程序 WebView(#25)
|
||||||
webview_flutter: ^4.8.0
|
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:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user