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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-28 04:32:16 -08:00
parent 9b467924a0
commit 5f28605e13
2 changed files with 181 additions and 3 deletions

View File

@ -1,7 +1,10 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.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 '../../../../core/theme/app_colors.dart';
import '../../domain/entities/chat_message.dart'; import '../../domain/entities/chat_message.dart';
import '../providers/chat_providers.dart'; import '../providers/chat_providers.dart';
@ -81,13 +84,25 @@ class _ChatPageState extends ConsumerState<ChatPage> {
ListTile( ListTile(
leading: const Icon(Icons.photo_library), leading: const Icon(Icons.photo_library),
title: const Text('从相册选择'), title: const Text('从相册选择'),
onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.gallery); }, subtitle: const Text('支持多选'),
onTap: () { Navigator.pop(ctx); _pickMultipleImages(); },
), ),
ListTile( ListTile(
leading: const Icon(Icons.camera_alt), leading: const Icon(Icons.camera_alt),
title: const Text('拍照'), title: const Text('拍照'),
onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.camera); }, 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<ChatPage> {
}); });
} }
Future<void> _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<void> _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<void> _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() { Widget _buildAttachmentPreview() {
return SizedBox( return SizedBox(
height: 80, height: 80,
@ -141,14 +295,36 @@ class _ChatPageState extends ConsumerState<ChatPage> {
itemCount: _pendingAttachments.length, itemCount: _pendingAttachments.length,
itemBuilder: (ctx, i) { itemBuilder: (ctx, i) {
final att = _pendingAttachments[i]; final att = _pendingAttachments[i];
final bytes = base64Decode(att.base64Data); final isImage = att.mediaType.startsWith('image/');
return Stack( return Stack(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(8), 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( Positioned(

View File

@ -37,6 +37,8 @@ dependencies:
gpt_markdown: ^1.1.5 gpt_markdown: ^1.1.5
flutter_svg: ^2.0.10+1 flutter_svg: ^2.0.10+1
image_picker: ^1.1.2 image_picker: ^1.1.2
super_clipboard: ^0.8.1
file_picker: ^8.0.0
# Voice # Voice
record: ^6.0.0 record: ^6.0.0