sojorn/sojorn_app/lib/services/image_upload_service.dart
2026-02-15 00:33:24 -06:00

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;
}