2025-08-10 01:20:45 +05:30
|
|
|
import 'dart:io';
|
|
|
|
|
import 'dart:convert';
|
|
|
|
|
import 'dart:ui' as ui;
|
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
|
import 'package:file_picker/file_picker.dart';
|
2025-12-22 11:20:00 +05:30
|
|
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
2025-08-10 01:20:45 +05:30
|
|
|
import 'package:image_picker/image_picker.dart';
|
|
|
|
|
import 'package:path/path.dart' as path;
|
|
|
|
|
import '../../../core/providers/app_providers.dart';
|
2025-12-25 20:29:38 +05:30
|
|
|
import '../../../core/services/worker_manager.dart';
|
2025-09-25 22:36:42 +05:30
|
|
|
import '../../../core/utils/debug_logger.dart';
|
2025-08-10 01:20:45 +05:30
|
|
|
|
2025-12-23 12:09:43 +05:30
|
|
|
/// Size threshold for optimizing images to WebP (200KB).
|
|
|
|
|
/// Images larger than this will be converted to WebP for better compression.
|
|
|
|
|
const int _webpOptimizationThreshold = 200 * 1024;
|
|
|
|
|
|
2025-12-22 11:20:00 +05:30
|
|
|
/// Standard web image formats that LLMs can process directly.
|
|
|
|
|
const Set<String> _standardImageFormats = {
|
|
|
|
|
'.jpg',
|
|
|
|
|
'.jpeg',
|
|
|
|
|
'.png',
|
|
|
|
|
'.gif',
|
|
|
|
|
'.webp',
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-23 12:09:43 +05:30
|
|
|
/// Formats that should always be converted to WebP (not widely supported).
|
|
|
|
|
const Set<String> _alwaysConvertFormats = {
|
2025-12-22 11:20:00 +05:30
|
|
|
'.heic',
|
|
|
|
|
'.heif',
|
|
|
|
|
'.dng',
|
|
|
|
|
'.raw',
|
|
|
|
|
'.cr2',
|
|
|
|
|
'.nef',
|
|
|
|
|
'.arw',
|
|
|
|
|
'.orf',
|
|
|
|
|
'.rw2',
|
2025-12-23 12:09:43 +05:30
|
|
|
'.bmp',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/// Formats that benefit from WebP conversion when large.
|
2025-12-25 20:29:38 +05:30
|
|
|
const Set<String> _optimizableFormats = {'.jpg', '.jpeg', '.png'};
|
2025-12-23 12:09:43 +05:30
|
|
|
|
|
|
|
|
/// Formats that should never be converted (animation, already optimal).
|
2025-12-25 20:29:38 +05:30
|
|
|
const Set<String> _preserveFormats = {'.gif', '.webp'};
|
2025-12-22 11:20:00 +05:30
|
|
|
|
|
|
|
|
/// All supported image formats (both standard and those requiring conversion).
|
|
|
|
|
const Set<String> allSupportedImageFormats = {
|
|
|
|
|
..._standardImageFormats,
|
2025-12-23 12:09:43 +05:30
|
|
|
..._alwaysConvertFormats,
|
2025-12-22 11:20:00 +05:30
|
|
|
};
|
|
|
|
|
|
2025-12-23 12:09:43 +05:30
|
|
|
/// Returns true if the extension always requires conversion to WebP.
|
|
|
|
|
bool _alwaysNeedsConversion(String extension) {
|
|
|
|
|
return _alwaysConvertFormats.contains(extension);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns true if the format can benefit from WebP optimization.
|
|
|
|
|
bool _canOptimize(String extension) {
|
|
|
|
|
return _optimizableFormats.contains(extension);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns true if the format should be preserved as-is.
|
|
|
|
|
bool _shouldPreserve(String extension) {
|
|
|
|
|
return _preserveFormats.contains(extension);
|
2025-12-22 11:20:00 +05:30
|
|
|
}
|
|
|
|
|
|
2025-12-25 20:29:38 +05:30
|
|
|
/// Top-level function for base64 encoding in an isolate.
|
|
|
|
|
String _encodeToDataUrlWorker(Map<String, dynamic> payload) {
|
|
|
|
|
final bytes = payload['bytes'] as List<int>;
|
|
|
|
|
final mimeType = payload['mimeType'] as String;
|
|
|
|
|
return 'data:$mimeType;base64,${base64Encode(bytes)}';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Helper to encode bytes to data URL, using isolate when worker is provided.
|
|
|
|
|
Future<String> _encodeToDataUrl(
|
|
|
|
|
List<int> bytes,
|
|
|
|
|
String mimeType,
|
|
|
|
|
WorkerManager? worker,
|
|
|
|
|
) async {
|
|
|
|
|
if (worker != null && bytes.length > 50 * 1024) {
|
|
|
|
|
// Use isolate for files > 50KB
|
|
|
|
|
return worker.schedule(_encodeToDataUrlWorker, {
|
|
|
|
|
'bytes': bytes,
|
|
|
|
|
'mimeType': mimeType,
|
|
|
|
|
}, debugLabel: 'base64-encode');
|
|
|
|
|
}
|
|
|
|
|
// Small files: encode on main thread
|
|
|
|
|
return 'data:$mimeType;base64,${base64Encode(bytes)}';
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-23 12:09:43 +05:30
|
|
|
/// Converts an image file to a base64 data URL with smart optimization.
|
2025-12-10 19:40:38 +05:30
|
|
|
/// This is a standalone utility used by both FileAttachmentService and TaskWorker.
|
2025-12-22 11:20:00 +05:30
|
|
|
///
|
2025-12-23 12:09:43 +05:30
|
|
|
/// Optimization strategy:
|
|
|
|
|
/// - HEIC/HEIF/RAW/BMP → Always convert to WebP
|
|
|
|
|
/// - Large JPEG/PNG (>200KB) → Convert to WebP for better compression
|
|
|
|
|
/// - Small JPEG/PNG (<200KB) → Pass through as-is
|
|
|
|
|
/// - GIF → Preserve (maintains animation)
|
|
|
|
|
/// - WebP → Preserve (already optimal)
|
2025-12-22 11:20:00 +05:30
|
|
|
///
|
2025-12-25 20:29:38 +05:30
|
|
|
/// If [worker] is provided, base64 encoding runs in a background isolate
|
|
|
|
|
/// to avoid blocking the UI thread for large images.
|
|
|
|
|
///
|
2025-12-23 12:09:43 +05:30
|
|
|
/// Returns null if conversion fails for formats requiring conversion.
|
2025-12-25 20:29:38 +05:30
|
|
|
Future<String?> convertImageFileToDataUrl(
|
|
|
|
|
File imageFile, {
|
|
|
|
|
WorkerManager? worker,
|
|
|
|
|
}) async {
|
2025-12-10 19:40:38 +05:30
|
|
|
try {
|
|
|
|
|
final ext = path.extension(imageFile.path).toLowerCase();
|
2025-12-23 12:09:43 +05:30
|
|
|
final fileSize = await imageFile.length();
|
2025-12-10 19:40:38 +05:30
|
|
|
|
2025-12-23 12:09:43 +05:30
|
|
|
// Formats that must always be converted (HEIC, RAW, BMP, etc.)
|
|
|
|
|
if (_alwaysNeedsConversion(ext)) {
|
2025-12-22 11:20:00 +05:30
|
|
|
DebugLogger.log(
|
2025-12-23 12:09:43 +05:30
|
|
|
'Converting image from $ext to WebP (required)',
|
2025-12-22 11:20:00 +05:30
|
|
|
scope: 'attachments',
|
2025-12-23 12:09:43 +05:30
|
|
|
data: {'path': imageFile.path, 'size': fileSize},
|
2025-12-22 11:20:00 +05:30
|
|
|
);
|
|
|
|
|
|
2025-12-23 12:09:43 +05:30
|
|
|
final convertedBytes = await _convertToWebP(imageFile);
|
2025-12-22 11:20:00 +05:30
|
|
|
if (convertedBytes != null) {
|
2025-12-25 20:29:38 +05:30
|
|
|
return _encodeToDataUrl(convertedBytes, 'image/webp', worker);
|
2025-12-22 11:20:00 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
DebugLogger.warning(
|
|
|
|
|
'Conversion failed for $ext format, cannot process image',
|
|
|
|
|
);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-23 12:09:43 +05:30
|
|
|
// Formats that should be preserved as-is (GIF, WebP)
|
|
|
|
|
if (_shouldPreserve(ext)) {
|
|
|
|
|
final bytes = await imageFile.readAsBytes();
|
|
|
|
|
final mimeType = ext == '.gif' ? 'image/gif' : 'image/webp';
|
2025-12-25 20:29:38 +05:30
|
|
|
return _encodeToDataUrl(bytes, mimeType, worker);
|
2025-12-23 12:09:43 +05:30
|
|
|
}
|
2025-12-22 11:20:00 +05:30
|
|
|
|
2025-12-23 12:09:43 +05:30
|
|
|
// Optimizable formats (JPEG, PNG) - convert if large
|
|
|
|
|
if (_canOptimize(ext) && fileSize > _webpOptimizationThreshold) {
|
|
|
|
|
DebugLogger.log(
|
|
|
|
|
'Optimizing large image from $ext to WebP',
|
|
|
|
|
scope: 'attachments',
|
|
|
|
|
data: {'path': imageFile.path, 'size': fileSize},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final convertedBytes = await _convertToWebP(imageFile);
|
|
|
|
|
if (convertedBytes != null) {
|
|
|
|
|
final savings = fileSize - convertedBytes.length;
|
|
|
|
|
final savingsPercent = (savings / fileSize * 100).toStringAsFixed(1);
|
|
|
|
|
DebugLogger.log(
|
|
|
|
|
'WebP optimization saved $savingsPercent%',
|
|
|
|
|
scope: 'attachments',
|
|
|
|
|
data: {
|
|
|
|
|
'originalSize': fileSize,
|
|
|
|
|
'newSize': convertedBytes.length,
|
|
|
|
|
'saved': savings,
|
|
|
|
|
},
|
|
|
|
|
);
|
2025-12-25 20:29:38 +05:30
|
|
|
return _encodeToDataUrl(convertedBytes, 'image/webp', worker);
|
2025-12-23 12:09:43 +05:30
|
|
|
}
|
|
|
|
|
// Fall through to pass-through if conversion fails
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Pass through as-is (small images or unknown formats)
|
|
|
|
|
final bytes = await imageFile.readAsBytes();
|
2025-12-10 19:40:38 +05:30
|
|
|
String mimeType = 'image/png';
|
|
|
|
|
if (ext == '.jpg' || ext == '.jpeg') {
|
|
|
|
|
mimeType = 'image/jpeg';
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-25 20:29:38 +05:30
|
|
|
return _encodeToDataUrl(bytes, mimeType, worker);
|
2025-12-10 19:40:38 +05:30
|
|
|
} catch (e) {
|
|
|
|
|
DebugLogger.error('convert-image-failed', scope: 'attachments', error: e);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-23 12:09:43 +05:30
|
|
|
/// Converts an image file to WebP bytes using flutter_image_compress.
|
|
|
|
|
/// WebP provides better compression than JPEG while maintaining quality.
|
|
|
|
|
Future<List<int>?> _convertToWebP(File imageFile) async {
|
2025-12-22 11:20:00 +05:30
|
|
|
try {
|
|
|
|
|
final result = await FlutterImageCompress.compressWithFile(
|
|
|
|
|
imageFile.absolute.path,
|
2025-12-23 12:09:43 +05:30
|
|
|
format: CompressFormat.webp,
|
|
|
|
|
quality: 85,
|
2025-12-22 11:20:00 +05:30
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (result != null && result.isNotEmpty) {
|
|
|
|
|
DebugLogger.log(
|
2025-12-23 12:09:43 +05:30
|
|
|
'Image converted to WebP successfully',
|
2025-12-22 11:20:00 +05:30
|
|
|
scope: 'attachments',
|
2025-12-25 20:29:38 +05:30
|
|
|
data: {'originalPath': imageFile.path, 'resultSize': result.length},
|
2025-12-22 11:20:00 +05:30
|
|
|
);
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
} catch (e) {
|
2025-12-25 20:29:38 +05:30
|
|
|
DebugLogger.error('webp-conversion-failed', scope: 'attachments', error: e);
|
2025-12-22 11:20:00 +05:30
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-19 13:50:54 +05:30
|
|
|
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');
|
2025-12-23 12:09:43 +05:30
|
|
|
final String ext = extension.isNotEmpty ? extension : '.webp';
|
2025-10-19 13:50:54 +05:30
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-22 11:20:00 +05:30
|
|
|
bool get isImage => allSupportedImageFormats.contains(extension);
|
2025-10-19 13:50:54 +05:30
|
|
|
}
|
|
|
|
|
|
2025-08-10 01:20:45 +05:30
|
|
|
class FileAttachmentService {
|
|
|
|
|
final ImagePicker _imagePicker = ImagePicker();
|
|
|
|
|
|
2025-12-10 19:40:38 +05:30
|
|
|
FileAttachmentService();
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
|
|
|
// Pick files from device
|
2025-10-19 13:50:54 +05:30
|
|
|
Future<List<LocalAttachment>> pickFiles({
|
2025-08-10 01:20:45 +05:30
|
|
|
bool allowMultiple = true,
|
|
|
|
|
List<String>? allowedExtensions,
|
|
|
|
|
}) async {
|
|
|
|
|
try {
|
|
|
|
|
final result = await FilePicker.platform.pickFiles(
|
|
|
|
|
allowMultiple: allowMultiple,
|
|
|
|
|
type: allowedExtensions != null ? FileType.custom : FileType.any,
|
|
|
|
|
allowedExtensions: allowedExtensions,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (result == null || result.files.isEmpty) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-19 13:50:54 +05:30
|
|
|
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();
|
2025-08-10 01:20:45 +05:30
|
|
|
} catch (e) {
|
|
|
|
|
throw Exception('Failed to pick files: $e');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Pick image from gallery
|
2025-10-19 13:50:54 +05:30
|
|
|
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',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-10 01:20:45 +05:30
|
|
|
try {
|
|
|
|
|
final XFile? image = await _imagePicker.pickImage(
|
|
|
|
|
source: ImageSource.gallery,
|
|
|
|
|
imageQuality: 85,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (image == null) return null;
|
2025-10-19 13:50:54 +05:30
|
|
|
final file = File(image.path);
|
|
|
|
|
final displayName = _deriveDisplayName(
|
|
|
|
|
preferredName: image.name,
|
|
|
|
|
filePath: image.path,
|
|
|
|
|
fallbackPrefix: 'photo',
|
|
|
|
|
);
|
|
|
|
|
return LocalAttachment(file: file, displayName: displayName);
|
2025-08-10 01:20:45 +05:30
|
|
|
} catch (e) {
|
|
|
|
|
throw Exception('Failed to pick image: $e');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Take photo from camera
|
2025-10-19 13:50:54 +05:30
|
|
|
Future<LocalAttachment?> takePhoto() async {
|
2025-08-10 01:20:45 +05:30
|
|
|
try {
|
|
|
|
|
final XFile? photo = await _imagePicker.pickImage(
|
|
|
|
|
source: ImageSource.camera,
|
|
|
|
|
imageQuality: 85,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (photo == null) return null;
|
2025-10-19 13:50:54 +05:30
|
|
|
final file = File(photo.path);
|
|
|
|
|
final displayName = _deriveDisplayName(
|
|
|
|
|
preferredName: photo.name,
|
|
|
|
|
filePath: photo.path,
|
|
|
|
|
fallbackPrefix: 'photo',
|
|
|
|
|
);
|
|
|
|
|
return LocalAttachment(file: file, displayName: displayName);
|
2025-08-10 01:20:45 +05:30
|
|
|
} catch (e) {
|
|
|
|
|
throw Exception('Failed to take photo: $e');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-23 12:09:43 +05:30
|
|
|
/// Compresses and resizes an image data URL.
|
|
|
|
|
/// Uses PNG format for the resize operation (dart:ui limitation),
|
|
|
|
|
/// then converts to WebP for optimal file size.
|
2025-08-10 01:20:45 +05:30
|
|
|
Future<String> compressImage(
|
|
|
|
|
String imageDataUrl,
|
|
|
|
|
int? maxWidth,
|
|
|
|
|
int? maxHeight,
|
|
|
|
|
) async {
|
|
|
|
|
try {
|
2025-11-13 12:39:09 +05:30
|
|
|
// Decode base64 data - with validation
|
|
|
|
|
final parts = imageDataUrl.split(',');
|
|
|
|
|
if (parts.length < 2) {
|
|
|
|
|
DebugLogger.log(
|
|
|
|
|
'Invalid data URL format - missing comma separator',
|
|
|
|
|
scope: 'attachments/image',
|
|
|
|
|
data: {
|
|
|
|
|
'urlPrefix': imageDataUrl.length > 50
|
|
|
|
|
? imageDataUrl.substring(0, 50)
|
|
|
|
|
: imageDataUrl,
|
|
|
|
|
},
|
|
|
|
|
);
|
2025-12-23 12:09:43 +05:30
|
|
|
return imageDataUrl;
|
2025-11-13 12:39:09 +05:30
|
|
|
}
|
|
|
|
|
final data = parts[1];
|
2025-08-10 01:20:45 +05:30
|
|
|
final bytes = base64Decode(data);
|
|
|
|
|
|
|
|
|
|
// Decode image
|
|
|
|
|
final codec = await ui.instantiateImageCodec(bytes);
|
|
|
|
|
final frame = await codec.getNextFrame();
|
|
|
|
|
final image = frame.image;
|
|
|
|
|
|
|
|
|
|
int width = image.width;
|
|
|
|
|
int height = image.height;
|
|
|
|
|
|
|
|
|
|
// Calculate new dimensions maintaining aspect ratio
|
|
|
|
|
if (maxWidth != null && maxHeight != null) {
|
|
|
|
|
if (width <= maxWidth && height <= maxHeight) {
|
2025-12-23 12:09:43 +05:30
|
|
|
return imageDataUrl;
|
2025-08-10 01:20:45 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (width / height > maxWidth / maxHeight) {
|
|
|
|
|
height = ((maxWidth * height) / width).round();
|
|
|
|
|
width = maxWidth;
|
|
|
|
|
} else {
|
|
|
|
|
width = ((maxHeight * width) / height).round();
|
|
|
|
|
height = maxHeight;
|
|
|
|
|
}
|
|
|
|
|
} else if (maxWidth != null) {
|
|
|
|
|
if (width <= maxWidth) {
|
2025-12-23 12:09:43 +05:30
|
|
|
return imageDataUrl;
|
2025-08-10 01:20:45 +05:30
|
|
|
}
|
|
|
|
|
height = ((maxWidth * height) / width).round();
|
|
|
|
|
width = maxWidth;
|
|
|
|
|
} else if (maxHeight != null) {
|
|
|
|
|
if (height <= maxHeight) {
|
2025-12-23 12:09:43 +05:30
|
|
|
return imageDataUrl;
|
2025-08-10 01:20:45 +05:30
|
|
|
}
|
|
|
|
|
width = ((maxHeight * width) / height).round();
|
|
|
|
|
height = maxHeight;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-23 12:09:43 +05:30
|
|
|
// Create resized image (dart:ui only supports PNG output)
|
2025-08-10 01:20:45 +05:30
|
|
|
final recorder = ui.PictureRecorder();
|
|
|
|
|
final canvas = Canvas(recorder);
|
|
|
|
|
|
|
|
|
|
canvas.drawImageRect(
|
|
|
|
|
image,
|
|
|
|
|
Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()),
|
|
|
|
|
Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()),
|
|
|
|
|
Paint(),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final picture = recorder.endRecording();
|
2025-12-23 12:09:43 +05:30
|
|
|
final resizedImage = await picture.toImage(width, height);
|
|
|
|
|
final byteData = await resizedImage.toByteData(
|
2025-08-10 01:20:45 +05:30
|
|
|
format: ui.ImageByteFormat.png,
|
|
|
|
|
);
|
2025-12-23 12:09:43 +05:30
|
|
|
final pngBytes = byteData!.buffer.asUint8List();
|
|
|
|
|
|
|
|
|
|
// Convert PNG to WebP for better compression
|
|
|
|
|
final webpBytes = await FlutterImageCompress.compressWithList(
|
|
|
|
|
pngBytes,
|
|
|
|
|
format: CompressFormat.webp,
|
|
|
|
|
quality: 85,
|
|
|
|
|
);
|
2025-08-10 01:20:45 +05:30
|
|
|
|
2025-12-23 12:09:43 +05:30
|
|
|
final compressedBase64 = base64Encode(webpBytes);
|
|
|
|
|
return 'data:image/webp;base64,$compressedBase64';
|
2025-08-10 01:20:45 +05:30
|
|
|
} catch (e) {
|
2025-09-25 22:36:42 +05:30
|
|
|
DebugLogger.error(
|
|
|
|
|
'compress-failed',
|
|
|
|
|
scope: 'attachments/image',
|
|
|
|
|
error: e,
|
|
|
|
|
);
|
2025-12-23 12:09:43 +05:30
|
|
|
return imageDataUrl;
|
2025-08-10 01:20:45 +05:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-10 19:40:38 +05:30
|
|
|
// Convert image file to base64 data URL with optional compression
|
2025-08-10 01:20:45 +05:30
|
|
|
Future<String?> convertImageToDataUrl(
|
|
|
|
|
File imageFile, {
|
|
|
|
|
bool enableCompression = false,
|
|
|
|
|
int? maxWidth,
|
|
|
|
|
int? maxHeight,
|
|
|
|
|
}) async {
|
2025-12-10 19:40:38 +05:30
|
|
|
// Use the shared utility for basic conversion
|
|
|
|
|
String? dataUrl = await convertImageFileToDataUrl(imageFile);
|
|
|
|
|
if (dataUrl == null) return null;
|
2025-08-10 01:20:45 +05:30
|
|
|
|
2025-12-10 19:40:38 +05:30
|
|
|
// Apply compression if enabled
|
|
|
|
|
if (enableCompression && (maxWidth != null || maxHeight != null)) {
|
|
|
|
|
dataUrl = await compressImage(dataUrl, maxWidth, maxHeight);
|
2025-08-10 01:20:45 +05:30
|
|
|
}
|
|
|
|
|
|
2025-12-10 19:40:38 +05:30
|
|
|
return dataUrl;
|
2025-08-10 01:20:45 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Format file size for display
|
|
|
|
|
String formatFileSize(int bytes) {
|
|
|
|
|
if (bytes < 1024) return '$bytes B';
|
|
|
|
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
|
|
|
|
if (bytes < 1024 * 1024 * 1024) {
|
|
|
|
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
|
|
|
|
}
|
|
|
|
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get file icon based on extension
|
|
|
|
|
String getFileIcon(String fileName) {
|
|
|
|
|
final ext = path.extension(fileName).toLowerCase();
|
|
|
|
|
|
|
|
|
|
// Documents
|
|
|
|
|
if (['.pdf', '.doc', '.docx'].contains(ext)) return '📄';
|
|
|
|
|
if (['.xls', '.xlsx'].contains(ext)) return '📊';
|
|
|
|
|
if (['.ppt', '.pptx'].contains(ext)) return '📊';
|
|
|
|
|
|
2025-12-22 11:20:00 +05:30
|
|
|
// Images (including iOS and RAW formats)
|
|
|
|
|
if (allSupportedImageFormats.contains(ext)) return '🖼️';
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
|
|
|
// Code
|
|
|
|
|
if (['.js', '.ts', '.py', '.dart', '.java', '.cpp'].contains(ext)) {
|
|
|
|
|
return '💻';
|
|
|
|
|
}
|
|
|
|
|
if (['.html', '.css', '.json', '.xml'].contains(ext)) return '🌐';
|
|
|
|
|
|
|
|
|
|
// Archives
|
|
|
|
|
if (['.zip', '.rar', '.7z', '.tar', '.gz'].contains(ext)) return '📦';
|
|
|
|
|
|
|
|
|
|
// Media
|
|
|
|
|
if (['.mp3', '.wav', '.flac', '.m4a'].contains(ext)) return '🎵';
|
|
|
|
|
if (['.mp4', '.avi', '.mov', '.mkv'].contains(ext)) return '🎬';
|
|
|
|
|
|
|
|
|
|
return '📎';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// File upload state
|
|
|
|
|
class FileUploadState {
|
|
|
|
|
final File file;
|
|
|
|
|
final String fileName;
|
|
|
|
|
final int fileSize;
|
|
|
|
|
final double progress;
|
|
|
|
|
final FileUploadStatus status;
|
|
|
|
|
final String? fileId;
|
|
|
|
|
final String? error;
|
2025-12-10 19:40:38 +05:30
|
|
|
final bool? isImage;
|
|
|
|
|
|
|
|
|
|
/// For images: stores the base64 data URL (e.g., "data:image/png;base64,...")
|
|
|
|
|
/// This matches web client behavior where images are not uploaded to server.
|
|
|
|
|
final String? base64DataUrl;
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
|
|
|
FileUploadState({
|
|
|
|
|
required this.file,
|
|
|
|
|
required this.fileName,
|
|
|
|
|
required this.fileSize,
|
|
|
|
|
required this.progress,
|
|
|
|
|
required this.status,
|
|
|
|
|
this.fileId,
|
|
|
|
|
this.error,
|
2025-12-10 19:40:38 +05:30
|
|
|
this.isImage,
|
|
|
|
|
this.base64DataUrl,
|
2025-08-10 01:20:45 +05:30
|
|
|
});
|
|
|
|
|
|
|
|
|
|
String get formattedSize {
|
|
|
|
|
if (fileSize < 1024) return '$fileSize B';
|
|
|
|
|
if (fileSize < 1024 * 1024) {
|
|
|
|
|
return '${(fileSize / 1024).toStringAsFixed(1)} KB';
|
|
|
|
|
}
|
|
|
|
|
if (fileSize < 1024 * 1024 * 1024) {
|
|
|
|
|
return '${(fileSize / (1024 * 1024)).toStringAsFixed(1)} MB';
|
|
|
|
|
}
|
|
|
|
|
return '${(fileSize / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String get fileIcon {
|
|
|
|
|
final ext = path.extension(fileName).toLowerCase();
|
|
|
|
|
|
|
|
|
|
// Documents
|
|
|
|
|
if (['.pdf', '.doc', '.docx'].contains(ext)) return '📄';
|
|
|
|
|
if (['.xls', '.xlsx'].contains(ext)) return '📊';
|
|
|
|
|
if (['.ppt', '.pptx'].contains(ext)) return '📊';
|
|
|
|
|
|
2025-12-22 11:20:00 +05:30
|
|
|
// Images (including iOS and RAW formats)
|
|
|
|
|
if (allSupportedImageFormats.contains(ext)) return '🖼️';
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
|
|
|
// Code
|
|
|
|
|
if (['.js', '.ts', '.py', '.dart', '.java', '.cpp'].contains(ext)) {
|
|
|
|
|
return '💻';
|
|
|
|
|
}
|
|
|
|
|
if (['.html', '.css', '.json', '.xml'].contains(ext)) return '🌐';
|
|
|
|
|
|
|
|
|
|
// Archives
|
|
|
|
|
if (['.zip', '.rar', '.7z', '.tar', '.gz'].contains(ext)) return '📦';
|
|
|
|
|
|
|
|
|
|
// Media
|
|
|
|
|
if (['.mp3', '.wav', '.flac', '.m4a'].contains(ext)) return '🎵';
|
|
|
|
|
if (['.mp4', '.avi', '.mov', '.mkv'].contains(ext)) return '🎬';
|
|
|
|
|
|
|
|
|
|
return '📎';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
enum FileUploadStatus { pending, uploading, completed, failed }
|
|
|
|
|
|
2025-08-17 16:11:19 +05:30
|
|
|
// Mock file attachment service for reviewer mode
|
|
|
|
|
class MockFileAttachmentService {
|
|
|
|
|
final ImagePicker _imagePicker = ImagePicker();
|
|
|
|
|
|
2025-10-19 13:50:54 +05:30
|
|
|
Future<List<LocalAttachment>> pickFiles({
|
2025-08-17 16:11:19 +05:30
|
|
|
bool allowMultiple = true,
|
|
|
|
|
List<String>? allowedExtensions,
|
|
|
|
|
}) async {
|
|
|
|
|
try {
|
|
|
|
|
final result = await FilePicker.platform.pickFiles(
|
|
|
|
|
allowMultiple: allowMultiple,
|
|
|
|
|
type: allowedExtensions != null ? FileType.custom : FileType.any,
|
|
|
|
|
allowedExtensions: allowedExtensions,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (result == null || result.files.isEmpty) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-19 13:50:54 +05:30
|
|
|
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();
|
2025-08-17 16:11:19 +05:30
|
|
|
} catch (e) {
|
|
|
|
|
throw Exception('Failed to pick files: $e');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-19 13:50:54 +05:30
|
|
|
Future<LocalAttachment?> pickImage() async {
|
2025-08-17 16:11:19 +05:30
|
|
|
try {
|
|
|
|
|
final XFile? image = await _imagePicker.pickImage(
|
|
|
|
|
source: ImageSource.gallery,
|
|
|
|
|
imageQuality: 85,
|
|
|
|
|
);
|
2025-10-19 13:50:54 +05:30
|
|
|
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);
|
2025-08-17 16:11:19 +05:30
|
|
|
} catch (e) {
|
|
|
|
|
throw Exception('Failed to pick image: $e');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-19 13:50:54 +05:30
|
|
|
Future<LocalAttachment?> takePhoto() async {
|
2025-08-17 16:11:19 +05:30
|
|
|
try {
|
|
|
|
|
final XFile? photo = await _imagePicker.pickImage(
|
|
|
|
|
source: ImageSource.camera,
|
|
|
|
|
imageQuality: 85,
|
|
|
|
|
);
|
2025-10-19 13:50:54 +05:30
|
|
|
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);
|
2025-08-17 16:11:19 +05:30
|
|
|
} catch (e) {
|
|
|
|
|
throw Exception('Failed to take photo: $e');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-10 01:20:45 +05:30
|
|
|
// Providers
|
2025-08-17 16:11:19 +05:30
|
|
|
final fileAttachmentServiceProvider = Provider<dynamic>((ref) {
|
|
|
|
|
final isReviewerMode = ref.watch(reviewerModeProvider);
|
2025-09-13 10:16:58 +05:30
|
|
|
|
2025-08-17 16:11:19 +05:30
|
|
|
if (isReviewerMode) {
|
|
|
|
|
return MockFileAttachmentService();
|
|
|
|
|
}
|
2025-09-13 10:16:58 +05:30
|
|
|
|
2025-12-10 19:40:38 +05:30
|
|
|
// Guard: only provide service when user is logged in
|
2025-08-10 01:20:45 +05:30
|
|
|
final apiService = ref.watch(apiServiceProvider);
|
|
|
|
|
if (apiService == null) return null;
|
2025-12-10 19:40:38 +05:30
|
|
|
|
|
|
|
|
return FileAttachmentService();
|
2025-08-10 01:20:45 +05:30
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// State notifier for managing attached files
|
2025-09-21 22:31:44 +05:30
|
|
|
class AttachedFilesNotifier extends Notifier<List<FileUploadState>> {
|
|
|
|
|
@override
|
|
|
|
|
List<FileUploadState> build() => [];
|
2025-08-10 01:20:45 +05:30
|
|
|
|
2025-10-19 13:50:54 +05:30
|
|
|
void addFiles(List<LocalAttachment> attachments) {
|
|
|
|
|
final newStates = attachments
|
2025-08-10 01:20:45 +05:30
|
|
|
.map(
|
2025-10-19 13:50:54 +05:30
|
|
|
(attachment) => FileUploadState(
|
|
|
|
|
file: attachment.file,
|
|
|
|
|
fileName: attachment.displayName,
|
|
|
|
|
fileSize: attachment.sizeInBytes,
|
2025-08-10 01:20:45 +05:30
|
|
|
progress: 0.0,
|
|
|
|
|
status: FileUploadStatus.pending,
|
2025-10-19 13:50:54 +05:30
|
|
|
isImage: attachment.isImage,
|
2025-08-10 01:20:45 +05:30
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.toList();
|
|
|
|
|
|
|
|
|
|
state = [...state, ...newStates];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void updateFileState(String filePath, FileUploadState newState) {
|
|
|
|
|
state = [
|
|
|
|
|
for (final fileState in state)
|
|
|
|
|
if (fileState.file.path == filePath) newState else fileState,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void removeFile(String filePath) {
|
|
|
|
|
state = state
|
|
|
|
|
.where((fileState) => fileState.file.path != filePath)
|
|
|
|
|
.toList();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void clearAll() {
|
|
|
|
|
state = [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final attachedFilesProvider =
|
2025-09-21 22:31:44 +05:30
|
|
|
NotifierProvider<AttachedFilesNotifier, List<FileUploadState>>(
|
|
|
|
|
AttachedFilesNotifier.new,
|
|
|
|
|
);
|