170 lines
5.3 KiB
Dart
170 lines
5.3 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()}/{entry.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;
|
|
static const int _maxCompressionAttempts = 5;
|
|
static const _signedUrlRefreshBuffer = Duration(minutes: 3);
|
|
static const _maxSignedUrlCacheEntries = 500;
|
|
static final Map<String, _SignedUrlCacheEntry> _signedUrlCache = {};
|
|
|
|
/// Upload a car image for a specific garage 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 {
|
|
final user = supabase.auth.currentUser;
|
|
if (user == null) {
|
|
throw Exception('You must be signed in to upload images.');
|
|
}
|
|
final userId = user.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',
|
|
),
|
|
);
|
|
|
|
_signedUrlCache.remove(path);
|
|
|
|
if (oldPath != null && oldPath.isNotEmpty && oldPath != path) {
|
|
try {
|
|
await deleteCarImage(oldPath);
|
|
} catch (e) {
|
|
debugPrint('StorageService.uploadCarImage cleanup error: $e');
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
final now = DateTime.now();
|
|
final cached = _signedUrlCache[path];
|
|
if (cached != null &&
|
|
now.isBefore(cached.expiresAt.subtract(_signedUrlRefreshBuffer))) {
|
|
return cached.url;
|
|
}
|
|
|
|
try {
|
|
final signed = await supabase.storage
|
|
.from(_bucket)
|
|
.createSignedUrl(path, _signedUrlExpirySeconds);
|
|
_signedUrlCache[path] = _SignedUrlCacheEntry(
|
|
url: signed,
|
|
expiresAt: now.add(const Duration(seconds: _signedUrlExpirySeconds)),
|
|
);
|
|
_pruneSignedUrlCache(now);
|
|
return signed;
|
|
} 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;
|
|
_signedUrlCache.remove(path);
|
|
try {
|
|
await supabase.storage.from(_bucket).remove([path]);
|
|
} catch (e) {
|
|
debugPrint('StorageService.deleteCarImage error: $e');
|
|
}
|
|
}
|
|
|
|
static void invalidateSignedUrl(String? path) {
|
|
if (path == null || path.isEmpty) return;
|
|
_signedUrlCache.remove(path);
|
|
}
|
|
|
|
static void _pruneSignedUrlCache(DateTime now) {
|
|
_signedUrlCache.removeWhere((_, entry) => now.isAfter(entry.expiresAt));
|
|
|
|
if (_signedUrlCache.length <= _maxSignedUrlCacheEntries) return;
|
|
final overflow = _signedUrlCache.length - _maxSignedUrlCacheEntries;
|
|
final keys = _signedUrlCache.keys.take(overflow).toList();
|
|
for (final key in keys) {
|
|
_signedUrlCache.remove(key);
|
|
}
|
|
}
|
|
|
|
static Future<Uint8List> _compressImage(File source) async {
|
|
final bytes = await source.readAsBytes();
|
|
return compute(_compressImageBytes, bytes);
|
|
}
|
|
|
|
/// Performs CPU-intensive image decoding, resizing, and JPEG encoding.
|
|
/// Runs in a background isolate via [compute] to avoid blocking the UI thread.
|
|
static Uint8List _compressImageBytes(Uint8List bytes) {
|
|
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.
|
|
var qualityAttempts = 0;
|
|
while (out.lengthInBytes > _maxImageBytes &&
|
|
quality > 35 &&
|
|
qualityAttempts < _maxCompressionAttempts) {
|
|
quality -= 5;
|
|
out = Uint8List.fromList(img.encodeJpg(working, quality: quality));
|
|
qualityAttempts += 1;
|
|
}
|
|
|
|
// Second pass: reduce dimensions progressively if still above the limit.
|
|
var dimensionAttempts = 0;
|
|
while (out.lengthInBytes > _maxImageBytes &&
|
|
working.width > 320 &&
|
|
dimensionAttempts < _maxCompressionAttempts) {
|
|
final nextWidth = (working.width * 0.85).round();
|
|
working = img.copyResize(working, width: nextWidth);
|
|
out = Uint8List.fromList(img.encodeJpg(working, quality: quality));
|
|
dimensionAttempts += 1;
|
|
}
|
|
|
|
if (out.lengthInBytes > _maxImageBytes) {
|
|
throw Exception(
|
|
'Image is too large after compression (${out.lengthInBytes} bytes).',
|
|
);
|
|
}
|
|
|
|
return out;
|
|
}
|
|
}
|
|
|
|
class _SignedUrlCacheEntry {
|
|
final String url;
|
|
final DateTime expiresAt;
|
|
|
|
const _SignedUrlCacheEntry({required this.url, required this.expiresAt});
|
|
}
|