hwhub/lib/services/storage_service.dart
Lukas Müllner aab5c7d039 fix(storage): enforce strict <=500KB upload guarantee for private bucket
- Harden compression pipeline with iterative quality + dimension reduction
- Throw explicit error if final output still exceeds 500KB bucket limit
- Keep 1080px cap while allowing progressive downscaling to fit constraints
- Update login screen branding comment to car64
2026-03-04 11:30:32 +01:00

109 lines
3.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()}/{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 {
try {
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;
} catch (e) {
debugPrint('StorageService.uploadCarImage error: $e');
return null;
}
}
/// 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;
}
}