- Persist scanner activeCollectionId via shared_preferences - Restore last selected collection automatically on scan tab load - Keep selection valid if collection list changes and store fallback safely - Harden collection membership service rules: - only owners can invite/remove members - prevent self-invite duplicates - block owner removal - block owner leave action with explicit error - Improve manage-collection UX with pull-to-refresh and owner leave guidance
621 lines
19 KiB
Dart
621 lines
19 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import '../main.dart';
|
|
import '../scanner_screen.dart';
|
|
import '../services/collection_service.dart';
|
|
import '../theme/app_colors.dart';
|
|
|
|
class ScanTab extends StatefulWidget {
|
|
const ScanTab({super.key});
|
|
|
|
@override
|
|
State<ScanTab> createState() => ScanTabState();
|
|
}
|
|
|
|
class ScanTabState extends State<ScanTab> {
|
|
static const _activeCollectionPrefKey = 'active_collection_id';
|
|
|
|
bool _isBusy = false;
|
|
List<Collection> _collections = [];
|
|
Collection? _selectedCollection;
|
|
bool _loadingCollections = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadCollections();
|
|
}
|
|
|
|
void refresh() => _loadCollections();
|
|
|
|
Future<void> _loadCollections() async {
|
|
try {
|
|
final list = await CollectionService.getMyCollections();
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final persistedId = prefs.getString(_activeCollectionPrefKey);
|
|
|
|
Collection? selected;
|
|
if (persistedId != null) {
|
|
final matching = list.where((c) => c.id == persistedId);
|
|
if (matching.isNotEmpty) {
|
|
selected = matching.first;
|
|
}
|
|
}
|
|
|
|
selected ??= list.isNotEmpty ? list.first : null;
|
|
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_collections = list;
|
|
_selectedCollection = selected;
|
|
_loadingCollections = false;
|
|
});
|
|
|
|
if (selected != null) {
|
|
await prefs.setString(_activeCollectionPrefKey, selected.id);
|
|
}
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => _loadingCollections = false);
|
|
showGlobalSnackBar('Failed to load collections: $e', isError: true);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
body: SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
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 and add cars\ninstantly to your selected collection.',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: AppColors.textSecondary,
|
|
height: 1.5,
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
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) {
|
|
_setActiveCollection(id);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
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),
|
|
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> _setActiveCollection(String? id) async {
|
|
if (id == null) return;
|
|
final matching = _collections.where((c) => c.id == id);
|
|
if (matching.isEmpty) return;
|
|
|
|
setState(() => _selectedCollection = matching.first);
|
|
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString(_activeCollectionPrefKey, id);
|
|
}
|
|
|
|
Future<void> _openScanner() async {
|
|
await navigatorKey.currentState!.push<void>(
|
|
MaterialPageRoute(
|
|
builder: (_) => ScannerScreen(
|
|
onDetected: (hwId) => _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<bool> _processHwId(String hwId) async {
|
|
final collection = _selectedCollection;
|
|
if (collection == null) return false;
|
|
|
|
setState(() => _isBusy = true);
|
|
|
|
try {
|
|
final existing = await supabase
|
|
.from('hotwheels')
|
|
.select('id')
|
|
.eq('hw_id', hwId)
|
|
.eq('collection_id', collection.id)
|
|
.maybeSingle();
|
|
|
|
if (existing != null) {
|
|
if (!mounted) return false;
|
|
setState(() => _isBusy = false);
|
|
await showDialog(
|
|
context: navigatorKey.currentContext ?? 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'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
return true;
|
|
}
|
|
|
|
final globalCar = await supabase
|
|
.from('global_cars')
|
|
.select('hw_id, name, series, year, color, is_verified')
|
|
.eq('hw_id', hwId)
|
|
.maybeSingle();
|
|
|
|
if (!mounted) return false;
|
|
setState(() => _isBusy = false);
|
|
|
|
if (globalCar != null) {
|
|
final addConfirmed = await showModalBottomSheet<bool>(
|
|
context: navigatorKey.currentContext ?? context,
|
|
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 false;
|
|
showGlobalSnackBar('$hwId added to "${collection.name}"! 🎉');
|
|
}
|
|
} else {
|
|
final discovery = await showModalBottomSheet<_NewDiscoveryData>(
|
|
context: navigatorKey.currentContext ?? context,
|
|
isScrollControlled: true,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
|
),
|
|
builder: (_) => _NewDiscoverySheet(
|
|
hwId: hwId,
|
|
collectionName: collection.name,
|
|
),
|
|
);
|
|
|
|
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 false;
|
|
showGlobalSnackBar('$hwId added to "${collection.name}"! 🎉');
|
|
}
|
|
}
|
|
return true;
|
|
} catch (e) {
|
|
if (mounted) setState(() => _isBusy = false);
|
|
showGlobalSnackBar('DB error: $e', isError: true);
|
|
return 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,
|
|
});
|
|
}
|
|
}
|
|
|
|
class _FoundCarSheet extends StatelessWidget {
|
|
final String collectionName;
|
|
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.collectionName,
|
|
});
|
|
|
|
@override
|
|
State<_NewDiscoverySheet> createState() => _NewDiscoverySheetState();
|
|
}
|
|
|
|
class _NewDiscoverySheetState extends State<_NewDiscoverySheet> {
|
|
final _nameController = TextEditingController();
|
|
final _seriesController = TextEditingController();
|
|
final _yearController = TextEditingController();
|
|
final _notesController = TextEditingController();
|
|
bool _isSaving = false;
|
|
|
|
@override
|
|
void dispose() {
|
|
_nameController.dispose();
|
|
_seriesController.dispose();
|
|
_yearController.dispose();
|
|
_notesController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _save() {
|
|
final name = _nameController.text.trim();
|
|
if (name.isEmpty) {
|
|
showGlobalSnackBar('Name is required for a new discovery.', isError: true);
|
|
return;
|
|
}
|
|
|
|
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(),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: EdgeInsets.only(
|
|
left: 20,
|
|
right: 20,
|
|
top: 20,
|
|
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
'New Discovery: ${widget.hwId}',
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
TextField(
|
|
controller: _nameController,
|
|
autofocus: true,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Name *',
|
|
hintText: "e.g. '70 Dodge Charger",
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
TextField(
|
|
controller: _seriesController,
|
|
decoration: const InputDecoration(labelText: 'Series'),
|
|
),
|
|
const SizedBox(height: 10),
|
|
TextField(
|
|
controller: _yearController,
|
|
keyboardType: TextInputType.number,
|
|
decoration: const InputDecoration(labelText: 'Year'),
|
|
),
|
|
const SizedBox(height: 10),
|
|
TextField(
|
|
controller: _notesController,
|
|
maxLines: 2,
|
|
decoration: const InputDecoration(
|
|
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}"'),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: TextButton(
|
|
onPressed: _isSaving ? null : () => Navigator.pop(context),
|
|
child: const Text('Cancel'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|