- Rename installed app display name to car64 (Android/iOS/Web titles) - Remove Buy Me a Coffee link from About screen - Update app version to 3.0.0+1 - Migrate scanner workflow to catalog-first flow: - look up in global_cars - show Found in Catalog bottom sheet for known cars - show New Discovery bottom sheet and insert into global_cars + car_votes - insert only into hotwheels for collection entries - Align garage reads to TPB schema by joining global_cars - Switch image field usage from image_url to user_image_url - Implement private-storage image service: - upload path auth.uid()/hotwheels.id.jpg - signed URL generation (1h) for display - in-app compression pipeline targeting <=500KB and max 1080px width - Use local brand fallback image assets/img/icon_bg_removed.png for missing car photos - Restrict edit dialog to personal notes (hotwheels) instead of global car metadata - Ensure first login auto-creates a default collection and owner membership
103 lines
3 KiB
Dart
103 lines
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.');
|
|
}
|
|
|
|
final resized = decoded.width > _maxWidth
|
|
? img.copyResize(decoded, width: _maxWidth)
|
|
: decoded;
|
|
|
|
var quality = 85;
|
|
Uint8List out = Uint8List.fromList(img.encodeJpg(resized, quality: quality));
|
|
|
|
while (out.lengthInBytes > _maxImageBytes && quality > 35) {
|
|
quality -= 10;
|
|
out = Uint8List.fromList(img.encodeJpg(resized, quality: quality));
|
|
}
|
|
|
|
if (out.lengthInBytes > _maxImageBytes) {
|
|
final reduced = img.copyResize(
|
|
resized,
|
|
width: (resized.width * 0.8).round(),
|
|
);
|
|
out = Uint8List.fromList(img.encodeJpg(reduced, quality: 45));
|
|
}
|
|
|
|
return out;
|
|
}
|
|
}
|