hwhub/lib/services/storage_service.dart
Lukas Müllner d8dbc2573a feat(v3.0): rebrand to car64 and align app flow with TPB schema
- 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
2026-03-04 10:12:12 +01:00

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;
}
}