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