refactor: Enhance file attachment handling and UI components
- Updated the file attachment service to utilize a new LocalAttachment class, improving the management of file metadata such as display names. - Refactored methods for picking and uploading files to accommodate the new LocalAttachment structure, ensuring consistent handling of file attributes. - Improved the chat page to validate and manage file attachments more effectively, enhancing user experience during file uploads. - Added functionality for image previews in the file attachment widget, allowing users to see selected images before sending. - Introduced a remove button for attachments, improving usability by enabling users to easily discard unwanted files.
This commit is contained in:
@@ -175,21 +175,26 @@ Future<void> _processPayload(Ref ref, SharedPayload payload) async {
|
||||
final svc = ref.read(fileAttachmentServiceProvider);
|
||||
if (svc != null) {
|
||||
// Add files to attachment list and kick off uploads, mirroring UI flow
|
||||
final files = payload.filePaths.map((p) => File(p)).toList();
|
||||
if (files.isNotEmpty) {
|
||||
ref.read(attachedFilesProvider.notifier).addFiles(files);
|
||||
final attachments = payload.filePaths
|
||||
.map(
|
||||
(p) =>
|
||||
LocalAttachment(file: File(p), displayName: path.basename(p)),
|
||||
)
|
||||
.toList();
|
||||
if (attachments.isNotEmpty) {
|
||||
ref.read(attachedFilesProvider.notifier).addFiles(attachments);
|
||||
|
||||
// Enqueue uploads via task queue to unify progress + retry
|
||||
final activeConv = ref.read(activeConversationProvider);
|
||||
for (final file in files) {
|
||||
for (final attachment in attachments) {
|
||||
try {
|
||||
await ref
|
||||
.read(taskQueueProvider.notifier)
|
||||
.enqueueUploadMedia(
|
||||
conversationId: activeConv?.id,
|
||||
filePath: file.path,
|
||||
fileName: path.basename(file.path),
|
||||
fileSize: await file.length(),
|
||||
filePath: attachment.file.path,
|
||||
fileName: attachment.displayName,
|
||||
fileSize: await attachment.file.length(),
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,62 @@ import '../../../core/services/api_service.dart';
|
||||
import '../../../core/providers/app_providers.dart';
|
||||
import '../../../core/utils/debug_logger.dart';
|
||||
|
||||
String _deriveDisplayName({
|
||||
required String? preferredName,
|
||||
required String filePath,
|
||||
String fallbackPrefix = 'attachment',
|
||||
}) {
|
||||
final String candidate =
|
||||
(preferredName != null && preferredName.trim().isNotEmpty)
|
||||
? preferredName.trim()
|
||||
: path.basename(filePath);
|
||||
|
||||
final String pathExt = path.extension(filePath);
|
||||
final String candidateExt = path.extension(candidate);
|
||||
final String extension = (candidateExt.isNotEmpty ? candidateExt : pathExt)
|
||||
.toLowerCase();
|
||||
|
||||
if (candidate.toLowerCase().startsWith('image_picker')) {
|
||||
return _timestampedName(prefix: fallbackPrefix, extension: extension);
|
||||
}
|
||||
|
||||
if (candidate.isEmpty) {
|
||||
return _timestampedName(prefix: fallbackPrefix, extension: extension);
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
String _timestampedName({required String prefix, required String extension}) {
|
||||
final DateTime now = DateTime.now();
|
||||
String two(int value) => value.toString().padLeft(2, '0');
|
||||
final String ext = extension.isNotEmpty ? extension : '.jpg';
|
||||
final String timestamp =
|
||||
'${now.year}${two(now.month)}${two(now.day)}_${two(now.hour)}${two(now.minute)}${two(now.second)}';
|
||||
return '${prefix}_$timestamp$ext';
|
||||
}
|
||||
|
||||
/// Represents a locally selected attachment with a user-facing display name.
|
||||
class LocalAttachment {
|
||||
LocalAttachment({required this.file, required this.displayName});
|
||||
|
||||
final File file;
|
||||
final String displayName;
|
||||
|
||||
int get sizeInBytes => file.lengthSync();
|
||||
|
||||
String get extension {
|
||||
final fromName = path.extension(displayName);
|
||||
if (fromName.isNotEmpty) {
|
||||
return fromName.toLowerCase();
|
||||
}
|
||||
return path.extension(file.path).toLowerCase();
|
||||
}
|
||||
|
||||
bool get isImage =>
|
||||
<String>{'.jpg', '.jpeg', '.png', '.gif', '.webp'}.contains(extension);
|
||||
}
|
||||
|
||||
class FileAttachmentService {
|
||||
final ApiService _apiService;
|
||||
final ImagePicker _imagePicker = ImagePicker();
|
||||
@@ -17,7 +73,7 @@ class FileAttachmentService {
|
||||
FileAttachmentService(this._apiService);
|
||||
|
||||
// Pick files from device
|
||||
Future<List<File>> pickFiles({
|
||||
Future<List<LocalAttachment>> pickFiles({
|
||||
bool allowMultiple = true,
|
||||
List<String>? allowedExtensions,
|
||||
}) async {
|
||||
@@ -32,17 +88,51 @@ class FileAttachmentService {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.files
|
||||
.where((file) => file.path != null)
|
||||
.map((file) => File(file.path!))
|
||||
.toList();
|
||||
return result.files.where((file) => file.path != null).map((file) {
|
||||
final displayName = _deriveDisplayName(
|
||||
preferredName: file.name,
|
||||
filePath: file.path!,
|
||||
fallbackPrefix: 'attachment',
|
||||
);
|
||||
return LocalAttachment(
|
||||
file: File(file.path!),
|
||||
displayName: displayName,
|
||||
);
|
||||
}).toList();
|
||||
} catch (e) {
|
||||
throw Exception('Failed to pick files: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Pick image from gallery
|
||||
Future<File?> pickImage() async {
|
||||
Future<LocalAttachment?> pickImage() async {
|
||||
try {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
allowMultiple: false,
|
||||
type: FileType.image,
|
||||
);
|
||||
|
||||
if (result != null && result.files.isNotEmpty) {
|
||||
final platformFile = result.files.first;
|
||||
if (platformFile.path != null) {
|
||||
final displayName = _deriveDisplayName(
|
||||
preferredName: platformFile.name,
|
||||
filePath: platformFile.path!,
|
||||
fallbackPrefix: 'photo',
|
||||
);
|
||||
return LocalAttachment(
|
||||
file: File(platformFile.path!),
|
||||
displayName: displayName,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
DebugLogger.log(
|
||||
'FilePicker image failed: $e',
|
||||
scope: 'attachments/image',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final XFile? image = await _imagePicker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
@@ -50,14 +140,20 @@ class FileAttachmentService {
|
||||
);
|
||||
|
||||
if (image == null) return null;
|
||||
return File(image.path);
|
||||
final file = File(image.path);
|
||||
final displayName = _deriveDisplayName(
|
||||
preferredName: image.name,
|
||||
filePath: image.path,
|
||||
fallbackPrefix: 'photo',
|
||||
);
|
||||
return LocalAttachment(file: file, displayName: displayName);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to pick image: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Take photo from camera
|
||||
Future<File?> takePhoto() async {
|
||||
Future<LocalAttachment?> takePhoto() async {
|
||||
try {
|
||||
final XFile? photo = await _imagePicker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
@@ -65,7 +161,13 @@ class FileAttachmentService {
|
||||
);
|
||||
|
||||
if (photo == null) return null;
|
||||
return File(photo.path);
|
||||
final file = File(photo.path);
|
||||
final displayName = _deriveDisplayName(
|
||||
preferredName: photo.name,
|
||||
filePath: photo.path,
|
||||
fallbackPrefix: 'photo',
|
||||
);
|
||||
return LocalAttachment(file: file, displayName: displayName);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to take photo: $e');
|
||||
}
|
||||
@@ -199,15 +301,21 @@ class FileAttachmentService {
|
||||
}
|
||||
|
||||
// Upload file with progress tracking
|
||||
Stream<FileUploadState> uploadFile(File file) async* {
|
||||
Stream<FileUploadState> uploadFile(LocalAttachment attachment) async* {
|
||||
DebugLogger.log(
|
||||
'upload-start',
|
||||
scope: 'attachments/file',
|
||||
data: {'path': file.path},
|
||||
data: {
|
||||
'path': attachment.file.path,
|
||||
'displayName': attachment.displayName,
|
||||
},
|
||||
);
|
||||
try {
|
||||
final fileName = path.basename(file.path);
|
||||
final file = attachment.file;
|
||||
final fileName = attachment.displayName;
|
||||
final fileSize = await file.length();
|
||||
final ext = path.extension(fileName).toLowerCase();
|
||||
final isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].contains(ext);
|
||||
|
||||
DebugLogger.log(
|
||||
'file-details',
|
||||
@@ -221,18 +329,9 @@ class FileAttachmentService {
|
||||
fileSize: fileSize,
|
||||
progress: 0.0,
|
||||
status: FileUploadStatus.uploading,
|
||||
isImage: isImage,
|
||||
);
|
||||
|
||||
// Check if this is an image file
|
||||
final ext = path.extension(fileName).toLowerCase();
|
||||
final isImage = [
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'gif',
|
||||
'webp',
|
||||
].contains(ext.substring(1));
|
||||
|
||||
// Upload ALL files (including images) to server for consistency with web client
|
||||
DebugLogger.log('upload-progress', scope: 'attachments/file');
|
||||
final fileId = await _apiService.uploadFile(file.path, fileName);
|
||||
@@ -253,8 +352,11 @@ class FileAttachmentService {
|
||||
);
|
||||
} catch (e) {
|
||||
DebugLogger.error('upload-failed', scope: 'attachments/file', error: e);
|
||||
final fileName = path.basename(file.path);
|
||||
final file = attachment.file;
|
||||
final fileName = attachment.displayName;
|
||||
final fileSize = await file.length();
|
||||
final ext = path.extension(fileName).toLowerCase();
|
||||
final isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].contains(ext);
|
||||
|
||||
yield FileUploadState(
|
||||
file: file,
|
||||
@@ -263,18 +365,21 @@ class FileAttachmentService {
|
||||
progress: 0.0,
|
||||
status: FileUploadStatus.failed,
|
||||
error: e.toString(),
|
||||
isImage: isImage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Upload multiple files
|
||||
Stream<List<FileUploadState>> uploadMultipleFiles(List<File> files) async* {
|
||||
Stream<List<FileUploadState>> uploadMultipleFiles(
|
||||
List<LocalAttachment> attachments,
|
||||
) async* {
|
||||
final states = <String, FileUploadState>{};
|
||||
|
||||
for (final file in files) {
|
||||
final uploadStream = uploadFile(file);
|
||||
for (final attachment in attachments) {
|
||||
final uploadStream = uploadFile(attachment);
|
||||
await for (final state in uploadStream) {
|
||||
states[file.path] = state;
|
||||
states[attachment.file.path] = state;
|
||||
yield states.values.toList();
|
||||
}
|
||||
}
|
||||
@@ -386,8 +491,7 @@ enum FileUploadStatus { pending, uploading, completed, failed }
|
||||
class MockFileAttachmentService {
|
||||
final ImagePicker _imagePicker = ImagePicker();
|
||||
|
||||
// Reuse the same methods from parent class
|
||||
Future<List<File>> pickFiles({
|
||||
Future<List<LocalAttachment>> pickFiles({
|
||||
bool allowMultiple = true,
|
||||
List<String>? allowedExtensions,
|
||||
}) async {
|
||||
@@ -402,48 +506,73 @@ class MockFileAttachmentService {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.files
|
||||
.where((file) => file.path != null)
|
||||
.map((file) => File(file.path!))
|
||||
.toList();
|
||||
return result.files.where((file) => file.path != null).map((file) {
|
||||
final displayName = _deriveDisplayName(
|
||||
preferredName: file.name,
|
||||
filePath: file.path!,
|
||||
fallbackPrefix: 'attachment',
|
||||
);
|
||||
return LocalAttachment(
|
||||
file: File(file.path!),
|
||||
displayName: displayName,
|
||||
);
|
||||
}).toList();
|
||||
} catch (e) {
|
||||
throw Exception('Failed to pick files: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<File?> pickImage() async {
|
||||
Future<LocalAttachment?> pickImage() async {
|
||||
try {
|
||||
final XFile? image = await _imagePicker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
imageQuality: 85,
|
||||
);
|
||||
return image != null ? File(image.path) : null;
|
||||
if (image == null) return null;
|
||||
final file = File(image.path);
|
||||
final displayName = _deriveDisplayName(
|
||||
preferredName: image.name,
|
||||
filePath: image.path,
|
||||
fallbackPrefix: 'photo',
|
||||
);
|
||||
return LocalAttachment(file: file, displayName: displayName);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to pick image: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<File?> takePhoto() async {
|
||||
Future<LocalAttachment?> takePhoto() async {
|
||||
try {
|
||||
final XFile? photo = await _imagePicker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
imageQuality: 85,
|
||||
);
|
||||
return photo != null ? File(photo.path) : null;
|
||||
if (photo == null) return null;
|
||||
final file = File(photo.path);
|
||||
final displayName = _deriveDisplayName(
|
||||
preferredName: photo.name,
|
||||
filePath: photo.path,
|
||||
fallbackPrefix: 'photo',
|
||||
);
|
||||
return LocalAttachment(file: file, displayName: displayName);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to take photo: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Mock upload file with progress tracking
|
||||
Stream<FileUploadState> uploadFile(File file) async* {
|
||||
Stream<FileUploadState> uploadFile(LocalAttachment attachment) async* {
|
||||
DebugLogger.log(
|
||||
'mock-upload',
|
||||
scope: 'attachments/mock',
|
||||
data: {'path': file.path},
|
||||
data: {
|
||||
'path': attachment.file.path,
|
||||
'displayName': attachment.displayName,
|
||||
},
|
||||
);
|
||||
|
||||
final fileName = path.basename(file.path);
|
||||
final file = attachment.file;
|
||||
final fileName = attachment.displayName;
|
||||
final fileSize = await file.length();
|
||||
|
||||
// Yield initial state
|
||||
@@ -453,6 +582,7 @@ class MockFileAttachmentService {
|
||||
fileSize: fileSize,
|
||||
progress: 0.0,
|
||||
status: FileUploadStatus.uploading,
|
||||
isImage: attachment.isImage,
|
||||
);
|
||||
|
||||
// Simulate upload progress
|
||||
@@ -464,6 +594,7 @@ class MockFileAttachmentService {
|
||||
fileSize: fileSize,
|
||||
progress: i / 10,
|
||||
status: FileUploadStatus.uploading,
|
||||
isImage: attachment.isImage,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -475,28 +606,26 @@ class MockFileAttachmentService {
|
||||
progress: 1.0,
|
||||
status: FileUploadStatus.completed,
|
||||
fileId: 'mock_file_${DateTime.now().millisecondsSinceEpoch}',
|
||||
isImage: attachment.isImage,
|
||||
);
|
||||
|
||||
DebugLogger.log('mock-complete', scope: 'attachments/mock');
|
||||
}
|
||||
|
||||
Future<List<String>> uploadFiles(
|
||||
List<File> files, {
|
||||
List<LocalAttachment> attachments, {
|
||||
Function(int, int)? onProgress,
|
||||
required String conversationId,
|
||||
}) async {
|
||||
// Simulate upload progress for reviewer mode
|
||||
final uploadIds = <String>[];
|
||||
|
||||
for (int i = 0; i < files.length; i++) {
|
||||
for (int i = 0; i < attachments.length; i++) {
|
||||
if (onProgress != null) {
|
||||
// Simulate progress
|
||||
for (int j = 0; j <= 100; j += 10) {
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
onProgress(i, j);
|
||||
}
|
||||
}
|
||||
// Generate mock upload ID
|
||||
uploadIds.add('mock_upload_${DateTime.now().millisecondsSinceEpoch}_$i');
|
||||
}
|
||||
|
||||
@@ -522,15 +651,16 @@ class AttachedFilesNotifier extends Notifier<List<FileUploadState>> {
|
||||
@override
|
||||
List<FileUploadState> build() => [];
|
||||
|
||||
void addFiles(List<File> files) {
|
||||
final newStates = files
|
||||
void addFiles(List<LocalAttachment> attachments) {
|
||||
final newStates = attachments
|
||||
.map(
|
||||
(file) => FileUploadState(
|
||||
file: file,
|
||||
fileName: path.basename(file.path),
|
||||
fileSize: file.lengthSync(),
|
||||
(attachment) => FileUploadState(
|
||||
file: attachment.file,
|
||||
fileName: attachment.displayName,
|
||||
fileSize: attachment.sizeInBytes,
|
||||
progress: 0.0,
|
||||
status: FileUploadStatus.pending,
|
||||
isImage: attachment.isImage,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
@@ -26,7 +26,6 @@ import '../widgets/file_attachment_widget.dart';
|
||||
import '../services/voice_input_service.dart';
|
||||
import '../services/file_attachment_service.dart';
|
||||
import 'voice_call_page.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import '../../../shared/services/tasks/task_queue.dart';
|
||||
import '../../tools/providers/tools_providers.dart';
|
||||
import '../../../core/models/chat_message.dart';
|
||||
@@ -390,19 +389,19 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
}
|
||||
|
||||
try {
|
||||
final files = await fileService.pickFiles();
|
||||
if (files.isEmpty) return;
|
||||
final attachments = await fileService.pickFiles();
|
||||
if (attachments.isEmpty) return;
|
||||
|
||||
// Validate file count
|
||||
final currentFiles = ref.read(attachedFilesProvider);
|
||||
if (!validateFileCount(currentFiles.length, files.length, 10)) {
|
||||
if (!validateFileCount(currentFiles.length, attachments.length, 10)) {
|
||||
if (!mounted) return;
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file sizes
|
||||
for (final file in files) {
|
||||
final fileSize = await file.length();
|
||||
for (final attachment in attachments) {
|
||||
final fileSize = await attachment.file.length();
|
||||
if (!validateFileSize(fileSize, 20)) {
|
||||
if (!mounted) return;
|
||||
return;
|
||||
@@ -410,19 +409,19 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
}
|
||||
|
||||
// Add files to the attachment list
|
||||
ref.read(attachedFilesProvider.notifier).addFiles(files);
|
||||
ref.read(attachedFilesProvider.notifier).addFiles(attachments);
|
||||
|
||||
// Enqueue uploads via task queue for unified retry/progress
|
||||
final activeConv = ref.read(activeConversationProvider);
|
||||
for (final file in files) {
|
||||
for (final attachment in attachments) {
|
||||
try {
|
||||
await ref
|
||||
.read(taskQueueProvider.notifier)
|
||||
.enqueueUploadMedia(
|
||||
conversationId: activeConv?.id,
|
||||
filePath: file.path,
|
||||
fileName: path.basename(file.path),
|
||||
fileSize: await file.length(),
|
||||
filePath: attachment.file.path,
|
||||
fileName: attachment.displayName,
|
||||
fileSize: await attachment.file.length(),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
@@ -459,16 +458,23 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
|
||||
try {
|
||||
DebugLogger.log('Picking image...', scope: 'chat/page');
|
||||
final image = fromCamera
|
||||
final attachment = fromCamera
|
||||
? await fileService.takePhoto()
|
||||
: await fileService.pickImage();
|
||||
if (image == null) {
|
||||
if (attachment == null) {
|
||||
DebugLogger.log('No image selected', scope: 'chat/page');
|
||||
return;
|
||||
}
|
||||
|
||||
DebugLogger.log('Image selected: ${image.path}', scope: 'chat/page');
|
||||
final imageSize = await image.length();
|
||||
DebugLogger.log(
|
||||
'Image selected: ${attachment.file.path}',
|
||||
scope: 'chat/page',
|
||||
);
|
||||
DebugLogger.log(
|
||||
'Image display name: ${attachment.displayName}',
|
||||
scope: 'chat/page',
|
||||
);
|
||||
final imageSize = await attachment.file.length();
|
||||
DebugLogger.log('Image size: $imageSize bytes', scope: 'chat/page');
|
||||
|
||||
// Validate file size (default 20MB limit like OpenWebUI)
|
||||
@@ -485,7 +491,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
}
|
||||
|
||||
// Add image to the attachment list
|
||||
ref.read(attachedFilesProvider.notifier).addFiles([image]);
|
||||
ref.read(attachedFilesProvider.notifier).addFiles([attachment]);
|
||||
DebugLogger.log('Image added to attachment list', scope: 'chat/page');
|
||||
|
||||
// Enqueue upload via task queue for unified retry/progress
|
||||
@@ -496,8 +502,8 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
.read(taskQueueProvider.notifier)
|
||||
.enqueueUploadMedia(
|
||||
conversationId: activeConv?.id,
|
||||
filePath: image.path,
|
||||
fileName: path.basename(image.path),
|
||||
filePath: attachment.file.path,
|
||||
fileName: attachment.displayName,
|
||||
fileSize: imageSize,
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -2,11 +2,20 @@ import 'package:flutter/material.dart';
|
||||
import '../../../shared/theme/theme_extensions.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:io' show File, Platform;
|
||||
import 'package:conduit/l10n/app_localizations.dart';
|
||||
import '../services/file_attachment_service.dart';
|
||||
import '../../../shared/services/tasks/task_queue.dart';
|
||||
import '../../../shared/widgets/loading_states.dart';
|
||||
|
||||
const Set<String> _previewableImageExtensions = <String>{
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.png',
|
||||
'.gif',
|
||||
'.webp',
|
||||
};
|
||||
|
||||
class FileAttachmentWidget extends ConsumerWidget {
|
||||
const FileAttachmentWidget({super.key});
|
||||
|
||||
@@ -58,6 +67,9 @@ class _FileAttachmentCard extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final bool canPreview = _canPreviewImage();
|
||||
final Widget removeButton = _buildRemoveButton(context, ref);
|
||||
|
||||
return Container(
|
||||
width: 140,
|
||||
padding: const EdgeInsets.all(Spacing.sm),
|
||||
@@ -72,14 +84,19 @@ class _FileAttachmentCard extends ConsumerWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (canPreview) ...[
|
||||
_buildImagePreview(context, removeButton),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
] else ...[
|
||||
Row(
|
||||
children: [
|
||||
Text(fileState.fileIcon, style: const TextStyle(fontSize: 20)),
|
||||
const Spacer(),
|
||||
_buildStatusIcon(context),
|
||||
const Spacer(),
|
||||
removeButton,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: Spacing.xs),
|
||||
],
|
||||
Text(
|
||||
fileState.fileName,
|
||||
style: TextStyle(
|
||||
@@ -154,6 +171,50 @@ class _FileAttachmentCard extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildRemoveButton(BuildContext context, WidgetRef ref) {
|
||||
final String tooltip = MaterialLocalizations.of(
|
||||
context,
|
||||
).deleteButtonTooltip;
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: tooltip,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _removeAttachment(ref),
|
||||
child: Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.cardBackground.withValues(
|
||||
alpha: 0.85,
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.cardBorder.withValues(alpha: 0.6),
|
||||
width: BorderWidth.thin,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Platform.isIOS ? CupertinoIcons.xmark : Icons.close,
|
||||
size: 14,
|
||||
color: context.conduitTheme.textPrimary.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _removeAttachment(WidgetRef ref) {
|
||||
ref.read(attachedFilesProvider.notifier).removeFile(fileState.file.path);
|
||||
ref
|
||||
.read(taskQueueProvider.notifier)
|
||||
.cancelUploadsForFile(fileState.file.path);
|
||||
}
|
||||
|
||||
Widget _buildProgressBar(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
||||
@@ -182,6 +243,88 @@ class _FileAttachmentCard extends ConsumerWidget {
|
||||
return context.conduitTheme.error.withValues(alpha: 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
bool _canPreviewImage() {
|
||||
if (fileState.isImage != null) {
|
||||
return fileState.isImage!;
|
||||
}
|
||||
final String lowerName = fileState.fileName.toLowerCase();
|
||||
return _previewableImageExtensions.any(lowerName.endsWith);
|
||||
}
|
||||
|
||||
Widget _buildImagePreview(BuildContext context, Widget removeButton) {
|
||||
final File file = fileState.file;
|
||||
final bool fileExists = file.existsSync();
|
||||
final Widget basePreview = fileExists
|
||||
? Image.file(
|
||||
file,
|
||||
fit: BoxFit.cover,
|
||||
filterQuality: FilterQuality.medium,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
_buildPreviewPlaceholderContent(context),
|
||||
)
|
||||
: _buildPreviewPlaceholderContent(context);
|
||||
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.cardBorder,
|
||||
width: BorderWidth.thin,
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 4 / 3,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(child: basePreview),
|
||||
if (fileState.status == FileUploadStatus.uploading)
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.cardBackground.withValues(
|
||||
alpha: 0.35,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: Spacing.xs,
|
||||
right: Spacing.xs,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.cardBackground.withValues(
|
||||
alpha: 0.85,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: _buildStatusIcon(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: Spacing.xs,
|
||||
left: Spacing.xs,
|
||||
child: removeButton,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPreviewPlaceholderContent(BuildContext context) {
|
||||
return Container(
|
||||
color: context.conduitTheme.textPrimary.withValues(alpha: 0.08),
|
||||
alignment: Alignment.center,
|
||||
child: Text(fileState.fileIcon, style: const TextStyle(fontSize: 26)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Attachment preview for messages
|
||||
|
||||
@@ -127,6 +127,43 @@ class TaskQueueNotifier extends Notifier<List<OutboundTask>> {
|
||||
await _save();
|
||||
}
|
||||
|
||||
Future<void> cancelUploadsForFile(String filePath) async {
|
||||
bool updated = false;
|
||||
state = [
|
||||
for (final task in state)
|
||||
task.maybeMap(
|
||||
uploadMedia: (upload) {
|
||||
if ((upload.status == TaskStatus.queued ||
|
||||
upload.status == TaskStatus.running) &&
|
||||
upload.filePath == filePath) {
|
||||
updated = true;
|
||||
return upload.copyWith(
|
||||
status: TaskStatus.cancelled,
|
||||
completedAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
return upload;
|
||||
},
|
||||
imageToDataUrl: (image) {
|
||||
if ((image.status == TaskStatus.queued ||
|
||||
image.status == TaskStatus.running) &&
|
||||
image.filePath == filePath) {
|
||||
updated = true;
|
||||
return image.copyWith(
|
||||
status: TaskStatus.cancelled,
|
||||
completedAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
return image;
|
||||
},
|
||||
orElse: () => task,
|
||||
),
|
||||
];
|
||||
if (updated) {
|
||||
await _save();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelByConversation(String conversationId) async {
|
||||
state = [
|
||||
for (final t in state)
|
||||
|
||||
@@ -107,6 +107,10 @@ class TaskWorker {
|
||||
QueuedAttachmentStatus.failed => FileUploadStatus.failed,
|
||||
QueuedAttachmentStatus.cancelled => FileUploadStatus.failed,
|
||||
};
|
||||
const imageExts = <String>{'.jpg', '.jpeg', '.png', '.gif', '.webp'};
|
||||
final lowerName = task.fileName.toLowerCase();
|
||||
final bool isImage =
|
||||
existing.isImage ?? imageExts.any(lowerName.endsWith);
|
||||
final newState = FileUploadState(
|
||||
file: File(task.filePath),
|
||||
fileName: task.fileName,
|
||||
@@ -117,6 +121,7 @@ class TaskWorker {
|
||||
status: status,
|
||||
fileId: entry.fileId ?? existing.fileId,
|
||||
error: entry.lastError,
|
||||
isImage: isImage,
|
||||
);
|
||||
_ref
|
||||
.read(attachedFilesProvider.notifier)
|
||||
@@ -260,6 +265,7 @@ class TaskWorker {
|
||||
progress: 0.0,
|
||||
status: FileUploadStatus.uploading,
|
||||
fileId: existing.fileId,
|
||||
isImage: existing.isImage ?? true,
|
||||
);
|
||||
_ref
|
||||
.read(attachedFilesProvider.notifier)
|
||||
|
||||
@@ -482,7 +482,7 @@ class TweakcnThemes {
|
||||
preview: const <Color>[
|
||||
Color(0xFFC96442),
|
||||
Color(0xFFE9E6DC),
|
||||
Color(0xFF9C87F5),
|
||||
Color(0xFF1A1915),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -508,7 +508,7 @@ class TweakcnThemes {
|
||||
preview: const <Color>[
|
||||
Color(0xFFA1A1AA),
|
||||
Color(0xFFF4F4F5),
|
||||
Color(0xFF2563EB),
|
||||
Color(0xFF404040),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ flutter:
|
||||
flutter_native_splash:
|
||||
# Splash background matches the light theme; `color_dark` handles dark mode.
|
||||
color: "#FFFFFF"
|
||||
color_dark: "#0B0E14"
|
||||
color_dark: "#0A0A0A"
|
||||
# Image to display on the splash screen
|
||||
image: assets/icons/icon.png
|
||||
|
||||
@@ -107,7 +107,7 @@ flutter_native_splash:
|
||||
# Android specific settings
|
||||
android_12:
|
||||
color: "#FFFFFF"
|
||||
color_dark: "#0B0E14"
|
||||
color_dark: "#0A0A0A"
|
||||
|
||||
# Web specific settings
|
||||
web: false
|
||||
|
||||
Reference in New Issue
Block a user