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
This commit is contained in:
parent
2c6ae514fb
commit
d8dbc2573a
16 changed files with 462 additions and 451 deletions
|
|
@ -1,6 +1,6 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="HW Collector Hub"
|
||||
android:label="car64"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
|
|
|
|||
BIN
assets/img/icon_bg_removed.png
Normal file
BIN
assets/img/icon_bg_removed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
|
|
@ -7,7 +7,7 @@
|
|||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>HW Collector Hub</string>
|
||||
<string>car64</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>HW Collector Hub</string>
|
||||
<string>car64</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
import 'services/collection_service.dart';
|
||||
import 'screens/login_screen.dart';
|
||||
import 'screens/home_shell.dart';
|
||||
|
||||
|
|
@ -20,7 +21,7 @@ Future<void> main() async {
|
|||
anonKey: _supabaseAnonKey,
|
||||
);
|
||||
|
||||
runApp(const HWHubApp());
|
||||
runApp(const Car64App());
|
||||
}
|
||||
|
||||
/// Convenience accessor used throughout the app.
|
||||
|
|
@ -49,13 +50,13 @@ Future<T?> showGlobalDialog<T>({required WidgetBuilder builder}) {
|
|||
}
|
||||
|
||||
// ── Root App Widget ───────────────────────────────────────────────────
|
||||
class HWHubApp extends StatelessWidget {
|
||||
const HWHubApp({super.key});
|
||||
class Car64App extends StatelessWidget {
|
||||
const Car64App({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'HW Collector Hub',
|
||||
title: 'car64',
|
||||
debugShowCheckedModeBanner: false,
|
||||
navigatorKey: navigatorKey,
|
||||
scaffoldMessengerKey: scaffoldMessengerKey,
|
||||
|
|
@ -78,12 +79,14 @@ class AuthGate extends StatefulWidget {
|
|||
class _AuthGateState extends State<AuthGate> {
|
||||
bool _isLoading = true;
|
||||
Session? _session;
|
||||
String? _lastEnsuredUserId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_session = supabase.auth.currentSession;
|
||||
_ensureDefaultCollectionIfNeeded();
|
||||
|
||||
supabase.auth.onAuthStateChange.listen(
|
||||
(AuthState authState) {
|
||||
|
|
@ -97,6 +100,8 @@ class _AuthGateState extends State<AuthGate> {
|
|||
setState(() {});
|
||||
}
|
||||
|
||||
_ensureDefaultCollectionIfNeeded();
|
||||
|
||||
if (authState.event == AuthChangeEvent.passwordRecovery) {
|
||||
_showResetPasswordDialog();
|
||||
}
|
||||
|
|
@ -109,6 +114,18 @@ class _AuthGateState extends State<AuthGate> {
|
|||
setState(() => _isLoading = false);
|
||||
}
|
||||
|
||||
Future<void> _ensureDefaultCollectionIfNeeded() async {
|
||||
final userId = _session?.user.id;
|
||||
if (userId == null || userId == _lastEnsuredUserId) return;
|
||||
|
||||
_lastEnsuredUserId = userId;
|
||||
try {
|
||||
await CollectionService.ensureDefaultCollection();
|
||||
} catch (e) {
|
||||
showGlobalSnackBar('Collection setup failed: $e', isError: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showResetPasswordDialog() async {
|
||||
await showDialog<void>(
|
||||
context: navigatorKey.currentContext!,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
/// Full-page About screen with app info, version, and links.
|
||||
|
|
@ -25,11 +24,6 @@ class _AboutScreenState extends State<AboutScreen> {
|
|||
if (mounted) setState(() => _packageInfo = info);
|
||||
}
|
||||
|
||||
Future<void> _openUrl(String url) async {
|
||||
final uri = Uri.parse(url);
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
|
@ -65,7 +59,7 @@ class _AboutScreenState extends State<AboutScreen> {
|
|||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: Text(
|
||||
'HW Collector Hub',
|
||||
'car64',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
|
|
@ -95,29 +89,6 @@ class _AboutScreenState extends State<AboutScreen> {
|
|||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Links section ──
|
||||
Text(
|
||||
'Links',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
_LinkTile(
|
||||
icon: Icons.coffee,
|
||||
iconColor: const Color(0xFFFFDD00),
|
||||
title: 'Buy Me a Coffee',
|
||||
subtitle: 'Support the development',
|
||||
onTap: () =>
|
||||
_openUrl('https://buymeacoffee.com/derkauzigekoala'),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Technical details ──
|
||||
Text(
|
||||
'Technical',
|
||||
|
|
@ -152,7 +123,7 @@ class _AboutScreenState extends State<AboutScreen> {
|
|||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'© 2026 HW Collector Hub',
|
||||
'© 2026 car64',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
|
|
@ -167,49 +138,6 @@ class _AboutScreenState extends State<AboutScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Link tile ─────────────────────────────────────────────────────────
|
||||
|
||||
class _LinkTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color iconColor;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _LinkTile({
|
||||
required this.icon,
|
||||
required this.iconColor,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: ListTile(
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: iconColor.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(icon, color: iconColor, size: 22),
|
||||
),
|
||||
title: Text(
|
||||
title,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(subtitle, style: const TextStyle(fontSize: 12)),
|
||||
trailing: const Icon(Icons.open_in_new, color: AppColors.textHint, size: 18),
|
||||
onTap: onTap,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Info row (label : value) ──────────────────────────────────────────
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
|
|
|
|||
|
|
@ -55,13 +55,26 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
try {
|
||||
final data = await supabase
|
||||
.from('hotwheels')
|
||||
.select()
|
||||
.select(
|
||||
'id, created_at, hw_id, notes, user_image_url, global_cars(name, series, year, color)')
|
||||
.eq('collection_id', widget.collectionId)
|
||||
.order('created_at', ascending: false);
|
||||
|
||||
final rows = List<Map<String, dynamic>>.from(data);
|
||||
final withSignedUrls = await Future.wait(
|
||||
rows.map((row) async {
|
||||
final path = row['user_image_url'] as String?;
|
||||
final signed = await StorageService.createSignedUrl(path);
|
||||
return {
|
||||
...row,
|
||||
'signed_image_url': signed,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_cars = List<Map<String, dynamic>>.from(data);
|
||||
_cars = withSignedUrls;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
|
|
@ -77,9 +90,10 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
if (_searchQuery.isEmpty) return _cars;
|
||||
final q = _searchQuery.toLowerCase();
|
||||
return _cars.where((car) {
|
||||
final global = car['global_cars'] as Map<String, dynamic>?;
|
||||
final id = (car['hw_id'] as String? ?? '').toLowerCase();
|
||||
final name = (car['name'] as String? ?? '').toLowerCase();
|
||||
final series = (car['series'] as String? ?? '').toLowerCase();
|
||||
final name = (global?['name'] as String? ?? '').toLowerCase();
|
||||
final series = (global?['series'] as String? ?? '').toLowerCase();
|
||||
return id.contains(q) || name.contains(q) || series.contains(q);
|
||||
}).toList();
|
||||
}
|
||||
|
|
@ -205,13 +219,14 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final car = _filteredCars[index];
|
||||
final global = car['global_cars'] as Map<String, dynamic>?;
|
||||
return CarCard(
|
||||
hwId: car['hw_id'] as String? ?? '???',
|
||||
name: car['name'] as String?,
|
||||
series: car['series'] as String?,
|
||||
year: car['year'] as int?,
|
||||
color: car['color'] as String?,
|
||||
imageUrl: car['image_url'] as String?,
|
||||
name: global?['name'] as String?,
|
||||
series: global?['series'] as String?,
|
||||
year: global?['year'] as int?,
|
||||
color: global?['color'] as String?,
|
||||
imageUrl: car['signed_image_url'] as String?,
|
||||
addedAt: car['created_at'] != null
|
||||
? DateTime.tryParse(car['created_at'])
|
||||
: null,
|
||||
|
|
@ -239,12 +254,13 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
}
|
||||
|
||||
void _showCarDetails(Map<String, dynamic> car) {
|
||||
final global = car['global_cars'] as Map<String, dynamic>?;
|
||||
final hwId = car['hw_id'] as String? ?? '???';
|
||||
final name = car['name'] as String?;
|
||||
final series = car['series'] as String?;
|
||||
final year = car['year'] as int?;
|
||||
final name = global?['name'] as String?;
|
||||
final series = global?['series'] as String?;
|
||||
final year = global?['year'] as int?;
|
||||
final notes = car['notes'] as String?;
|
||||
final imageUrl = car['image_url'] as String?;
|
||||
final imageUrl = car['signed_image_url'] as String?;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
|
|
@ -296,10 +312,16 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
child: TextButton.icon(
|
||||
onPressed: () => _updatePhoto(car, context),
|
||||
icon: Icon(
|
||||
imageUrl != null ? Icons.camera_alt : Icons.add_a_photo,
|
||||
(car['user_image_url'] as String?) != null
|
||||
? Icons.camera_alt
|
||||
: Icons.add_a_photo,
|
||||
size: 18,
|
||||
),
|
||||
label: Text(imageUrl != null ? 'Change Photo' : 'Add Photo'),
|
||||
label: Text(
|
||||
(car['user_image_url'] as String?) != null
|
||||
? 'Change Photo'
|
||||
: 'Add Photo',
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
|
@ -384,7 +406,12 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
return Container(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.backgroundLight,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Image.asset(
|
||||
'assets/img/icon_bg_removed.png',
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) => Center(
|
||||
child: Icon(
|
||||
Icons.directions_car_filled,
|
||||
size: 48,
|
||||
|
|
@ -393,6 +420,8 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
: AppColors.orange.withValues(alpha: 0.25),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -410,13 +439,14 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
|
||||
showGlobalSnackBar('Uploading photo…');
|
||||
|
||||
final oldImageUrl = car['image_url'] as String?;
|
||||
final url = await StorageService.uploadCarImage(
|
||||
final oldPath = car['user_image_url'] as String?;
|
||||
final newPath = await StorageService.uploadCarImage(
|
||||
file: File(xFile.path),
|
||||
oldImageUrl: oldImageUrl,
|
||||
entryId: car['id'] as int,
|
||||
oldPath: oldPath,
|
||||
);
|
||||
|
||||
if (url == null) {
|
||||
if (newPath == null) {
|
||||
showGlobalSnackBar('Failed to upload photo.', isError: true);
|
||||
return;
|
||||
}
|
||||
|
|
@ -424,7 +454,7 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
try {
|
||||
await supabase
|
||||
.from('hotwheels')
|
||||
.update({'image_url': url})
|
||||
.update({'user_image_url': newPath})
|
||||
.eq('id', car['id']);
|
||||
|
||||
showGlobalSnackBar('Photo updated!');
|
||||
|
|
@ -485,7 +515,7 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
|
||||
try {
|
||||
// Delete image from storage first (best-effort).
|
||||
await StorageService.deleteCarImage(car['image_url'] as String?);
|
||||
await StorageService.deleteCarImage(car['user_image_url'] as String?);
|
||||
|
||||
await supabase
|
||||
.from('hotwheels')
|
||||
|
|
@ -651,28 +681,17 @@ class _EditCarDialog extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _EditCarDialogState extends State<_EditCarDialog> {
|
||||
late final TextEditingController _nameCtrl;
|
||||
late final TextEditingController _seriesCtrl;
|
||||
late final TextEditingController _yearCtrl;
|
||||
late final TextEditingController _notesCtrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameCtrl = TextEditingController(text: widget.car['name'] as String? ?? '');
|
||||
_seriesCtrl =
|
||||
TextEditingController(text: widget.car['series'] as String? ?? '');
|
||||
final year = widget.car['year'];
|
||||
_yearCtrl = TextEditingController(text: year != null ? '$year' : '');
|
||||
_notesCtrl =
|
||||
TextEditingController(text: widget.car['notes'] as String? ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
_seriesCtrl.dispose();
|
||||
_yearCtrl.dispose();
|
||||
_notesCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
|
@ -680,14 +699,8 @@ class _EditCarDialogState extends State<_EditCarDialog> {
|
|||
void _save() {
|
||||
final updates = <String, dynamic>{};
|
||||
|
||||
final name = _nameCtrl.text.trim();
|
||||
final series = _seriesCtrl.text.trim();
|
||||
final yearStr = _yearCtrl.text.trim();
|
||||
final notes = _notesCtrl.text.trim();
|
||||
|
||||
updates['name'] = name.isEmpty ? null : name;
|
||||
updates['series'] = series.isEmpty ? null : series;
|
||||
updates['year'] = yearStr.isEmpty ? null : int.tryParse(yearStr);
|
||||
updates['notes'] = notes.isEmpty ? null : notes;
|
||||
|
||||
Navigator.pop(context, updates);
|
||||
|
|
@ -710,34 +723,9 @@ class _EditCarDialogState extends State<_EditCarDialog> {
|
|||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _nameCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Car Name',
|
||||
hintText: "e.g. '70 Dodge Charger",
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _seriesCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Series',
|
||||
hintText: 'e.g. HW Flames',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _yearCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Year',
|
||||
hintText: 'e.g. 2025',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _notesCtrl,
|
||||
maxLines: 2,
|
||||
maxLines: 4,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Notes',
|
||||
hintText: 'Any extra info…',
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ class _LoginScreenState extends State<LoginScreen>
|
|||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'HW COLLECTOR HUB',
|
||||
'CAR64',
|
||||
style: TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w700,
|
||||
|
|
@ -300,7 +300,7 @@ class _LoginScreenState extends State<LoginScreen>
|
|||
|
||||
// ── Footer ──
|
||||
Text(
|
||||
'© 2026 HW Collector Hub',
|
||||
'© 2026 car64',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ class ProfileScreen extends StatelessWidget {
|
|||
_SettingsTile(
|
||||
icon: Icons.info_outline,
|
||||
title: 'About',
|
||||
subtitle: 'HW Collector Hub',
|
||||
subtitle: 'car64',
|
||||
onTap: () => _showAbout(context),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
|
@ -131,7 +131,7 @@ class ProfileScreen extends StatelessWidget {
|
|||
const SizedBox(height: 40),
|
||||
const Center(
|
||||
child: Text(
|
||||
'© 2026 HW Collector Hub',
|
||||
'© 2026 car64',
|
||||
style:
|
||||
TextStyle(fontSize: 12, color: AppColors.textHint),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,14 +1,9 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import '../main.dart';
|
||||
import '../scanner_screen.dart';
|
||||
import '../services/collection_service.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
/// The "Scan" tab — quick-access view for scanning / adding cars.
|
||||
class ScanTab extends StatefulWidget {
|
||||
const ScanTab({super.key});
|
||||
|
||||
|
|
@ -28,7 +23,6 @@ class ScanTabState extends State<ScanTab> {
|
|||
_loadCollections();
|
||||
}
|
||||
|
||||
/// Public so HomeShell can trigger a refresh when switching to this tab.
|
||||
void refresh() => _loadCollections();
|
||||
|
||||
Future<void> _loadCollections() async {
|
||||
|
|
@ -43,11 +37,7 @@ class ScanTabState extends State<ScanTab> {
|
|||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loadingCollections = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to load collections: $e'),
|
||||
),
|
||||
);
|
||||
showGlobalSnackBar('Failed to load collections: $e', isError: true);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -60,7 +50,6 @@ class ScanTabState extends State<ScanTab> {
|
|||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// ── Illustration ──
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
|
|
@ -77,14 +66,11 @@ class ScanTabState extends State<ScanTab> {
|
|||
const SizedBox(height: 28),
|
||||
const Text(
|
||||
'Scan a Hot Wheels Car',
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
'Point your camera at the model ID on the\npackaging to instantly add it to your collection.',
|
||||
'Point your camera at the model ID and add cars\ninstantly to your selected collection.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
|
|
@ -93,8 +79,6 @@ class ScanTabState extends State<ScanTab> {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── Collection picker ──
|
||||
if (_loadingCollections)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
|
|
@ -150,8 +134,7 @@ class ScanTabState extends State<ScanTab> {
|
|||
.toList(),
|
||||
onChanged: (id) {
|
||||
setState(() {
|
||||
final matching =
|
||||
_collections.where((c) => c.id == id);
|
||||
final matching = _collections.where((c) => c.id == id);
|
||||
_selectedCollection =
|
||||
matching.isNotEmpty ? matching.first : null;
|
||||
});
|
||||
|
|
@ -160,8 +143,6 @@ class ScanTabState extends State<ScanTab> {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── Scan button (gradient) ──
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
|
|
@ -210,8 +191,6 @@ class ScanTabState extends State<ScanTab> {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Manual entry ──
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
|
|
@ -281,27 +260,23 @@ class ScanTabState extends State<ScanTab> {
|
|||
setState(() => _isBusy = true);
|
||||
|
||||
try {
|
||||
// Check if this hw_id already exists in the selected collection.
|
||||
final data = await supabase
|
||||
final existing = await supabase
|
||||
.from('hotwheels')
|
||||
.select()
|
||||
.select('id')
|
||||
.eq('hw_id', hwId)
|
||||
.eq('collection_id', collection.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (existing != null) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isBusy = false);
|
||||
|
||||
if (data != null) {
|
||||
// Already in collection
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
icon: const Icon(Icons.check_circle,
|
||||
color: AppColors.success, size: 48),
|
||||
title: const Text('Already in Collection!'),
|
||||
content:
|
||||
Text('$hwId is already in "${collection.name}".'),
|
||||
content: Text('$hwId is already in "${collection.name}".'),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
|
|
@ -310,17 +285,58 @@ class ScanTabState extends State<ScanTab> {
|
|||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// New — offer to add
|
||||
final added = await showDialog<bool>(
|
||||
return;
|
||||
}
|
||||
|
||||
final globalCar = await supabase
|
||||
.from('global_cars')
|
||||
.select('hw_id, name, series, year, color, is_verified')
|
||||
.eq('hw_id', hwId)
|
||||
.maybeSingle();
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _isBusy = false);
|
||||
|
||||
if (globalCar != null) {
|
||||
final addConfirmed = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
builder: (_) => _AddCarDialog(
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (_) => _FoundCarSheet(
|
||||
collectionName: collection.name,
|
||||
car: globalCar,
|
||||
),
|
||||
);
|
||||
|
||||
if (addConfirmed == true) {
|
||||
await _addToCollection(collection.id, hwId);
|
||||
if (!mounted) return;
|
||||
showGlobalSnackBar('$hwId added to "${collection.name}"! 🎉');
|
||||
}
|
||||
} else {
|
||||
final discovery = await showModalBottomSheet<_NewDiscoveryData>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (_) => _NewDiscoverySheet(
|
||||
hwId: hwId,
|
||||
collectionId: collection.id,
|
||||
collectionName: collection.name,
|
||||
),
|
||||
);
|
||||
if (added == true) {
|
||||
|
||||
if (discovery != null) {
|
||||
await _createGlobalCarAndVote(
|
||||
hwId: hwId,
|
||||
name: discovery.name,
|
||||
series: discovery.series,
|
||||
year: discovery.year,
|
||||
);
|
||||
await _addToCollection(collection.id, hwId, notes: discovery.notes);
|
||||
if (!mounted) return;
|
||||
showGlobalSnackBar('$hwId added to "${collection.name}"! 🎉');
|
||||
}
|
||||
}
|
||||
|
|
@ -329,30 +345,148 @@ class ScanTabState extends State<ScanTab> {
|
|||
showGlobalSnackBar('DB error: $e', isError: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addToCollection(
|
||||
String collectionId,
|
||||
String hwId, {
|
||||
String? notes,
|
||||
}) async {
|
||||
await supabase.from('hotwheels').insert({
|
||||
'hw_id': hwId,
|
||||
'user_id': supabase.auth.currentUser!.id,
|
||||
'collection_id': collectionId,
|
||||
if (notes != null && notes.trim().isNotEmpty) 'notes': notes.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _createGlobalCarAndVote({
|
||||
required String hwId,
|
||||
required String name,
|
||||
String? series,
|
||||
int? year,
|
||||
}) async {
|
||||
final cleanedSeries = series?.trim();
|
||||
final payload = <String, dynamic>{
|
||||
'hw_id': hwId,
|
||||
'name': name,
|
||||
'series': (cleanedSeries?.isNotEmpty ?? false) ? cleanedSeries : null,
|
||||
'year': year,
|
||||
}..removeWhere((key, value) => value == null);
|
||||
|
||||
await supabase.from('global_cars').insert(payload);
|
||||
|
||||
await supabase.from('car_votes').insert({
|
||||
'hw_id': hwId,
|
||||
'user_id': supabase.auth.currentUser!.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Add Car Dialog (inline, styled) ──────────────────────────────────
|
||||
class _AddCarDialog extends StatefulWidget {
|
||||
final String hwId;
|
||||
final String collectionId;
|
||||
class _FoundCarSheet extends StatelessWidget {
|
||||
final String collectionName;
|
||||
const _AddCarDialog({
|
||||
final Map<String, dynamic> car;
|
||||
|
||||
const _FoundCarSheet({required this.collectionName, required this.car});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hwId = car['hw_id'] as String? ?? '???';
|
||||
final name = car['name'] as String? ?? 'Unknown model';
|
||||
final series = car['series'] as String?;
|
||||
final year = car['year'];
|
||||
final verified = car['is_verified'] == true;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: 20,
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Found in Catalog',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('$name ($hwId)', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
if (series != null && series.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text('Series: $series'),
|
||||
],
|
||||
if (year != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text('Year: $year'),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
verified ? 'Verified by community' : 'Unverified catalog entry',
|
||||
style: TextStyle(
|
||||
color: verified ? AppColors.success : AppColors.textSecondary,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text('Add to "$collectionName"'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NewDiscoveryData {
|
||||
final String name;
|
||||
final String? series;
|
||||
final int? year;
|
||||
final String? notes;
|
||||
|
||||
const _NewDiscoveryData({
|
||||
required this.name,
|
||||
this.series,
|
||||
this.year,
|
||||
this.notes,
|
||||
});
|
||||
}
|
||||
|
||||
class _NewDiscoverySheet extends StatefulWidget {
|
||||
final String hwId;
|
||||
final String collectionName;
|
||||
|
||||
const _NewDiscoverySheet({
|
||||
required this.hwId,
|
||||
required this.collectionId,
|
||||
required this.collectionName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_AddCarDialog> createState() => _AddCarDialogState();
|
||||
State<_NewDiscoverySheet> createState() => _NewDiscoverySheetState();
|
||||
}
|
||||
|
||||
class _AddCarDialogState extends State<_AddCarDialog> {
|
||||
class _NewDiscoverySheetState extends State<_NewDiscoverySheet> {
|
||||
final _nameController = TextEditingController();
|
||||
final _seriesController = TextEditingController();
|
||||
final _yearController = TextEditingController();
|
||||
final _notesController = TextEditingController();
|
||||
bool _isAdding = false;
|
||||
File? _pickedImage;
|
||||
bool _isSaving = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
|
|
@ -363,208 +497,95 @@ class _AddCarDialogState extends State<_AddCarDialog> {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickImage() async {
|
||||
final picker = ImagePicker();
|
||||
final xFile = await picker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
maxWidth: 800,
|
||||
maxHeight: 800,
|
||||
imageQuality: 60,
|
||||
);
|
||||
if (xFile != null && mounted) {
|
||||
setState(() => _pickedImage = File(xFile.path));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _quickAdd() async {
|
||||
setState(() => _isAdding = true);
|
||||
try {
|
||||
await supabase.from('hotwheels').insert({
|
||||
'hw_id': widget.hwId,
|
||||
'user_id': supabase.auth.currentUser!.id,
|
||||
'collection_id': widget.collectionId,
|
||||
});
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context, true);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isAdding = false);
|
||||
showGlobalSnackBar('Failed to add: $e', isError: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _add() async {
|
||||
setState(() => _isAdding = true);
|
||||
|
||||
try {
|
||||
final row = <String, dynamic>{
|
||||
'hw_id': widget.hwId,
|
||||
'user_id': supabase.auth.currentUser!.id,
|
||||
'collection_id': widget.collectionId,
|
||||
};
|
||||
|
||||
// Optional fields — only include if filled in.
|
||||
void _save() {
|
||||
final name = _nameController.text.trim();
|
||||
final series = _seriesController.text.trim();
|
||||
final yearStr = _yearController.text.trim();
|
||||
final notes = _notesController.text.trim();
|
||||
|
||||
if (name.isNotEmpty) row['name'] = name;
|
||||
if (series.isNotEmpty) row['series'] = series;
|
||||
if (yearStr.isNotEmpty) {
|
||||
final y = int.tryParse(yearStr);
|
||||
if (y != null) row['year'] = y;
|
||||
if (name.isEmpty) {
|
||||
showGlobalSnackBar('Name is required for a new discovery.', isError: true);
|
||||
return;
|
||||
}
|
||||
if (notes.isNotEmpty) row['notes'] = notes;
|
||||
|
||||
// Upload image if one was taken.
|
||||
if (_pickedImage != null) {
|
||||
final url = await StorageService.uploadCarImage(
|
||||
file: _pickedImage!,
|
||||
setState(() => _isSaving = true);
|
||||
|
||||
Navigator.pop(
|
||||
context,
|
||||
_NewDiscoveryData(
|
||||
name: name,
|
||||
series: _seriesController.text.trim().isEmpty
|
||||
? null
|
||||
: _seriesController.text.trim(),
|
||||
year: int.tryParse(_yearController.text.trim()),
|
||||
notes: _notesController.text.trim().isEmpty
|
||||
? null
|
||||
: _notesController.text.trim(),
|
||||
),
|
||||
);
|
||||
if (url != null) row['image_url'] = url;
|
||||
}
|
||||
|
||||
await supabase.from('hotwheels').insert(row);
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context, true);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isAdding = false);
|
||||
showGlobalSnackBar('Failed to add: $e', isError: true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: AppColors.brandGradient,
|
||||
shape: BoxShape.circle,
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: 20,
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
|
||||
),
|
||||
child:
|
||||
const Icon(Icons.add, color: Colors.white, size: 28),
|
||||
),
|
||||
title: Text('Add ${widget.hwId}'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// ── Photo picker ──
|
||||
GestureDetector(
|
||||
onTap: _pickImage,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 140,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.backgroundLight,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: AppColors.orange.withValues(alpha: 0.4),
|
||||
width: 1.5,
|
||||
),
|
||||
image: _pickedImage != null
|
||||
? DecorationImage(
|
||||
image: FileImage(_pickedImage!),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: _pickedImage == null
|
||||
? Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.add_a_photo,
|
||||
size: 36,
|
||||
color: AppColors.orange.withValues(alpha: 0.6)),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Tap to take a photo',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
Text(
|
||||
'New Discovery: ${widget.hwId}',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Colors.black54,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close,
|
||||
size: 16, color: Colors.white),
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: () =>
|
||||
setState(() => _pickedImage = null),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Car Name',
|
||||
labelText: 'Name *',
|
||||
hintText: "e.g. '70 Dodge Charger",
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _seriesController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Series',
|
||||
hintText: 'e.g. HW Flames',
|
||||
decoration: const InputDecoration(labelText: 'Series'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _yearController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Year',
|
||||
hintText: 'e.g. 2025',
|
||||
decoration: const InputDecoration(labelText: 'Year'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _notesController,
|
||||
maxLines: 2,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Notes',
|
||||
hintText: 'Any extra info…',
|
||||
labelText: 'Notes (for your garage entry)',
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isSaving ? null : _save,
|
||||
icon: const Icon(Icons.save_outlined),
|
||||
label: Text('Save & Add to "${widget.collectionName}"'),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _isAdding ? null : () => Navigator.pop(context),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: TextButton(
|
||||
onPressed: _isSaving ? null : () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
OutlinedButton(
|
||||
onPressed: _isAdding ? null : _quickAdd,
|
||||
child: const Text('Skip'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: _isAdding ? null : _add,
|
||||
child: _isAdding
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Add'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,26 @@ class CollectionMember {
|
|||
class CollectionService {
|
||||
CollectionService._();
|
||||
|
||||
/// Ensures the current user has at least one collection membership.
|
||||
/// Creates a default collection on first login.
|
||||
static Future<void> ensureDefaultCollection() async {
|
||||
final userId = supabase.auth.currentUser?.id;
|
||||
if (userId == null) return;
|
||||
|
||||
final existing = await supabase
|
||||
.from('collection_members')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.limit(1);
|
||||
|
||||
if (existing.isNotEmpty) return;
|
||||
|
||||
await create(
|
||||
name: 'My Garage',
|
||||
description: 'Your default collection',
|
||||
);
|
||||
}
|
||||
|
||||
/// Fetch all collections the current user is a member of,
|
||||
/// including item count and member count.
|
||||
static Future<List<Collection>> getMyCollections() async {
|
||||
|
|
|
|||
|
|
@ -1,72 +1,103 @@
|
|||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../main.dart';
|
||||
|
||||
/// Handles uploading / deleting car images in Supabase Storage.
|
||||
///
|
||||
/// Bucket: `car-images` (public, but URLs are unguessable)
|
||||
/// Path: `cars/{uuid}.jpg` — random UUID per image.
|
||||
///
|
||||
/// Shared garage — any authenticated user can upload / replace / delete.
|
||||
/// Bucket: `car-images` (private)
|
||||
/// Path: `{auth.uid()}/{hotwheels.id}.jpg`
|
||||
class StorageService {
|
||||
StorageService._();
|
||||
|
||||
static const _bucket = 'car-images';
|
||||
static const _uuid = Uuid();
|
||||
static const int _maxImageBytes = 500 * 1024;
|
||||
static const int _maxWidth = 1080;
|
||||
static const int _signedUrlExpirySeconds = 3600;
|
||||
|
||||
/// Upload a photo from [file].
|
||||
///
|
||||
/// If [oldImageUrl] is provided the previous file is deleted first.
|
||||
/// Returns the public URL on success, or `null` on failure.
|
||||
/// 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,
|
||||
String? oldImageUrl,
|
||||
required int entryId,
|
||||
String? oldPath,
|
||||
}) async {
|
||||
try {
|
||||
// Clean up old image if re-uploading.
|
||||
if (oldImageUrl != null) {
|
||||
await _deleteByUrl(oldImageUrl);
|
||||
if (oldPath != null && oldPath.isNotEmpty) {
|
||||
await deleteCarImage(oldPath);
|
||||
}
|
||||
|
||||
final path = 'cars/${_uuid.v4()}.jpg';
|
||||
final userId = supabase.auth.currentUser!.id;
|
||||
final path = '$userId/$entryId.jpg';
|
||||
final compressed = await _compressImage(file);
|
||||
|
||||
await supabase.storage.from(_bucket).upload(
|
||||
await supabase.storage.from(_bucket).uploadBinary(
|
||||
path,
|
||||
file,
|
||||
compressed,
|
||||
fileOptions: const FileOptions(
|
||||
upsert: true,
|
||||
contentType: 'image/jpeg',
|
||||
),
|
||||
);
|
||||
|
||||
// Return the public URL.
|
||||
final url = supabase.storage.from(_bucket).getPublicUrl(path);
|
||||
return url;
|
||||
return path;
|
||||
} catch (e) {
|
||||
debugPrint('StorageService.uploadCarImage error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the image at the given public [imageUrl].
|
||||
static Future<void> deleteCarImage(String? imageUrl) async {
|
||||
if (imageUrl == null || imageUrl.isEmpty) return;
|
||||
await _deleteByUrl(imageUrl);
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the storage path from a public URL and remove the file.
|
||||
static Future<void> _deleteByUrl(String imageUrl) async {
|
||||
/// Deletes an image using its storage path.
|
||||
static Future<void> deleteCarImage(String? path) async {
|
||||
if (path == null || path.isEmpty) return;
|
||||
try {
|
||||
// Public URLs look like:
|
||||
// .../storage/v1/object/public/car-images/cars/<uuid>.jpg
|
||||
final marker = '/object/public/$_bucket/';
|
||||
final idx = imageUrl.indexOf(marker);
|
||||
if (idx == -1) return;
|
||||
final path = imageUrl.substring(idx + marker.length);
|
||||
await supabase.storage.from(_bucket).remove([path]);
|
||||
} catch (e) {
|
||||
debugPrint('StorageService._deleteByUrl error: $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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -143,7 +143,12 @@ class _PlaceholderIcon extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Image.asset(
|
||||
'assets/img/icon_bg_removed.png',
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) => Center(
|
||||
child: Icon(
|
||||
Icons.directions_car_filled,
|
||||
size: 48,
|
||||
|
|
@ -151,6 +156,8 @@ class _PlaceholderIcon extends StatelessWidget {
|
|||
? Colors.white.withValues(alpha: 0.15)
|
||||
: AppColors.orange.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -449,7 +449,7 @@ packages:
|
|||
source: hosted
|
||||
version: "4.1.2"
|
||||
image:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: image
|
||||
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
||||
|
|
@ -1046,7 +1046,7 @@ packages:
|
|||
source: hosted
|
||||
version: "2.3.1"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||
|
|
@ -1110,7 +1110,7 @@ packages:
|
|||
source: hosted
|
||||
version: "3.1.5"
|
||||
uuid:
|
||||
dependency: "direct main"
|
||||
dependency: transitive
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 2.0.0+1
|
||||
version: 3.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.0
|
||||
|
|
@ -43,8 +43,7 @@ dependencies:
|
|||
flutter_launcher_icons: ^0.14.3
|
||||
intl: ^0.20.2
|
||||
cached_network_image: ^3.4.1
|
||||
uuid: ^4.5.1
|
||||
url_launcher: ^6.3.1
|
||||
image: ^4.2.0
|
||||
package_info_plus: ^8.1.3
|
||||
|
||||
dev_dependencies:
|
||||
|
|
|
|||
|
|
@ -21,13 +21,13 @@
|
|||
<!-- iOS meta tags & icons -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="hwhub">
|
||||
<meta name="apple-mobile-web-app-title" content="car64">
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png">
|
||||
|
||||
<title>hwhub</title>
|
||||
<title>car64</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<style id="splash-screen-style">
|
||||
html {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "hwhub",
|
||||
"short_name": "hwhub",
|
||||
"name": "car64",
|
||||
"short_name": "car64",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
|
|
|
|||
Loading…
Reference in a new issue