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:
Lukas Müllner 2026-03-04 10:12:12 +01:00
parent 2c6ae514fb
commit d8dbc2573a
16 changed files with 462 additions and 451 deletions

View file

@ -1,6 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application <application
android:label="HW Collector Hub" android:label="car64"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View file

@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>HW Collector Hub</string> <string>car64</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@ -15,7 +15,7 @@
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>HW Collector Hub</string> <string>car64</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import 'theme/app_theme.dart'; import 'theme/app_theme.dart';
import 'services/collection_service.dart';
import 'screens/login_screen.dart'; import 'screens/login_screen.dart';
import 'screens/home_shell.dart'; import 'screens/home_shell.dart';
@ -20,7 +21,7 @@ Future<void> main() async {
anonKey: _supabaseAnonKey, anonKey: _supabaseAnonKey,
); );
runApp(const HWHubApp()); runApp(const Car64App());
} }
/// Convenience accessor used throughout the app. /// Convenience accessor used throughout the app.
@ -49,13 +50,13 @@ Future<T?> showGlobalDialog<T>({required WidgetBuilder builder}) {
} }
// Root App Widget // Root App Widget
class HWHubApp extends StatelessWidget { class Car64App extends StatelessWidget {
const HWHubApp({super.key}); const Car64App({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
title: 'HW Collector Hub', title: 'car64',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
navigatorKey: navigatorKey, navigatorKey: navigatorKey,
scaffoldMessengerKey: scaffoldMessengerKey, scaffoldMessengerKey: scaffoldMessengerKey,
@ -78,12 +79,14 @@ class AuthGate extends StatefulWidget {
class _AuthGateState extends State<AuthGate> { class _AuthGateState extends State<AuthGate> {
bool _isLoading = true; bool _isLoading = true;
Session? _session; Session? _session;
String? _lastEnsuredUserId;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_session = supabase.auth.currentSession; _session = supabase.auth.currentSession;
_ensureDefaultCollectionIfNeeded();
supabase.auth.onAuthStateChange.listen( supabase.auth.onAuthStateChange.listen(
(AuthState authState) { (AuthState authState) {
@ -97,6 +100,8 @@ class _AuthGateState extends State<AuthGate> {
setState(() {}); setState(() {});
} }
_ensureDefaultCollectionIfNeeded();
if (authState.event == AuthChangeEvent.passwordRecovery) { if (authState.event == AuthChangeEvent.passwordRecovery) {
_showResetPasswordDialog(); _showResetPasswordDialog();
} }
@ -109,6 +114,18 @@ class _AuthGateState extends State<AuthGate> {
setState(() => _isLoading = false); 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 { Future<void> _showResetPasswordDialog() async {
await showDialog<void>( await showDialog<void>(
context: navigatorKey.currentContext!, context: navigatorKey.currentContext!,

View file

@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
/// Full-page About screen with app info, version, and links. /// Full-page About screen with app info, version, and links.
@ -25,11 +24,6 @@ class _AboutScreenState extends State<AboutScreen> {
if (mounted) setState(() => _packageInfo = info); if (mounted) setState(() => _packageInfo = info);
} }
Future<void> _openUrl(String url) async {
final uri = Uri.parse(url);
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@ -65,7 +59,7 @@ class _AboutScreenState extends State<AboutScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
Center( Center(
child: Text( child: Text(
'HW Collector Hub', 'car64',
style: theme.textTheme.headlineSmall?.copyWith( style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@ -95,29 +89,6 @@ class _AboutScreenState extends State<AboutScreen> {
const Divider(), const Divider(),
const SizedBox(height: 16), 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 // Technical details
Text( Text(
'Technical', 'Technical',
@ -152,7 +123,7 @@ class _AboutScreenState extends State<AboutScreen> {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'© 2026 HW Collector Hub', '© 2026 car64',
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: AppColors.textHint, 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) // Info row (label : value)
class _InfoRow extends StatelessWidget { class _InfoRow extends StatelessWidget {

View file

@ -55,13 +55,26 @@ class GarageScreenState extends State<GarageScreen> {
try { try {
final data = await supabase final data = await supabase
.from('hotwheels') .from('hotwheels')
.select() .select(
'id, created_at, hw_id, notes, user_image_url, global_cars(name, series, year, color)')
.eq('collection_id', widget.collectionId) .eq('collection_id', widget.collectionId)
.order('created_at', ascending: false); .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; if (!mounted) return;
setState(() { setState(() {
_cars = List<Map<String, dynamic>>.from(data); _cars = withSignedUrls;
_isLoading = false; _isLoading = false;
}); });
} catch (e) { } catch (e) {
@ -77,9 +90,10 @@ class GarageScreenState extends State<GarageScreen> {
if (_searchQuery.isEmpty) return _cars; if (_searchQuery.isEmpty) return _cars;
final q = _searchQuery.toLowerCase(); final q = _searchQuery.toLowerCase();
return _cars.where((car) { return _cars.where((car) {
final global = car['global_cars'] as Map<String, dynamic>?;
final id = (car['hw_id'] as String? ?? '').toLowerCase(); final id = (car['hw_id'] as String? ?? '').toLowerCase();
final name = (car['name'] as String? ?? '').toLowerCase(); final name = (global?['name'] as String? ?? '').toLowerCase();
final series = (car['series'] as String? ?? '').toLowerCase(); final series = (global?['series'] as String? ?? '').toLowerCase();
return id.contains(q) || name.contains(q) || series.contains(q); return id.contains(q) || name.contains(q) || series.contains(q);
}).toList(); }).toList();
} }
@ -205,13 +219,14 @@ class GarageScreenState extends State<GarageScreen> {
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, index) { (context, index) {
final car = _filteredCars[index]; final car = _filteredCars[index];
final global = car['global_cars'] as Map<String, dynamic>?;
return CarCard( return CarCard(
hwId: car['hw_id'] as String? ?? '???', hwId: car['hw_id'] as String? ?? '???',
name: car['name'] as String?, name: global?['name'] as String?,
series: car['series'] as String?, series: global?['series'] as String?,
year: car['year'] as int?, year: global?['year'] as int?,
color: car['color'] as String?, color: global?['color'] as String?,
imageUrl: car['image_url'] as String?, imageUrl: car['signed_image_url'] as String?,
addedAt: car['created_at'] != null addedAt: car['created_at'] != null
? DateTime.tryParse(car['created_at']) ? DateTime.tryParse(car['created_at'])
: null, : null,
@ -239,12 +254,13 @@ class GarageScreenState extends State<GarageScreen> {
} }
void _showCarDetails(Map<String, dynamic> car) { void _showCarDetails(Map<String, dynamic> car) {
final global = car['global_cars'] as Map<String, dynamic>?;
final hwId = car['hw_id'] as String? ?? '???'; final hwId = car['hw_id'] as String? ?? '???';
final name = car['name'] as String?; final name = global?['name'] as String?;
final series = car['series'] as String?; final series = global?['series'] as String?;
final year = car['year'] as int?; final year = global?['year'] as int?;
final notes = car['notes'] as String?; final notes = car['notes'] as String?;
final imageUrl = car['image_url'] as String?; final imageUrl = car['signed_image_url'] as String?;
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
@ -296,10 +312,16 @@ class GarageScreenState extends State<GarageScreen> {
child: TextButton.icon( child: TextButton.icon(
onPressed: () => _updatePhoto(car, context), onPressed: () => _updatePhoto(car, context),
icon: Icon( 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, 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; final isDark = Theme.of(context).brightness == Brightness.dark;
return Container( return Container(
color: isDark ? AppColors.surfaceDark : AppColors.backgroundLight, 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( child: Icon(
Icons.directions_car_filled, Icons.directions_car_filled,
size: 48, size: 48,
@ -393,6 +420,8 @@ class GarageScreenState extends State<GarageScreen> {
: AppColors.orange.withValues(alpha: 0.25), : AppColors.orange.withValues(alpha: 0.25),
), ),
), ),
),
),
); );
} }
@ -410,13 +439,14 @@ class GarageScreenState extends State<GarageScreen> {
showGlobalSnackBar('Uploading photo…'); showGlobalSnackBar('Uploading photo…');
final oldImageUrl = car['image_url'] as String?; final oldPath = car['user_image_url'] as String?;
final url = await StorageService.uploadCarImage( final newPath = await StorageService.uploadCarImage(
file: File(xFile.path), 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); showGlobalSnackBar('Failed to upload photo.', isError: true);
return; return;
} }
@ -424,7 +454,7 @@ class GarageScreenState extends State<GarageScreen> {
try { try {
await supabase await supabase
.from('hotwheels') .from('hotwheels')
.update({'image_url': url}) .update({'user_image_url': newPath})
.eq('id', car['id']); .eq('id', car['id']);
showGlobalSnackBar('Photo updated!'); showGlobalSnackBar('Photo updated!');
@ -485,7 +515,7 @@ class GarageScreenState extends State<GarageScreen> {
try { try {
// Delete image from storage first (best-effort). // 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 await supabase
.from('hotwheels') .from('hotwheels')
@ -651,28 +681,17 @@ class _EditCarDialog extends StatefulWidget {
} }
class _EditCarDialogState extends State<_EditCarDialog> { class _EditCarDialogState extends State<_EditCarDialog> {
late final TextEditingController _nameCtrl;
late final TextEditingController _seriesCtrl;
late final TextEditingController _yearCtrl;
late final TextEditingController _notesCtrl; late final TextEditingController _notesCtrl;
@override @override
void initState() { void initState() {
super.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 = _notesCtrl =
TextEditingController(text: widget.car['notes'] as String? ?? ''); TextEditingController(text: widget.car['notes'] as String? ?? '');
} }
@override @override
void dispose() { void dispose() {
_nameCtrl.dispose();
_seriesCtrl.dispose();
_yearCtrl.dispose();
_notesCtrl.dispose(); _notesCtrl.dispose();
super.dispose(); super.dispose();
} }
@ -680,14 +699,8 @@ class _EditCarDialogState extends State<_EditCarDialog> {
void _save() { void _save() {
final updates = <String, dynamic>{}; final updates = <String, dynamic>{};
final name = _nameCtrl.text.trim();
final series = _seriesCtrl.text.trim();
final yearStr = _yearCtrl.text.trim();
final notes = _notesCtrl.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; updates['notes'] = notes.isEmpty ? null : notes;
Navigator.pop(context, updates); Navigator.pop(context, updates);
@ -710,34 +723,9 @@ class _EditCarDialogState extends State<_EditCarDialog> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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( TextField(
controller: _notesCtrl, controller: _notesCtrl,
maxLines: 2, maxLines: 4,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Notes', labelText: 'Notes',
hintText: 'Any extra info…', hintText: 'Any extra info…',

View file

@ -142,7 +142,7 @@ class _LoginScreenState extends State<LoginScreen>
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text( const Text(
'HW COLLECTOR HUB', 'CAR64',
style: TextStyle( style: TextStyle(
fontSize: 26, fontSize: 26,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
@ -300,7 +300,7 @@ class _LoginScreenState extends State<LoginScreen>
// Footer // Footer
Text( Text(
'© 2026 HW Collector Hub', '© 2026 car64',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: Colors.white.withValues(alpha: 0.5), color: Colors.white.withValues(alpha: 0.5),

View file

@ -105,7 +105,7 @@ class ProfileScreen extends StatelessWidget {
_SettingsTile( _SettingsTile(
icon: Icons.info_outline, icon: Icons.info_outline,
title: 'About', title: 'About',
subtitle: 'HW Collector Hub', subtitle: 'car64',
onTap: () => _showAbout(context), onTap: () => _showAbout(context),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
@ -131,7 +131,7 @@ class ProfileScreen extends StatelessWidget {
const SizedBox(height: 40), const SizedBox(height: 40),
const Center( const Center(
child: Text( child: Text(
'© 2026 HW Collector Hub', '© 2026 car64',
style: style:
TextStyle(fontSize: 12, color: AppColors.textHint), TextStyle(fontSize: 12, color: AppColors.textHint),
), ),

View file

@ -1,14 +1,9 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../main.dart'; import '../main.dart';
import '../scanner_screen.dart'; import '../scanner_screen.dart';
import '../services/collection_service.dart'; import '../services/collection_service.dart';
import '../services/storage_service.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
/// The "Scan" tab quick-access view for scanning / adding cars.
class ScanTab extends StatefulWidget { class ScanTab extends StatefulWidget {
const ScanTab({super.key}); const ScanTab({super.key});
@ -28,7 +23,6 @@ class ScanTabState extends State<ScanTab> {
_loadCollections(); _loadCollections();
} }
/// Public so HomeShell can trigger a refresh when switching to this tab.
void refresh() => _loadCollections(); void refresh() => _loadCollections();
Future<void> _loadCollections() async { Future<void> _loadCollections() async {
@ -43,11 +37,7 @@ class ScanTabState extends State<ScanTab> {
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() => _loadingCollections = false); setState(() => _loadingCollections = false);
ScaffoldMessenger.of(context).showSnackBar( showGlobalSnackBar('Failed to load collections: $e', isError: true);
SnackBar(
content: Text('Failed to load collections: $e'),
),
);
} }
} }
@ -60,7 +50,6 @@ class ScanTabState extends State<ScanTab> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// Illustration
Container( Container(
width: 120, width: 120,
height: 120, height: 120,
@ -77,14 +66,11 @@ class ScanTabState extends State<ScanTab> {
const SizedBox(height: 28), const SizedBox(height: 28),
const Text( const Text(
'Scan a Hot Wheels Car', 'Scan a Hot Wheels Car',
style: TextStyle( style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
fontSize: 22,
fontWeight: FontWeight.w700,
),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
const Text( 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, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
@ -93,8 +79,6 @@ class ScanTabState extends State<ScanTab> {
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Collection picker
if (_loadingCollections) if (_loadingCollections)
const Padding( const Padding(
padding: EdgeInsets.symmetric(vertical: 8), padding: EdgeInsets.symmetric(vertical: 8),
@ -150,8 +134,7 @@ class ScanTabState extends State<ScanTab> {
.toList(), .toList(),
onChanged: (id) { onChanged: (id) {
setState(() { setState(() {
final matching = final matching = _collections.where((c) => c.id == id);
_collections.where((c) => c.id == id);
_selectedCollection = _selectedCollection =
matching.isNotEmpty ? matching.first : null; matching.isNotEmpty ? matching.first : null;
}); });
@ -160,8 +143,6 @@ class ScanTabState extends State<ScanTab> {
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Scan button (gradient)
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
height: 56, height: 56,
@ -210,8 +191,6 @@ class ScanTabState extends State<ScanTab> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Manual entry
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: OutlinedButton.icon( child: OutlinedButton.icon(
@ -281,27 +260,23 @@ class ScanTabState extends State<ScanTab> {
setState(() => _isBusy = true); setState(() => _isBusy = true);
try { try {
// Check if this hw_id already exists in the selected collection. final existing = await supabase
final data = await supabase
.from('hotwheels') .from('hotwheels')
.select() .select('id')
.eq('hw_id', hwId) .eq('hw_id', hwId)
.eq('collection_id', collection.id) .eq('collection_id', collection.id)
.maybeSingle(); .maybeSingle();
if (existing != null) {
if (!mounted) return; if (!mounted) return;
setState(() => _isBusy = false); setState(() => _isBusy = false);
if (data != null) {
// Already in collection
await showDialog( await showDialog(
context: context, context: context,
builder: (_) => AlertDialog( builder: (_) => AlertDialog(
icon: const Icon(Icons.check_circle, icon: const Icon(Icons.check_circle,
color: AppColors.success, size: 48), color: AppColors.success, size: 48),
title: const Text('Already in Collection!'), title: const Text('Already in Collection!'),
content: content: Text('$hwId is already in "${collection.name}".'),
Text('$hwId is already in "${collection.name}".'),
actions: [ actions: [
ElevatedButton( ElevatedButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
@ -310,17 +285,58 @@ class ScanTabState extends State<ScanTab> {
], ],
), ),
); );
} else { return;
// New offer to add }
final added = await showDialog<bool>(
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, 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, hwId: hwId,
collectionId: collection.id,
collectionName: collection.name, 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}"! 🎉'); showGlobalSnackBar('$hwId added to "${collection.name}"! 🎉');
} }
} }
@ -329,30 +345,148 @@ class ScanTabState extends State<ScanTab> {
showGlobalSnackBar('DB error: $e', isError: true); 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(),
});
} }
// Add Car Dialog (inline, styled) Future<void> _createGlobalCarAndVote({
class _AddCarDialog extends StatefulWidget { required String hwId,
final String hwId; required String name,
final String collectionId; 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,
});
}
}
class _FoundCarSheet extends StatelessWidget {
final String collectionName; 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.hwId,
required this.collectionId,
required this.collectionName, required this.collectionName,
}); });
@override @override
State<_AddCarDialog> createState() => _AddCarDialogState(); State<_NewDiscoverySheet> createState() => _NewDiscoverySheetState();
} }
class _AddCarDialogState extends State<_AddCarDialog> { class _NewDiscoverySheetState extends State<_NewDiscoverySheet> {
final _nameController = TextEditingController(); final _nameController = TextEditingController();
final _seriesController = TextEditingController(); final _seriesController = TextEditingController();
final _yearController = TextEditingController(); final _yearController = TextEditingController();
final _notesController = TextEditingController(); final _notesController = TextEditingController();
bool _isAdding = false; bool _isSaving = false;
File? _pickedImage;
@override @override
void dispose() { void dispose() {
@ -363,208 +497,95 @@ class _AddCarDialogState extends State<_AddCarDialog> {
super.dispose(); super.dispose();
} }
Future<void> _pickImage() async { void _save() {
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.
final name = _nameController.text.trim(); final name = _nameController.text.trim();
final series = _seriesController.text.trim(); if (name.isEmpty) {
final yearStr = _yearController.text.trim(); showGlobalSnackBar('Name is required for a new discovery.', isError: true);
final notes = _notesController.text.trim(); return;
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 (notes.isNotEmpty) row['notes'] = notes;
// Upload image if one was taken. setState(() => _isSaving = true);
if (_pickedImage != null) {
final url = await StorageService.uploadCarImage( Navigator.pop(
file: _pickedImage!, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( return Padding(
icon: Container( padding: EdgeInsets.only(
padding: const EdgeInsets.all(12), left: 20,
decoration: const BoxDecoration( right: 20,
gradient: AppColors.brandGradient, top: 20,
shape: BoxShape.circle, 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( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// Photo picker Text(
GestureDetector( 'New Discovery: ${widget.hwId}',
onTap: _pickImage, style: Theme.of(context).textTheme.titleLarge?.copyWith(
child: Container( fontWeight: FontWeight.w700,
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,
), ),
), ),
], const SizedBox(height: 14),
)
: 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),
TextField( TextField(
controller: _nameController, controller: _nameController,
autofocus: true,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Car Name', labelText: 'Name *',
hintText: "e.g. '70 Dodge Charger", hintText: "e.g. '70 Dodge Charger",
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 10),
TextField( TextField(
controller: _seriesController, controller: _seriesController,
decoration: const InputDecoration( decoration: const InputDecoration(labelText: 'Series'),
labelText: 'Series',
hintText: 'e.g. HW Flames',
), ),
), const SizedBox(height: 10),
const SizedBox(height: 12),
TextField( TextField(
controller: _yearController, controller: _yearController,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
decoration: const InputDecoration( decoration: const InputDecoration(labelText: 'Year'),
labelText: 'Year',
hintText: 'e.g. 2025',
), ),
), const SizedBox(height: 10),
const SizedBox(height: 12),
TextField( TextField(
controller: _notesController, controller: _notesController,
maxLines: 2, maxLines: 2,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Notes', labelText: 'Notes (for your garage entry)',
hintText: 'Any extra info…',
), ),
), ),
], 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: [ const SizedBox(height: 8),
TextButton( SizedBox(
onPressed: _isAdding ? null : () => Navigator.pop(context), width: double.infinity,
child: TextButton(
onPressed: _isSaving ? null : () => Navigator.pop(context),
child: const Text('Cancel'), 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'),
), ),
], ],
),
); );
} }
} }

View file

@ -49,6 +49,26 @@ class CollectionMember {
class CollectionService { class CollectionService {
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, /// Fetch all collections the current user is a member of,
/// including item count and member count. /// including item count and member count.
static Future<List<Collection>> getMyCollections() async { static Future<List<Collection>> getMyCollections() async {

View file

@ -1,72 +1,103 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:image/image.dart' as img;
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:uuid/uuid.dart';
import '../main.dart'; import '../main.dart';
/// Handles uploading / deleting car images in Supabase Storage. /// Handles uploading / deleting car images in Supabase Storage.
/// ///
/// Bucket: `car-images` (public, but URLs are unguessable) /// Bucket: `car-images` (private)
/// Path: `cars/{uuid}.jpg` random UUID per image. /// Path: `{auth.uid()}/{hotwheels.id}.jpg`
///
/// Shared garage any authenticated user can upload / replace / delete.
class StorageService { class StorageService {
StorageService._(); StorageService._();
static const _bucket = 'car-images'; 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]. /// Upload a car image for a specific hotwheels entry.
/// /// Returns the storage path on success (e.g. `uid/123.jpg`).
/// If [oldImageUrl] is provided the previous file is deleted first.
/// Returns the public URL on success, or `null` on failure.
static Future<String?> uploadCarImage({ static Future<String?> uploadCarImage({
required File file, required File file,
String? oldImageUrl, required int entryId,
String? oldPath,
}) async { }) async {
try { try {
// Clean up old image if re-uploading. if (oldPath != null && oldPath.isNotEmpty) {
if (oldImageUrl != null) { await deleteCarImage(oldPath);
await _deleteByUrl(oldImageUrl);
} }
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, path,
file, compressed,
fileOptions: const FileOptions( fileOptions: const FileOptions(
upsert: true,
contentType: 'image/jpeg', contentType: 'image/jpeg',
), ),
); );
// Return the public URL. return path;
final url = supabase.storage.from(_bucket).getPublicUrl(path);
return url;
} catch (e) { } catch (e) {
debugPrint('StorageService.uploadCarImage error: $e'); debugPrint('StorageService.uploadCarImage error: $e');
return null; return null;
} }
} }
/// Delete the image at the given public [imageUrl]. /// Generates a temporary signed URL for a private image path.
static Future<void> deleteCarImage(String? imageUrl) async { static Future<String?> createSignedUrl(String? path) async {
if (imageUrl == null || imageUrl.isEmpty) return; if (path == null || path.isEmpty) return null;
await _deleteByUrl(imageUrl); 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. /// Deletes an image using its storage path.
static Future<void> _deleteByUrl(String imageUrl) async { static Future<void> deleteCarImage(String? path) async {
if (path == null || path.isEmpty) return;
try { 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]); await supabase.storage.from(_bucket).remove([path]);
} catch (e) { } 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;
}
} }

View file

@ -143,7 +143,12 @@ class _PlaceholderIcon extends StatelessWidget {
@override @override
Widget build(BuildContext context) { 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( child: Icon(
Icons.directions_car_filled, Icons.directions_car_filled,
size: 48, size: 48,
@ -151,6 +156,8 @@ class _PlaceholderIcon extends StatelessWidget {
? Colors.white.withValues(alpha: 0.15) ? Colors.white.withValues(alpha: 0.15)
: AppColors.orange.withValues(alpha: 0.3), : AppColors.orange.withValues(alpha: 0.3),
), ),
),
),
); );
} }
} }

View file

@ -449,7 +449,7 @@ packages:
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image: image:
dependency: transitive dependency: "direct main"
description: description:
name: image name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
@ -1046,7 +1046,7 @@ packages:
source: hosted source: hosted
version: "2.3.1" version: "2.3.1"
url_launcher: url_launcher:
dependency: "direct main" dependency: transitive
description: description:
name: url_launcher name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
@ -1110,7 +1110,7 @@ packages:
source: hosted source: hosted
version: "3.1.5" version: "3.1.5"
uuid: uuid:
dependency: "direct main" dependency: transitive
description: description:
name: uuid name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"

View file

@ -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 # 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 # 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. # 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: environment:
sdk: ^3.11.0 sdk: ^3.11.0
@ -43,8 +43,7 @@ dependencies:
flutter_launcher_icons: ^0.14.3 flutter_launcher_icons: ^0.14.3
intl: ^0.20.2 intl: ^0.20.2
cached_network_image: ^3.4.1 cached_network_image: ^3.4.1
uuid: ^4.5.1 image: ^4.2.0
url_launcher: ^6.3.1
package_info_plus: ^8.1.3 package_info_plus: ^8.1.3
dev_dependencies: dev_dependencies:

View file

@ -21,13 +21,13 @@
<!-- iOS meta tags & icons --> <!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes"> <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-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"> <link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"> <link rel="icon" type="image/png" href="favicon.png">
<title>hwhub</title> <title>car64</title>
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
<style id="splash-screen-style"> <style id="splash-screen-style">
html { html {

View file

@ -1,6 +1,6 @@
{ {
"name": "hwhub", "name": "car64",
"short_name": "hwhub", "short_name": "car64",
"start_url": ".", "start_url": ".",
"display": "standalone", "display": "standalone",
"background_color": "#0175C2", "background_color": "#0175C2",