From b971900263f348bfeb7078989d3ecc98d675775f Mon Sep 17 00:00:00 2001 From: pp-bot Date: Tue, 24 Mar 2026 13:20:56 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9B=BE=E7=89=87=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E4=B8=8E=E5=8F=91=E9=80=81=E5=85=A8=E9=87=8F=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=20(#31~#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #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 --- Doc/image_preview_edit_architecture.md | 176 ++++++++++ .../chat/di/chat_service_providers.dart | 11 + .../chat/usecases/send_image_usecase.dart | 106 ++++++ .../features/chat/view/chat_detail_page.dart | 118 +++++-- .../features/chat/view/image_viewer_page.dart | 196 +++++++++++ .../view/widgets/image_message_bubble.dart | 182 +++++++++++ .../chat/view/widgets/image_picker_sheet.dart | 305 ++++++++++++++++++ apps/im_app/pubspec.yaml | 15 + 8 files changed, 1082 insertions(+), 27 deletions(-) create mode 100644 Doc/image_preview_edit_architecture.md create mode 100644 apps/im_app/lib/features/chat/usecases/send_image_usecase.dart create mode 100644 apps/im_app/lib/features/chat/view/image_viewer_page.dart create mode 100644 apps/im_app/lib/features/chat/view/widgets/image_message_bubble.dart create mode 100644 apps/im_app/lib/features/chat/view/widgets/image_picker_sheet.dart diff --git a/Doc/image_preview_edit_architecture.md b/Doc/image_preview_edit_architecture.md new file mode 100644 index 0000000..c58f851 --- /dev/null +++ b/Doc/image_preview_edit_architecture.md @@ -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 接入) diff --git a/apps/im_app/lib/features/chat/di/chat_service_providers.dart b/apps/im_app/lib/features/chat/di/chat_service_providers.dart index fc3bcef..e0177c1 100644 --- a/apps/im_app/lib/features/chat/di/chat_service_providers.dart +++ b/apps/im_app/lib/features/chat/di/chat_service_providers.dart @@ -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((ref) { messageRepo: ref.read(messageRepositoryProvider), ); }); + +// ── SendImageUseCase ────────────────────────────────────────────────────────── + +/// 图片上传并发送消息用例 Provider(#33) +final sendImageUseCaseProvider = Provider((ref) { + return SendImageUseCase( + apiClient: ref.read(networkSdkApiProvider), + sendMessage: ref.read(sendMessageUseCaseProvider), + ); +}); diff --git a/apps/im_app/lib/features/chat/usecases/send_image_usecase.dart b/apps/im_app/lib/features/chat/usecases/send_image_usecase.dart new file mode 100644 index 0000000..5da888e --- /dev/null +++ b/apps/im_app/lib/features/chat/usecases/send_image_usecase.dart @@ -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 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); + } +} diff --git a/apps/im_app/lib/features/chat/view/chat_detail_page.dart b/apps/im_app/lib/features/chat/view/chat_detail_page.dart index 75a6cb6..595b347 100644 --- a/apps/im_app/lib/features/chat/view/chat_detail_page.dart +++ b/apps/im_app/lib/features/chat/view/chat_detail_page.dart @@ -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 createState() => _ChatDetailPageState(); } @@ -50,7 +68,6 @@ class _ChatDetailPageState extends ConsumerState { 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 { } } + 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 { 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 { controller: _inputCtrl, isSending: state.isSending, onSend: _send, + onAttach: _showImagePicker, ), ], ), @@ -136,21 +157,30 @@ class _ChatDetailPageState extends ConsumerState { } } -// ── 消息气泡 ────────────────────────────────────────────────────────────────── +// ── 消息气泡路由(#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, diff --git a/apps/im_app/lib/features/chat/view/image_viewer_page.dart b/apps/im_app/lib/features/chat/view/image_viewer_page.dart new file mode 100644 index 0000000..bb2107f --- /dev/null +++ b/apps/im_app/lib/features/chat/view/image_viewer_page.dart @@ -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 urls; + final int initialIndex; + + /// 打开全屏图片查看页 + static void open( + BuildContext context, { + required List urls, + int initialIndex = 0, + }) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ImageViewerPage(urls: urls, initialIndex: initialIndex), + fullscreenDialog: true, + ), + ); + } + + @override + State createState() => _ImageViewerPageState(); +} + +class _ImageViewerPageState extends State { + late int _currentIndex; + bool _isSaving = false; + + @override + void initState() { + super.initState(); + _currentIndex = widget.initialIndex; + } + + String get _currentUrl => widget.urls[_currentIndex]; + + Future _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), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/im_app/lib/features/chat/view/widgets/image_message_bubble.dart b/apps/im_app/lib/features/chat/view/widgets/image_message_bubble.dart new file mode 100644 index 0000000..271e5be --- /dev/null +++ b/apps/im_app/lib/features/chat/view/widgets/image_message_bubble.dart @@ -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 _parse(String raw) { + try { + return jsonDecode(raw) as Map; + } catch (_) { + return {}; + } + } +} diff --git a/apps/im_app/lib/features/chat/view/widgets/image_picker_sheet.dart b/apps/im_app/lib/features/chat/view/widgets/image_picker_sheet.dart new file mode 100644 index 0000000..d9a6a5a --- /dev/null +++ b/apps/im_app/lib/features/chat/view/widgets/image_picker_sheet.dart @@ -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 show( + BuildContext context, + WidgetRef ref, { + required int chatId, + }) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => ProviderScope( + parent: ProviderScope.containerOf(context), + child: ImagePickerSheet(chatId: chatId), + ), + ); + } + + @override + ConsumerState createState() => _ImagePickerSheetState(); +} + +class _ImagePickerSheetState extends ConsumerState { + final _picker = ImagePicker(); + final List _selected = []; + bool _isSending = false; + + Future _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 _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 _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 _sendAll() async { + if (_selected.isEmpty || _isSending) return; + setState(() => _isSending = true); + + final useCase = ref.read(sendImageUseCaseProvider); + final errors = []; + + for (final file in List.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), + ), + ), + ), + ], + ); + } +} diff --git a/apps/im_app/pubspec.yaml b/apps/im_app/pubspec.yaml index 1a68eec..09900d5 100644 --- a/apps/im_app/pubspec.yaml +++ b/apps/im_app/pubspec.yaml @@ -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: