From 5f28605e135c8fb9633f34674f74dd1aeb5454b6 Mon Sep 17 00:00:00 2001 From: hailin Date: Sat, 28 Feb 2026 04:32:16 -0800 Subject: [PATCH] feat: add clipboard paste, multi-image select, and file picker - Add super_clipboard and file_picker dependencies - Clipboard paste: reads PNG/JPEG image data from system clipboard - Multi-image: pickMultiImage with remaining count limit - File picker: supports images (jpg/png/gif/webp) and PDF files - Updated attachment preview to show file icon for non-image types - Bottom sheet now shows 4 options: gallery, camera, clipboard, file Co-Authored-By: Claude Opus 4.6 --- .../chat/presentation/pages/chat_page.dart | 182 +++++++++++++++++- it0_app/pubspec.yaml | 2 + 2 files changed, 181 insertions(+), 3 deletions(-) diff --git a/it0_app/lib/features/chat/presentation/pages/chat_page.dart b/it0_app/lib/features/chat/presentation/pages/chat_page.dart index 11402e4..21b5c22 100644 --- a/it0_app/lib/features/chat/presentation/pages/chat_page.dart +++ b/it0_app/lib/features/chat/presentation/pages/chat_page.dart @@ -1,7 +1,10 @@ import 'dart:convert'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:super_clipboard/super_clipboard.dart'; +import 'package:file_picker/file_picker.dart'; import '../../../../core/theme/app_colors.dart'; import '../../domain/entities/chat_message.dart'; import '../providers/chat_providers.dart'; @@ -81,13 +84,25 @@ class _ChatPageState extends ConsumerState { ListTile( leading: const Icon(Icons.photo_library), title: const Text('从相册选择'), - onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.gallery); }, + subtitle: const Text('支持多选'), + onTap: () { Navigator.pop(ctx); _pickMultipleImages(); }, ), ListTile( leading: const Icon(Icons.camera_alt), title: const Text('拍照'), onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.camera); }, ), + ListTile( + leading: const Icon(Icons.content_paste), + title: const Text('从剪贴板粘贴'), + onTap: () { Navigator.pop(ctx); _pasteFromClipboard(); }, + ), + ListTile( + leading: const Icon(Icons.attach_file), + title: const Text('选择文件'), + subtitle: const Text('图片、PDF'), + onTap: () { Navigator.pop(ctx); _pickFile(); }, + ), ], ), ), @@ -133,6 +148,145 @@ class _ChatPageState extends ConsumerState { }); } + Future _pickMultipleImages() async { + final remaining = _maxAttachments - _pendingAttachments.length; + if (remaining <= 0) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('最多添加 $_maxAttachments 张图片')), + ); + } + return; + } + + final picker = ImagePicker(); + final pickedList = await picker.pickMultiImage( + maxWidth: 1568, + maxHeight: 1568, + imageQuality: 85, + ); + if (pickedList.isEmpty) return; + + final toAdd = pickedList.take(remaining); + for (final picked in toAdd) { + final bytes = await picked.readAsBytes(); + final ext = picked.path.split('.').last.toLowerCase(); + final mediaType = switch (ext) { + 'png' => 'image/png', + 'webp' => 'image/webp', + 'gif' => 'image/gif', + _ => 'image/jpeg', + }; + _pendingAttachments.add(ChatAttachment( + base64Data: base64Encode(bytes), + mediaType: mediaType, + fileName: picked.name, + )); + } + setState(() {}); + + if (pickedList.length > remaining && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('已选择 $remaining 张,最多 $_maxAttachments 张')), + ); + } + } + + Future _pasteFromClipboard() async { + final clipboard = SystemClipboard.instance; + if (clipboard == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('当前设备不支持剪贴板读取')), + ); + } + return; + } + + try { + final reader = await clipboard.read(); + for (final item in reader.items) { + // Try PNG first, then JPEG + for (final format in [Formats.png, Formats.jpeg]) { + if (item.canProvide(format)) { + final completer = item.getFile(format); + if (completer != null) { + final file = await completer; + final bytes = await file.readAll(); + final mediaType = format == Formats.png ? 'image/png' : 'image/jpeg'; + setState(() { + _pendingAttachments.add(ChatAttachment( + base64Data: base64Encode(bytes), + mediaType: mediaType, + fileName: 'clipboard.${format == Formats.png ? "png" : "jpg"}', + )); + }); + return; + } + } + } + } + } catch (e) { + // Clipboard read failed + } + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('剪贴板中没有图片')), + ); + } + } + + Future _pickFile() async { + final remaining = _maxAttachments - _pendingAttachments.length; + if (remaining <= 0) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('最多添加 $_maxAttachments 个附件')), + ); + } + return; + } + + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf'], + allowMultiple: true, + ); + if (result == null || result.files.isEmpty) return; + + final toAdd = result.files.take(remaining); + for (final file in toAdd) { + if (file.path == null) continue; + final bytes = await File(file.path!).readAsBytes(); + final ext = (file.extension ?? '').toLowerCase(); + final String mediaType; + if (ext == 'pdf') { + mediaType = 'application/pdf'; + } else { + mediaType = switch (ext) { + 'png' => 'image/png', + 'webp' => 'image/webp', + 'gif' => 'image/gif', + _ => 'image/jpeg', + }; + } + + _pendingAttachments.add(ChatAttachment( + base64Data: base64Encode(bytes), + mediaType: mediaType, + fileName: file.name, + )); + } + setState(() {}); + + if (result.files.length > remaining && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('已选择 $remaining 个,最多 $_maxAttachments 个')), + ); + } + } + Widget _buildAttachmentPreview() { return SizedBox( height: 80, @@ -141,14 +295,36 @@ class _ChatPageState extends ConsumerState { itemCount: _pendingAttachments.length, itemBuilder: (ctx, i) { final att = _pendingAttachments[i]; - final bytes = base64Decode(att.base64Data); + final isImage = att.mediaType.startsWith('image/'); return Stack( children: [ Padding( padding: const EdgeInsets.all(4), child: ClipRRect( borderRadius: BorderRadius.circular(8), - child: Image.memory(bytes, width: 72, height: 72, fit: BoxFit.cover, cacheWidth: 144, cacheHeight: 144), + child: isImage + ? Image.memory( + base64Decode(att.base64Data), + width: 72, height: 72, + fit: BoxFit.cover, + cacheWidth: 144, cacheHeight: 144, + ) + : Container( + width: 72, height: 72, + color: AppColors.surfaceLight, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.description, size: 28, color: AppColors.textSecondary), + const SizedBox(height: 2), + Text( + att.fileName?.split('.').last.toUpperCase() ?? 'FILE', + style: const TextStyle(fontSize: 10, color: AppColors.textMuted), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), ), ), Positioned( diff --git a/it0_app/pubspec.yaml b/it0_app/pubspec.yaml index bf0f4f2..8f76190 100644 --- a/it0_app/pubspec.yaml +++ b/it0_app/pubspec.yaml @@ -37,6 +37,8 @@ dependencies: gpt_markdown: ^1.1.5 flutter_svg: ^2.0.10+1 image_picker: ^1.1.2 + super_clipboard: ^0.8.1 + file_picker: ^8.0.0 # Voice record: ^6.0.0