- Add collections & collection_members tables (SQL migration) - Create CollectionService for CRUD, invite/remove members - Add CollectionsScreen with create/manage/open flow - Add ManageCollectionScreen (rename, invite by email, leave/delete) - Update GarageScreen to filter by collection_id - Update ScanTab with collection picker dropdown - Update HomeShell: Collections tab replaces Garage tab - Fix RLS policies (infinite recursion, owner visibility) - Change package ID to com.dltw.derkauzigekoala.hwhub - Fix unused variable warning in AboutScreen
563 lines
18 KiB
Dart
563 lines
18 KiB
Dart
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});
|
|
|
|
@override
|
|
State<ScanTab> createState() => ScanTabState();
|
|
}
|
|
|
|
class ScanTabState extends State<ScanTab> {
|
|
bool _isBusy = false;
|
|
List<Collection> _collections = [];
|
|
Collection? _selectedCollection;
|
|
bool _loadingCollections = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadCollections();
|
|
}
|
|
|
|
/// Public so HomeShell can trigger a refresh when switching to this tab.
|
|
void refresh() => _loadCollections();
|
|
|
|
Future<void> _loadCollections() async {
|
|
try {
|
|
final list = await CollectionService.getMyCollections();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_collections = list;
|
|
_selectedCollection = list.isNotEmpty ? list.first : null;
|
|
_loadingCollections = false;
|
|
});
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => _loadingCollections = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
body: SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
// ── Illustration ──
|
|
Container(
|
|
width: 120,
|
|
height: 120,
|
|
decoration: BoxDecoration(
|
|
gradient: AppColors.brandGradientSoft,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(
|
|
Icons.qr_code_scanner,
|
|
size: 56,
|
|
color: AppColors.orange,
|
|
),
|
|
),
|
|
const SizedBox(height: 28),
|
|
const Text(
|
|
'Scan a Hot Wheels Car',
|
|
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.',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: AppColors.textSecondary,
|
|
height: 1.5,
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// ── Collection picker ──
|
|
if (_loadingCollections)
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 8),
|
|
child: SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
)
|
|
else if (_collections.isEmpty)
|
|
const Text(
|
|
'Create a collection first!',
|
|
style: TextStyle(color: AppColors.error),
|
|
)
|
|
else
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(
|
|
color: AppColors.orange.withValues(alpha: 0.4),
|
|
),
|
|
),
|
|
child: DropdownButtonHideUnderline(
|
|
child: DropdownButton<String>(
|
|
isExpanded: true,
|
|
value: _selectedCollection?.id,
|
|
icon: const Icon(Icons.arrow_drop_down,
|
|
color: AppColors.orange),
|
|
items: _collections
|
|
.map((c) => DropdownMenuItem(
|
|
value: c.id,
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
c.isOwner
|
|
? Icons.collections_bookmark
|
|
: Icons.group,
|
|
size: 18,
|
|
color: AppColors.orange,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
c.name,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
))
|
|
.toList(),
|
|
onChanged: (id) {
|
|
setState(() {
|
|
_selectedCollection = _collections
|
|
.firstWhere((c) => c.id == id);
|
|
});
|
|
},
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// ── Scan button (gradient) ──
|
|
SizedBox(
|
|
width: double.infinity,
|
|
height: 56,
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
gradient: AppColors.brandGradient,
|
|
borderRadius: BorderRadius.circular(50),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: AppColors.orange.withValues(alpha: 0.35),
|
|
blurRadius: 14,
|
|
offset: const Offset(0, 5),
|
|
),
|
|
],
|
|
),
|
|
child: ElevatedButton.icon(
|
|
onPressed: _isBusy || _selectedCollection == null
|
|
? null
|
|
: _openScanner,
|
|
icon: _isBusy
|
|
? const SizedBox(
|
|
width: 22,
|
|
height: 22,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2.5,
|
|
color: Colors.white,
|
|
),
|
|
)
|
|
: const Icon(Icons.camera_alt, color: Colors.white),
|
|
label: Text(
|
|
_isBusy ? 'Processing…' : 'Open Scanner',
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.transparent,
|
|
shadowColor: Colors.transparent,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(50),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// ── Manual entry ──
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: OutlinedButton.icon(
|
|
onPressed: _isBusy || _selectedCollection == null
|
|
? null
|
|
: _manualEntry,
|
|
icon: const Icon(Icons.keyboard),
|
|
label: const Text('Enter ID Manually'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _openScanner() async {
|
|
final hwId = await navigatorKey.currentState!.push<String>(
|
|
MaterialPageRoute(builder: (_) => const ScannerScreen()),
|
|
);
|
|
|
|
if (hwId == null || !mounted) return;
|
|
await _processHwId(hwId);
|
|
}
|
|
|
|
Future<void> _manualEntry() async {
|
|
final controller = TextEditingController();
|
|
final result = await showDialog<String>(
|
|
context: context,
|
|
builder: (_) => AlertDialog(
|
|
title: const Text('Enter HW ID'),
|
|
content: TextField(
|
|
controller: controller,
|
|
autofocus: true,
|
|
textCapitalization: TextCapitalization.characters,
|
|
decoration: const InputDecoration(hintText: 'e.g. JKF21'),
|
|
onSubmitted: (v) {
|
|
final val = v.trim().toUpperCase();
|
|
if (val.isNotEmpty) Navigator.pop(context, val);
|
|
},
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Cancel'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
final val = controller.text.trim().toUpperCase();
|
|
if (val.isNotEmpty) Navigator.pop(context, val);
|
|
},
|
|
child: const Text('OK'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (result == null || !mounted) return;
|
|
await _processHwId(result);
|
|
}
|
|
|
|
Future<void> _processHwId(String hwId) async {
|
|
final collection = _selectedCollection;
|
|
if (collection == null) return;
|
|
|
|
setState(() => _isBusy = true);
|
|
|
|
try {
|
|
// Check if this hw_id already exists in the selected collection.
|
|
final data = await supabase
|
|
.from('hotwheels')
|
|
.select()
|
|
.eq('hw_id', hwId)
|
|
.eq('collection_id', collection.id)
|
|
.maybeSingle();
|
|
|
|
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}".'),
|
|
actions: [
|
|
ElevatedButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Got it'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
} else {
|
|
// New — offer to add
|
|
final added = await showDialog<bool>(
|
|
context: context,
|
|
builder: (_) => _AddCarDialog(
|
|
hwId: hwId,
|
|
collectionId: collection.id,
|
|
collectionName: collection.name,
|
|
),
|
|
);
|
|
if (added == true) {
|
|
showGlobalSnackBar('$hwId added to "${collection.name}"! 🎉');
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (mounted) setState(() => _isBusy = false);
|
|
showGlobalSnackBar('DB error: $e', isError: true);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Add Car Dialog (inline, styled) ──────────────────────────────────
|
|
class _AddCarDialog extends StatefulWidget {
|
|
final String hwId;
|
|
final String collectionId;
|
|
final String collectionName;
|
|
const _AddCarDialog({
|
|
required this.hwId,
|
|
required this.collectionId,
|
|
required this.collectionName,
|
|
});
|
|
|
|
@override
|
|
State<_AddCarDialog> createState() => _AddCarDialogState();
|
|
}
|
|
|
|
class _AddCarDialogState extends State<_AddCarDialog> {
|
|
final _nameController = TextEditingController();
|
|
final _seriesController = TextEditingController();
|
|
final _yearController = TextEditingController();
|
|
final _notesController = TextEditingController();
|
|
bool _isAdding = false;
|
|
File? _pickedImage;
|
|
|
|
@override
|
|
void dispose() {
|
|
_nameController.dispose();
|
|
_seriesController.dispose();
|
|
_yearController.dispose();
|
|
_notesController.dispose();
|
|
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.
|
|
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 (notes.isNotEmpty) row['notes'] = notes;
|
|
|
|
// Upload image if one was taken.
|
|
if (_pickedImage != null) {
|
|
final url = await StorageService.uploadCarImage(
|
|
file: _pickedImage!,
|
|
);
|
|
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,
|
|
),
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
)
|
|
: 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(
|
|
controller: _nameController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Car Name',
|
|
hintText: "e.g. '70 Dodge Charger",
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: _seriesController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Series',
|
|
hintText: 'e.g. HW Flames',
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: _yearController,
|
|
keyboardType: TextInputType.number,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Year',
|
|
hintText: 'e.g. 2025',
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: _notesController,
|
|
maxLines: 2,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Notes',
|
|
hintText: 'Any extra info…',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: _isAdding ? 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'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|