581 lines
16 KiB
Dart
581 lines
16 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:http_parser/http_parser.dart' as http_parser;
|
|
import 'package:image/image.dart' as img;
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
import 'media_sanitizer.dart';
|
|
import '../config/api_config.dart';
|
|
import '../models/image_filter.dart';
|
|
import 'auth_service.dart';
|
|
|
|
/// Result of an image upload operation
|
|
class UploadResult {
|
|
final String uploadUrl;
|
|
final String publicUrl;
|
|
final String fileName;
|
|
final int fileSize;
|
|
final int? width;
|
|
final int? height;
|
|
|
|
const UploadResult({
|
|
required this.uploadUrl,
|
|
required this.publicUrl,
|
|
required this.fileName,
|
|
required this.fileSize,
|
|
this.width,
|
|
this.height,
|
|
});
|
|
|
|
factory UploadResult.fromJson(Map<String, dynamic> json) {
|
|
final signedUrl = json['signedUrl'] ?? json['signed_url'];
|
|
final resolvedPublicUrl = signedUrl ?? json['publicUrl'] ?? json['public_url'];
|
|
|
|
return UploadResult(
|
|
uploadUrl: (json['uploadUrl'] ?? json['upload_url']) as String,
|
|
publicUrl: (resolvedPublicUrl ?? '') as String,
|
|
fileName: (json['fileName'] ?? json['file_name'] ?? '') as String,
|
|
fileSize: (json['fileSize'] ?? json['file_size'] ?? 0) as int,
|
|
width: json['width'] as int?,
|
|
height: json['height'] as int?,
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toMap() {
|
|
return {
|
|
'uploadUrl': uploadUrl,
|
|
'publicUrl': publicUrl,
|
|
'fileName': fileName,
|
|
'fileSize': fileSize,
|
|
'width': width,
|
|
'height': height,
|
|
};
|
|
}
|
|
}
|
|
|
|
/// Progress callback for upload operations
|
|
typedef UploadProgressCallback = void Function(double progress);
|
|
|
|
/// Service for uploading images AND videos to Cloudflare R2 via Go Backend
|
|
class ImageUploadService {
|
|
final AuthService _auth = AuthService.instance;
|
|
final _storage = const FlutterSecureStorage();
|
|
|
|
/// Get the current authentication token
|
|
Future<String?> _getAuthToken() async {
|
|
return _auth.accessToken;
|
|
}
|
|
|
|
/// Default upload settings
|
|
static const int defaultMaxWidth = 1920;
|
|
static const int defaultMaxHeight = 1920;
|
|
static const int defaultQuality = 85;
|
|
|
|
// =========================================================
|
|
// NEW: Streamed Video Upload (Prevents OutOfMemory Errors)
|
|
// =========================================================
|
|
Future<String> uploadVideo(
|
|
File videoFile, {
|
|
UploadProgressCallback? onProgress,
|
|
}) async {
|
|
final token = await _getAuthToken();
|
|
if (token == null) {
|
|
throw UploadException('Not authenticated. Please sign in again.');
|
|
}
|
|
|
|
// Use Go API upload endpoint with R2 integration
|
|
final uri = Uri.parse('${ApiConfig.baseUrl}/upload');
|
|
|
|
final request = http.MultipartRequest('POST', uri);
|
|
|
|
request.headers['Authorization'] = 'Bearer $token';
|
|
|
|
// CRITICAL: Use fromPath to stream from disk instead of loading into memory
|
|
final fileLength = await videoFile.length();
|
|
request.files.add(await http.MultipartFile.fromPath(
|
|
'media', // Field name matches upload-media
|
|
videoFile.path,
|
|
contentType: http_parser.MediaType.parse('video/mp4'),
|
|
));
|
|
|
|
request.fields['type'] = 'video';
|
|
request.fields['fileName'] = videoFile.path.split('/').last;
|
|
|
|
onProgress?.call(0.1);
|
|
|
|
try {
|
|
final streamedResponse = await request.send();
|
|
final response = await http.Response.fromStream(streamedResponse);
|
|
|
|
onProgress?.call(1.0);
|
|
|
|
if (response.statusCode != 200) {
|
|
final errorData = jsonDecode(response.body) as Map<String, dynamic>;
|
|
throw UploadException(errorData['message'] ?? 'Upload failed');
|
|
}
|
|
|
|
final responseData = jsonDecode(response.body) as Map<String, dynamic>;
|
|
// Return publicUrl or signedUrl depending on your function response
|
|
final url = (responseData['publicUrl'] ?? responseData['signedUrl']) as String;
|
|
return _fixR2Url(url);
|
|
} catch (e) {
|
|
throw UploadException('Video upload failed: $e');
|
|
}
|
|
}
|
|
|
|
// =========================================================
|
|
// Existing Image Logic (Preserved)
|
|
// =========================================================
|
|
|
|
/// Uploads an image file with optional filtering
|
|
Future<String> uploadImage(
|
|
File imageFile, {
|
|
ImageFilter? filter,
|
|
int maxWidth = defaultMaxWidth,
|
|
int maxHeight = defaultMaxHeight,
|
|
int quality = defaultQuality,
|
|
UploadProgressCallback? onProgress,
|
|
}) async {
|
|
// 1. Auth Check
|
|
final token = await _getAuthToken();
|
|
if (token == null) {
|
|
throw UploadException('Not authenticated. Please sign in again.');
|
|
}
|
|
|
|
File sanitizedFile;
|
|
bool useRawUpload = false;
|
|
try {
|
|
sanitizedFile = await MediaSanitizer.sanitizeImage(imageFile);
|
|
} catch (e) {
|
|
final message = e.toString();
|
|
if (message.contains('Unsupported operation') || message.contains('_Namespace')) {
|
|
// Fallback: upload original bytes without processing for unsupported formats.
|
|
useRawUpload = true;
|
|
sanitizedFile = imageFile;
|
|
} else {
|
|
throw UploadException('Image sanitization failed: $e');
|
|
}
|
|
}
|
|
|
|
final fileName = sanitizedFile.path.split('/').last;
|
|
final contentType = useRawUpload ? _contentTypeForFileName(fileName) : 'image/jpeg';
|
|
|
|
// 2. Process image with filter if provided
|
|
Uint8List fileBytes;
|
|
|
|
if (useRawUpload) {
|
|
fileBytes = await sanitizedFile.readAsBytes();
|
|
} else if (filter != null && filter.id != 'none') {
|
|
onProgress?.call(0.1);
|
|
final processed = await _processImage(sanitizedFile, filter, maxWidth, maxHeight, quality);
|
|
fileBytes = processed.bytes;
|
|
} else {
|
|
// Just resize without filter
|
|
final resized = await _resizeImage(sanitizedFile, maxWidth, maxHeight, quality);
|
|
fileBytes = resized.bytes;
|
|
}
|
|
|
|
onProgress?.call(0.2);
|
|
|
|
|
|
return _uploadBytes(
|
|
fileBytes: fileBytes,
|
|
fileName: fileName,
|
|
contentType: contentType,
|
|
token: token,
|
|
onProgress: onProgress,
|
|
);
|
|
}
|
|
|
|
/// Uploads image bytes directly (web-safe).
|
|
Future<String> uploadImageBytes(
|
|
Uint8List imageBytes, {
|
|
String? fileName,
|
|
ImageFilter? filter,
|
|
int maxWidth = defaultMaxWidth,
|
|
int maxHeight = defaultMaxHeight,
|
|
int quality = defaultQuality,
|
|
UploadProgressCallback? onProgress,
|
|
}) async {
|
|
final token = await _getAuthToken();
|
|
if (token == null) {
|
|
throw UploadException('Not authenticated. Please sign in again.');
|
|
}
|
|
|
|
final safeName = (fileName != null && fileName.isNotEmpty)
|
|
? fileName
|
|
: 'upload_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
|
const contentType = 'image/jpeg';
|
|
|
|
Uint8List fileBytes;
|
|
if (filter != null && filter.id != 'none') {
|
|
onProgress?.call(0.1);
|
|
final processed =
|
|
await _processImageBytes(imageBytes, filter, maxWidth, maxHeight, quality);
|
|
fileBytes = processed.bytes;
|
|
} else {
|
|
final resized =
|
|
await _resizeImageBytes(imageBytes, maxWidth, maxHeight, quality);
|
|
fileBytes = resized.bytes;
|
|
}
|
|
|
|
onProgress?.call(0.2);
|
|
|
|
return _uploadBytes(
|
|
fileBytes: fileBytes,
|
|
fileName: safeName,
|
|
contentType: contentType,
|
|
token: token,
|
|
onProgress: onProgress,
|
|
);
|
|
}
|
|
|
|
/// Uploads multiple images
|
|
Future<List<String>> uploadMultiple(
|
|
List<File> imageFiles, {
|
|
ImageFilter? filter,
|
|
void Function(int current, int total)? onProgress,
|
|
}) async {
|
|
final results = <String>[];
|
|
final total = imageFiles.length;
|
|
|
|
for (int i = 0; i < imageFiles.length; i++) {
|
|
try {
|
|
final url = await uploadImage(imageFiles[i], filter: filter);
|
|
results.add(url);
|
|
onProgress?.call(i + 1, total);
|
|
} catch (e) {
|
|
throw UploadException('Failed to upload image ${i + 1}/$total: $e');
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// --- Internal Processing Helpers ---
|
|
|
|
Future<_ProcessedImage> _processImage(
|
|
File imageFile,
|
|
ImageFilter filter,
|
|
int maxWidth,
|
|
int maxHeight,
|
|
int quality,
|
|
) async {
|
|
final bytes = await imageFile.readAsBytes();
|
|
final image = img.decodeImage(bytes);
|
|
|
|
if (image == null) {
|
|
throw UploadException('Failed to decode image');
|
|
}
|
|
|
|
if (filter.brightness != 1.0) {
|
|
img.adjustColor(image, brightness: filter.brightness - 1.0);
|
|
}
|
|
if (filter.contrast != 1.0) {
|
|
img.adjustColor(image, contrast: filter.contrast - 1.0);
|
|
}
|
|
if (filter.saturation != 1.0) {
|
|
img.adjustColor(image, saturation: filter.saturation - 1.0);
|
|
}
|
|
|
|
if (filter.vignette > 0) {
|
|
_applyVignette(image, filter.vignette);
|
|
}
|
|
|
|
final resized = _resizeMaintainAspectRatio(image, maxWidth, maxHeight);
|
|
final outputBytes = img.encodeJpg(resized, quality: quality);
|
|
|
|
return _ProcessedImage(
|
|
bytes: outputBytes,
|
|
width: resized.width,
|
|
height: resized.height,
|
|
);
|
|
}
|
|
|
|
Future<_ProcessedImage> _processImageBytes(
|
|
Uint8List bytes,
|
|
ImageFilter filter,
|
|
int maxWidth,
|
|
int maxHeight,
|
|
int quality,
|
|
) async {
|
|
final image = img.decodeImage(bytes);
|
|
|
|
if (image == null) {
|
|
throw UploadException('Failed to decode image');
|
|
}
|
|
|
|
if (filter.brightness != 1.0) {
|
|
img.adjustColor(image, brightness: filter.brightness - 1.0);
|
|
}
|
|
if (filter.contrast != 1.0) {
|
|
img.adjustColor(image, contrast: filter.contrast - 1.0);
|
|
}
|
|
if (filter.saturation != 1.0) {
|
|
img.adjustColor(image, saturation: filter.saturation - 1.0);
|
|
}
|
|
|
|
if (filter.vignette > 0) {
|
|
_applyVignette(image, filter.vignette);
|
|
}
|
|
|
|
final resized = _resizeMaintainAspectRatio(image, maxWidth, maxHeight);
|
|
final outputBytes = img.encodeJpg(resized, quality: quality);
|
|
|
|
return _ProcessedImage(
|
|
bytes: outputBytes,
|
|
width: resized.width,
|
|
height: resized.height,
|
|
);
|
|
}
|
|
|
|
Future<_ProcessedImage> _resizeImage(
|
|
File imageFile,
|
|
int maxWidth,
|
|
int maxHeight,
|
|
int quality,
|
|
) async {
|
|
final bytes = await imageFile.readAsBytes();
|
|
final image = img.decodeImage(bytes);
|
|
|
|
if (image == null) {
|
|
throw UploadException('Failed to decode image');
|
|
}
|
|
|
|
final resized = _resizeMaintainAspectRatio(image, maxWidth, maxHeight);
|
|
final outputBytes = img.encodeJpg(resized, quality: quality);
|
|
|
|
return _ProcessedImage(
|
|
bytes: outputBytes,
|
|
width: resized.width,
|
|
height: resized.height,
|
|
);
|
|
}
|
|
|
|
img.Image _resizeMaintainAspectRatio(img.Image image, int maxWidth, int maxHeight) {
|
|
if (image.width <= maxWidth && image.height <= maxHeight) {
|
|
return image;
|
|
}
|
|
|
|
final widthRatio = maxWidth / image.width;
|
|
final heightRatio = maxHeight / image.height;
|
|
final ratio = widthRatio < heightRatio ? widthRatio : heightRatio;
|
|
|
|
final newWidth = (image.width * ratio).round();
|
|
final newHeight = (image.height * ratio).round();
|
|
|
|
return img.copyResize(image, width: newWidth, height: newHeight);
|
|
}
|
|
|
|
void _applyVignette(img.Image image, double intensity) {
|
|
// Vignette logic placeholder
|
|
}
|
|
|
|
Future<_ProcessedImage> _resizeImageBytes(
|
|
Uint8List bytes,
|
|
int maxWidth,
|
|
int maxHeight,
|
|
int quality,
|
|
) async {
|
|
final image = img.decodeImage(bytes);
|
|
|
|
if (image == null) {
|
|
throw UploadException('Failed to decode image');
|
|
}
|
|
|
|
final resized = _resizeMaintainAspectRatio(image, maxWidth, maxHeight);
|
|
final outputBytes = img.encodeJpg(resized, quality: quality);
|
|
|
|
return _ProcessedImage(
|
|
bytes: outputBytes,
|
|
width: resized.width,
|
|
height: resized.height,
|
|
);
|
|
}
|
|
|
|
Future<ImageValidationResult> validateImage(File imageFile) async {
|
|
final fileName = imageFile.path.split('/').last;
|
|
final extension = fileName.split('.').last.toLowerCase();
|
|
|
|
const supportedFormats = {'jpg', 'jpeg', 'png', 'gif', 'webp'};
|
|
if (!supportedFormats.contains(extension)) {
|
|
return ImageValidationResult(
|
|
isValid: false,
|
|
error: 'Unsupported file format: $extension',
|
|
);
|
|
}
|
|
|
|
final fileSize = await imageFile.length();
|
|
const maxSize = 10 * 1024 * 1024; // 10MB
|
|
if (fileSize > maxSize) {
|
|
return ImageValidationResult(
|
|
isValid: false,
|
|
error: 'File size exceeds 10MB limit',
|
|
);
|
|
}
|
|
|
|
try {
|
|
final bytes = await imageFile.readAsBytes();
|
|
final image = img.decodeImage(bytes);
|
|
|
|
if (image == null) {
|
|
return ImageValidationResult(
|
|
isValid: false,
|
|
error: 'Invalid image file',
|
|
);
|
|
}
|
|
|
|
return ImageValidationResult(
|
|
isValid: true,
|
|
width: image.width,
|
|
height: image.height,
|
|
fileSize: fileSize,
|
|
format: extension,
|
|
);
|
|
} catch (e) {
|
|
return ImageValidationResult(
|
|
isValid: false,
|
|
error: 'Failed to read image: $e',
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<String> _uploadBytes({
|
|
required Uint8List fileBytes,
|
|
required String fileName,
|
|
required String contentType,
|
|
required String token,
|
|
UploadProgressCallback? onProgress,
|
|
}) async {
|
|
try {
|
|
final uri = Uri.parse('${ApiConfig.baseUrl}/upload');
|
|
final request = http.MultipartRequest('POST', uri);
|
|
|
|
request.headers['Authorization'] = 'Bearer $token';
|
|
|
|
request.files.add(http.MultipartFile.fromBytes(
|
|
'image',
|
|
fileBytes,
|
|
filename: fileName,
|
|
contentType: http_parser.MediaType.parse(contentType),
|
|
));
|
|
|
|
request.fields['fileName'] = fileName;
|
|
|
|
onProgress?.call(0.3);
|
|
|
|
final streamedResponse = await request.send();
|
|
final response = await http.Response.fromStream(streamedResponse);
|
|
|
|
onProgress?.call(0.9);
|
|
|
|
if (response.statusCode != 200) {
|
|
final errorData = jsonDecode(response.body) as Map<String, dynamic>;
|
|
final errorMsg = errorData['error'] ?? 'Unknown error';
|
|
throw UploadException('Upload failed: $errorMsg');
|
|
}
|
|
|
|
final responseData = jsonDecode(response.body) as Map<String, dynamic>;
|
|
final signedUrl = responseData['signedUrl'] ?? responseData['signed_url'];
|
|
final publicUrl = (signedUrl ?? responseData['publicUrl']) as String;
|
|
|
|
onProgress?.call(1.0);
|
|
|
|
// FORCE FIX: Ensure custom domain is used even if backend returns raw R2 URL
|
|
return _fixR2Url(publicUrl);
|
|
} catch (e, stack) {
|
|
throw UploadException(e.toString());
|
|
}
|
|
}
|
|
|
|
/// Helper to force custom domains if raw R2 URLs slip through
|
|
String _fixR2Url(String url) {
|
|
if (url.contains('gosojorn.com')) {
|
|
return url.replaceAll('gosojorn.com', 'sojorn.net');
|
|
}
|
|
|
|
if (!url.contains('.r2.cloudflarestorage.com')) return url;
|
|
|
|
// Fix Image URLs
|
|
if (url.contains('/sojorn-media/')) {
|
|
final key = url.split('/sojorn-media/').last;
|
|
return 'https://img.sojorn.net/$key';
|
|
}
|
|
|
|
// Fix Video URLs
|
|
if (url.contains('/sojorn-videos/')) {
|
|
final key = url.split('/sojorn-videos/').last;
|
|
return 'https://quips.sojorn.net/$key';
|
|
}
|
|
|
|
return url;
|
|
}
|
|
|
|
String _contentTypeForFileName(String fileName) {
|
|
final extension = fileName.split('.').last.toLowerCase();
|
|
switch (extension) {
|
|
case 'jpg':
|
|
case 'jpeg':
|
|
return 'image/jpeg';
|
|
case 'png':
|
|
return 'image/png';
|
|
case 'gif':
|
|
return 'image/gif';
|
|
case 'webp':
|
|
return 'image/webp';
|
|
case 'svg':
|
|
return 'image/svg+xml';
|
|
case 'heic':
|
|
case 'heif':
|
|
return 'image/heic';
|
|
default:
|
|
return 'application/octet-stream';
|
|
}
|
|
}
|
|
}
|
|
|
|
class ImageValidationResult {
|
|
final bool isValid;
|
|
final String? error;
|
|
final int? width;
|
|
final int? height;
|
|
final int? fileSize;
|
|
final String? format;
|
|
|
|
const ImageValidationResult({
|
|
required this.isValid,
|
|
this.error,
|
|
this.width,
|
|
this.height,
|
|
this.fileSize,
|
|
this.format,
|
|
});
|
|
|
|
String getFormattedSize() {
|
|
if (fileSize == null) return 'Unknown';
|
|
if (fileSize! < 1024) return '$fileSize B';
|
|
if (fileSize! < 1024 * 1024) return '${(fileSize! / 1024).toStringAsFixed(1)} KB';
|
|
return '${(fileSize! / (1024 * 1024)).toStringAsFixed(2)} MB';
|
|
}
|
|
}
|
|
|
|
class _ProcessedImage {
|
|
final Uint8List bytes;
|
|
final int width;
|
|
final int height;
|
|
|
|
const _ProcessedImage({
|
|
required this.bytes,
|
|
required this.width,
|
|
required this.height,
|
|
});
|
|
}
|
|
|
|
class UploadException implements Exception {
|
|
final String message;
|
|
UploadException(this.message);
|
|
@override
|
|
String toString() => message;
|
|
}
|