hwhub/lib/screens/scan_tab.dart
Lukas Müllner a93694728e chore(scanner): add duplicate-ID cooldown for rapid scan stability
- Ignore repeated processing of the same HW ID within a short 2s window
- Prevent duplicate dialogs/inserts during very fast repeated captures
- Keep continuous scanner loop responsive while reducing accidental re-triggers
2026-03-04 11:08:03 +01:00

663 lines
21 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';
static const _duplicateCooldown = Duration(seconds: 2);
bool _isBusy = false;
List<Collection> _collections = [];
Collection? _selectedCollection;
bool _loadingCollections = true;
String? _lastProcessedHwId;
DateTime? _lastProcessedAt;
@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(
collections: _collections,
activeCollectionId: _selectedCollection?.id,
onCollectionChanged: _setActiveCollection,
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 now = DateTime.now();
if (_lastProcessedHwId == hwId &&
_lastProcessedAt != null &&
now.difference(_lastProcessedAt!) < _duplicateCooldown) {
return true;
}
_lastProcessedHwId = hwId;
_lastProcessedAt = now;
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, confirmation_count')
.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);
await _ensureValidationVote(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,
});
}
Future<void> _ensureValidationVote(String hwId) async {
final userId = supabase.auth.currentUser!.id;
final existingVote = await supabase
.from('car_votes')
.select('id')
.eq('hw_id', hwId)
.eq('user_id', userId)
.maybeSingle();
if (existingVote != null) return;
await supabase.from('car_votes').insert({
'hw_id': hwId,
'user_id': userId,
});
}
}
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;
final confirmations = (car['confirmation_count'] as num?)?.toInt() ?? 0;
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: 4),
Text(
'$confirmations confirmation${confirmations == 1 ? '' : 's'}',
style: const TextStyle(
color: AppColors.textHint,
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'),
),
),
],
),
);
}
}