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 _signedUrlCache = {}; /// Upload a car image for a specific garage entry. /// Returns the storage path on success (e.g. `uid/123.jpg`). static Future 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 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 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 _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. 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}); }