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 _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: 2–5 uppercase letters followed by
|
// Matches typical Hot Wheels model IDs: 2–5 uppercase letters followed by
|
||||||
// 2–4 digits, e.g. JKF21, HCV73, GRX33, FYD83.
|
// 2–4 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),
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue