hwhub/lib/services/storage_service.dart

159 lines
4.8 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 {
if (oldPath != null && oldPath.isNotEmpty) {
await deleteCarImage(oldPath);
}
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);
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();
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;
var attempts = 0;
Uint8List out = Uint8List.fromList(img.encodeJpg(working, quality: quality));
// First pass: reduce JPEG quality.
while (out.lengthInBytes > _maxImageBytes &&
quality > 35 &&
attempts < _maxCompressionAttempts) {
quality -= 5;
out = Uint8List.fromList(img.encodeJpg(working, quality: quality));
attempts += 1;
}
// Second pass: reduce dimensions progressively if still above the limit.
while (out.lengthInBytes > _maxImageBytes &&
working.width > 320 &&
attempts < _maxCompressionAttempts) {
final nextWidth = (working.width * 0.85).round();
working = img.copyResize(working, width: nextWidth);
out = Uint8List.fromList(img.encodeJpg(working, quality: quality));
attempts += 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});
}