hwhub/lib/screens/scan_tab.dart
Lukas Müllner 91f25e0cef feat(v1.1): complete UI overhaul with branded theme, garage, and image uploads
- Custom Material 3 theme with Hot Wheels branding (orange/red gradient, navy accents)
- Locally bundled Poppins font (Regular, Medium, SemiBold, Bold)
- Redesigned login screen with full-bleed background image and frosted glass form
- Bottom navigation shell: My Garage / Scan / Profile tabs
- My Garage screen with grid view, search, stats, detail bottom sheet
- Edit and delete car details from the detail sheet
- Skip button for quick-add with minimal info
- Camera photo upload to Supabase Storage (UUID-based unguessable paths)
- Change/add photo from car detail view
- Profile screen with change password, about dialog, sign out
- Scan tab with camera scanner and manual entry
- Styled scanner screen with crosshair overlay and gradient buttons
- Custom app icon with transparent background and adaptive icon support
- Native splash screen with brand colors
- Auto-refresh garage on tab switch
2026-02-24 07:46:06 +01:00

449 lines
14 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/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;
@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 garage.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
height: 1.5,
),
),
const SizedBox(height: 36),
// ── 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 ? 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 ? 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 {
setState(() => _isBusy = true);
try {
final data = await supabase
.from('hotwheels')
.select()
.eq('hw_id', hwId)
.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 Garage!'),
content:
Text('$hwId is already in your collection.'),
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),
);
if (added == true) {
showGlobalSnackBar('$hwId added to your garage! 🎉');
}
}
} 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;
const _AddCarDialog({required this.hwId});
@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,
});
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,
};
// 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 to Garage'),
),
],
);
}
}