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:
parent
e4afe7b63c
commit
99da5cf1ca
2 changed files with 51 additions and 6 deletions
|
|
@ -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: 2–5 uppercase letters followed by
|
||||
// 2–4 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),
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
Loading…
Reference in a new issue