- Change uploadCarImage to throw explicit errors instead of returning null - Handle upload exceptions in garage detail flow with clearer user feedback - Guide users to retry with a smaller/clearer image when upload constraints fail
104 lines
3.1 KiB
Dart
104 lines
3.1 KiB
Dart
import 'dart:io';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:image/image.dart' as img;
|
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
|
import '../main.dart';
|
|
|
|
/// Handles uploading / deleting car images in Supabase Storage.
|
|
///
|
|
/// Bucket: `car-images` (private)
|
|
/// Path: `{auth.uid()}/{hotwheels.id}.jpg`
|
|
class StorageService {
|
|
StorageService._();
|
|
|
|
static const _bucket = 'car-images';
|
|
static const int _maxImageBytes = 500 * 1024;
|
|
static const int _maxWidth = 1080;
|
|
static const int _signedUrlExpirySeconds = 3600;
|
|
|
|
/// Upload a car image for a specific hotwheels entry.
|
|
/// Returns the storage path on success (e.g. `uid/123.jpg`).
|
|
static Future<String> uploadCarImage({
|
|
required File file,
|
|
required int entryId,
|
|
String? oldPath,
|
|
}) async {
|
|
if (oldPath != null && oldPath.isNotEmpty) {
|
|
await deleteCarImage(oldPath);
|
|
}
|
|
|
|
final userId = supabase.auth.currentUser!.id;
|
|
final path = '$userId/$entryId.jpg';
|
|
final compressed = await _compressImage(file);
|
|
|
|
await supabase.storage.from(_bucket).uploadBinary(
|
|
path,
|
|
compressed,
|
|
fileOptions: const FileOptions(
|
|
upsert: true,
|
|
contentType: 'image/jpeg',
|
|
),
|
|
);
|
|
|
|
return path;
|
|
}
|
|
|
|
/// Generates a temporary signed URL for a private image path.
|
|
static Future<String?> createSignedUrl(String? path) async {
|
|
if (path == null || path.isEmpty) return null;
|
|
try {
|
|
return await supabase.storage
|
|
.from(_bucket)
|
|
.createSignedUrl(path, _signedUrlExpirySeconds);
|
|
} catch (e) {
|
|
debugPrint('StorageService.createSignedUrl error: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Deletes an image using its storage path.
|
|
static Future<void> deleteCarImage(String? path) async {
|
|
if (path == null || path.isEmpty) return;
|
|
try {
|
|
await supabase.storage.from(_bucket).remove([path]);
|
|
} catch (e) {
|
|
debugPrint('StorageService.deleteCarImage error: $e');
|
|
}
|
|
}
|
|
|
|
static Future<Uint8List> _compressImage(File source) async {
|
|
final bytes = await source.readAsBytes();
|
|
final decoded = img.decodeImage(bytes);
|
|
if (decoded == null) {
|
|
throw Exception('Invalid image file.');
|
|
}
|
|
|
|
img.Image working = decoded.width > _maxWidth
|
|
? img.copyResize(decoded, width: _maxWidth)
|
|
: decoded;
|
|
|
|
var quality = 85;
|
|
Uint8List out = Uint8List.fromList(img.encodeJpg(working, quality: quality));
|
|
|
|
// First pass: reduce JPEG quality.
|
|
while (out.lengthInBytes > _maxImageBytes && quality > 35) {
|
|
quality -= 5;
|
|
out = Uint8List.fromList(img.encodeJpg(working, quality: quality));
|
|
}
|
|
|
|
// Second pass: reduce dimensions progressively if still above the limit.
|
|
while (out.lengthInBytes > _maxImageBytes && working.width > 320) {
|
|
final nextWidth = (working.width * 0.85).round();
|
|
working = img.copyResize(working, width: nextWidth);
|
|
out = Uint8List.fromList(img.encodeJpg(working, quality: quality));
|
|
}
|
|
|
|
if (out.lengthInBytes > _maxImageBytes) {
|
|
throw Exception(
|
|
'Image is too large after compression (${out.lengthInBytes} bytes).',
|
|
);
|
|
}
|
|
|
|
return out;
|
|
}
|
|
}
|