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 _isBusy = false;
bool _cameraReady = false; bool _cameraReady = false;
String? _lastDetected; String? _lastDetected;
bool _scanAccepted = false;
String _statusText = 'Ready to scan';
// Matches typical Hot Wheels model IDs: 25 uppercase letters followed by // Matches typical Hot Wheels model IDs: 25 uppercase letters followed by
// 24 digits, e.g. JKF21, HCV73, GRX33, FYD83. // 24 digits, e.g. JKF21, HCV73, GRX33, FYD83.
@ -67,7 +69,10 @@ class _ScannerScreenState extends State<ScannerScreen> {
Future<void> _captureAndScan() async { Future<void> _captureAndScan() async {
if (_isBusy || _cameraController == null || !_cameraController!.value.isInitialized) return; if (_isBusy || _cameraController == null || !_cameraController!.value.isInitialized) return;
setState(() => _isBusy = true); setState(() {
_isBusy = true;
_statusText = 'Scanning…';
});
try { try {
final xFile = await _cameraController!.takePicture(); final xFile = await _cameraController!.takePicture();
@ -118,8 +123,16 @@ class _ScannerScreenState extends State<ScannerScreen> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Scan error: $e'), backgroundColor: Colors.red), SnackBar(content: Text('Scan error: $e'), backgroundColor: Colors.red),
); );
setState(() => _statusText = 'Scan failed, try again');
} finally { } 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 (!mounted) return;
if (keepScanning) { 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 { } else {
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
@ -167,6 +188,22 @@ class _ScannerScreenState extends State<ScannerScreen> {
), ),
body: Column( body: Column(
children: [ 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 // Camera preview
Expanded( Expanded(
child: _cameraReady child: _cameraReady
@ -192,7 +229,10 @@ class _ScannerScreenState extends State<ScannerScreen> {
height: 100, height: 100,
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all( border: Border.all(
color: AppColors.orange.withValues(alpha: 0.8), color: (_scanAccepted
? AppColors.success
: AppColors.orange)
.withValues(alpha: 0.9),
width: 2.5, width: 2.5,
), ),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),

View file

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