feat(scan+sharing): polish rapid scan feedback and invite UX states

- Add scanner status bar with real-time state (ready/scanning/saved)
- Add success pulse styling after accepted detection for faster repeated scans
- Keep scanner in uninterrupted loop while giving clear visual confirmation
- Add invite busy state in manage collection to prevent duplicate submits
- Improve collaborative management responsiveness with explicit inviting state
This commit is contained in:
Lukas Müllner 2026-03-04 10:47:45 +01:00
parent e4afe7b63c
commit 99da5cf1ca
2 changed files with 51 additions and 6 deletions

View file

@ -24,6 +24,8 @@ class _ScannerScreenState extends State<ScannerScreen> {
bool _isBusy = false;
bool _cameraReady = false;
String? _lastDetected;
bool _scanAccepted = false;
String _statusText = 'Ready to scan';
// Matches typical Hot Wheels model IDs: 25 uppercase letters followed by
// 24 digits, e.g. JKF21, HCV73, GRX33, FYD83.
@ -67,7 +69,10 @@ class _ScannerScreenState extends State<ScannerScreen> {
Future<void> _captureAndScan() async {
if (_isBusy || _cameraController == null || !_cameraController!.value.isInitialized) return;
setState(() => _isBusy = true);
setState(() {
_isBusy = true;
_statusText = 'Scanning…';
});
try {
final xFile = await _cameraController!.takePicture();
@ -118,8 +123,16 @@ class _ScannerScreenState extends State<ScannerScreen> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Scan error: $e'), backgroundColor: Colors.red),
);
setState(() => _statusText = 'Scan failed, try again');
} finally {
if (mounted) setState(() => _isBusy = false);
if (mounted) {
setState(() {
_isBusy = false;
if (_statusText == 'Scanning…') {
_statusText = 'Ready to scan';
}
});
}
}
}
@ -134,7 +147,15 @@ class _ScannerScreenState extends State<ScannerScreen> {
if (!mounted) return;
if (keepScanning) {
setState(() => _lastDetected = null);
setState(() {
_lastDetected = null;
_scanAccepted = true;
_statusText = 'Saved. Ready for next scan';
});
Future<void>.delayed(const Duration(milliseconds: 650), () {
if (!mounted) return;
setState(() => _scanAccepted = false);
});
} else {
Navigator.of(context).pop();
}
@ -167,6 +188,22 @@ class _ScannerScreenState extends State<ScannerScreen> {
),
body: Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
color: _scanAccepted
? AppColors.success.withValues(alpha: 0.12)
: Colors.black.withValues(alpha: 0.55),
child: Text(
_statusText,
textAlign: TextAlign.center,
style: TextStyle(
color: _scanAccepted ? AppColors.success : Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
// Camera preview
Expanded(
child: _cameraReady
@ -192,7 +229,10 @@ class _ScannerScreenState extends State<ScannerScreen> {
height: 100,
decoration: BoxDecoration(
border: Border.all(
color: AppColors.orange.withValues(alpha: 0.8),
color: (_scanAccepted
? AppColors.success
: AppColors.orange)
.withValues(alpha: 0.9),
width: 2.5,
),
borderRadius: BorderRadius.circular(16),

View file

@ -18,6 +18,7 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
late Collection _collection;
List<CollectionMember> _members = [];
bool _isLoading = true;
bool _isInviting = false;
@override
void initState() {
@ -128,6 +129,7 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
}
Future<void> _inviteMember() async {
if (_isInviting) return;
final emailCtrl = TextEditingController();
final result = await showDialog<bool>(
@ -192,6 +194,7 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
}
try {
setState(() => _isInviting = true);
await CollectionService.inviteByEmail(
collectionId: _collection.id,
email: email,
@ -200,6 +203,8 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
_loadMembers();
} catch (e) {
showGlobalSnackBar('$e', isError: true);
} finally {
if (mounted) setState(() => _isInviting = false);
}
}
@ -347,9 +352,9 @@ class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
const Spacer(),
if (_collection.isOwner)
TextButton.icon(
onPressed: _inviteMember,
onPressed: _isInviting ? null : _inviteMember,
icon: const Icon(Icons.person_add, size: 18),
label: const Text('Invite'),
label: Text(_isInviting ? 'Inviting…' : 'Invite'),
),
],
),