hwhub/lib/screens/scan_tab.dart
Lukas Müllner 94e0ab436b
Update lib/screens/scan_tab.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-24 20:14:48 +01:00

570 lines
19 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);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to load collections: $e'),
),
);
}
}
@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(() {
final matching =
_collections.where((c) => c.id == id);
_selectedCollection =
matching.isNotEmpty ? matching.first : null;
});
},
),
),
),
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'),
),
],
);
}
}