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:
parent
9b467924a0
commit
5f28605e13
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue