feat(scan+images): continuous scan loop and signed-url refresh fallback
- Add continuous scanner callback flow so detections are processed in-place - Keep scanner open after add/lookup so users can continue scanning immediately - Integrate scan processing with catalog branch logic without route round-trips - Add signed URL refresh strategy for private image links on load failures - Trigger signed-link regeneration from garage cards and detail view retry action - Keep multi-select/move workflow and pagination compatible with refreshed image state
This commit is contained in:
parent
647448d7e6
commit
012ff411a1
4 changed files with 99 additions and 22 deletions
|
|
@ -10,7 +10,9 @@ import 'theme/app_colors.dart';
|
|||
///
|
||||
/// The detected ID is returned via Navigator.pop(context, hwId).
|
||||
class ScannerScreen extends StatefulWidget {
|
||||
const ScannerScreen({super.key});
|
||||
final Future<bool> Function(String hwId)? onDetected;
|
||||
|
||||
const ScannerScreen({super.key, this.onDetected});
|
||||
|
||||
@override
|
||||
State<ScannerScreen> createState() => _ScannerScreenState();
|
||||
|
|
@ -94,6 +96,9 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||
|
||||
if (found != null) {
|
||||
setState(() => _lastDetected = found);
|
||||
if (widget.onDetected != null) {
|
||||
await _submitDetected(found);
|
||||
}
|
||||
} else {
|
||||
// Show all detected text so user knows what was seen.
|
||||
final allText = recognized.blocks.map((b) => b.text).join('\n');
|
||||
|
|
@ -118,6 +123,29 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _submitDetected(String hwId) async {
|
||||
if (widget.onDetected == null) {
|
||||
if (mounted) Navigator.of(context).pop(hwId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final keepScanning = await widget.onDetected!(hwId);
|
||||
if (!mounted) return;
|
||||
|
||||
if (keepScanning) {
|
||||
setState(() => _lastDetected = null);
|
||||
} else {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Process error: $e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cameraController?.dispose();
|
||||
|
|
@ -229,7 +257,9 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(_lastDetected),
|
||||
onPressed: _isBusy || _lastDetected == null
|
||||
? null
|
||||
: () => _submitDetected(_lastDetected!),
|
||||
child: const Text('Use This'),
|
||||
),
|
||||
],
|
||||
|
|
@ -298,7 +328,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||
);
|
||||
|
||||
if (result != null && context.mounted) {
|
||||
Navigator.of(context).pop(result);
|
||||
await _submitDetected(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
|
||||
bool _selectionMode = false;
|
||||
final Set<int> _selectedIds = <int>{};
|
||||
final Set<int> _refreshingImageIds = <int>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -266,6 +267,7 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
(context, index) {
|
||||
final car = _filteredCars[index];
|
||||
final global = car['global_cars'] as Map<String, dynamic>?;
|
||||
final carId = car['id'] as int;
|
||||
return CarCard(
|
||||
hwId: car['hw_id'] as String? ?? '???',
|
||||
name: global?['name'] as String?,
|
||||
|
|
@ -273,10 +275,11 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
year: global?['year'] as int?,
|
||||
color: global?['color'] as String?,
|
||||
imageUrl: car['signed_image_url'] as String?,
|
||||
isSelected: _selectedIds.contains(car['id'] as int),
|
||||
isSelected: _selectedIds.contains(carId),
|
||||
addedAt: car['created_at'] != null
|
||||
? DateTime.tryParse(car['created_at'])
|
||||
: null,
|
||||
onImageError: () => _refreshSignedUrlForCar(carId),
|
||||
onTap: () => _selectionMode
|
||||
? _toggleCarSelection(car)
|
||||
: _showCarDetails(car),
|
||||
|
|
@ -434,6 +437,30 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshSignedUrlForCar(int carId) async {
|
||||
if (_refreshingImageIds.contains(carId)) return;
|
||||
|
||||
final index = _cars.indexWhere((c) => c['id'] == carId);
|
||||
if (index == -1) return;
|
||||
|
||||
final path = _cars[index]['user_image_url'] as String?;
|
||||
if (path == null || path.isEmpty) return;
|
||||
|
||||
_refreshingImageIds.add(carId);
|
||||
try {
|
||||
final signed = await StorageService.createSignedUrl(path);
|
||||
if (!mounted || signed == null) return;
|
||||
setState(() {
|
||||
_cars[index] = {
|
||||
..._cars[index],
|
||||
'signed_image_url': signed,
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
_refreshingImageIds.remove(carId);
|
||||
}
|
||||
}
|
||||
|
||||
void _showCarDetails(Map<String, dynamic> car) {
|
||||
if (_selectionMode) {
|
||||
_toggleCarSelection(car);
|
||||
|
|
@ -485,7 +512,20 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
? Image.network(
|
||||
imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => _imagePlaceholder(),
|
||||
errorBuilder: (context, error, stackTrace) => Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(child: _imagePlaceholder()),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: TextButton.icon(
|
||||
onPressed: () => _refreshSignedUrlForCar(car['id'] as int),
|
||||
icon: const Icon(Icons.refresh, size: 16),
|
||||
label: const Text('Refresh image link'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: _imagePlaceholder(),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -209,12 +209,13 @@ class ScanTabState extends State<ScanTab> {
|
|||
}
|
||||
|
||||
Future<void> _openScanner() async {
|
||||
final hwId = await navigatorKey.currentState!.push<String>(
|
||||
MaterialPageRoute(builder: (_) => const ScannerScreen()),
|
||||
await navigatorKey.currentState!.push<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ScannerScreen(
|
||||
onDetected: (hwId) => _processHwId(hwId),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (hwId == null || !mounted) return;
|
||||
await _processHwId(hwId);
|
||||
}
|
||||
|
||||
Future<void> _manualEntry() async {
|
||||
|
|
@ -253,9 +254,9 @@ class ScanTabState extends State<ScanTab> {
|
|||
await _processHwId(result);
|
||||
}
|
||||
|
||||
Future<void> _processHwId(String hwId) async {
|
||||
Future<bool> _processHwId(String hwId) async {
|
||||
final collection = _selectedCollection;
|
||||
if (collection == null) return;
|
||||
if (collection == null) return false;
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
|
|
@ -268,10 +269,10 @@ class ScanTabState extends State<ScanTab> {
|
|||
.maybeSingle();
|
||||
|
||||
if (existing != null) {
|
||||
if (!mounted) return;
|
||||
if (!mounted) return false;
|
||||
setState(() => _isBusy = false);
|
||||
await showDialog(
|
||||
context: context,
|
||||
context: navigatorKey.currentContext ?? context,
|
||||
builder: (_) => AlertDialog(
|
||||
icon: const Icon(Icons.check_circle,
|
||||
color: AppColors.success, size: 48),
|
||||
|
|
@ -285,7 +286,7 @@ class ScanTabState extends State<ScanTab> {
|
|||
],
|
||||
),
|
||||
);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
final globalCar = await supabase
|
||||
|
|
@ -294,12 +295,12 @@ class ScanTabState extends State<ScanTab> {
|
|||
.eq('hw_id', hwId)
|
||||
.maybeSingle();
|
||||
|
||||
if (!mounted) return;
|
||||
if (!mounted) return false;
|
||||
setState(() => _isBusy = false);
|
||||
|
||||
if (globalCar != null) {
|
||||
final addConfirmed = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
context: navigatorKey.currentContext ?? context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
|
|
@ -312,12 +313,12 @@ class ScanTabState extends State<ScanTab> {
|
|||
|
||||
if (addConfirmed == true) {
|
||||
await _addToCollection(collection.id, hwId);
|
||||
if (!mounted) return;
|
||||
if (!mounted) return false;
|
||||
showGlobalSnackBar('$hwId added to "${collection.name}"! 🎉');
|
||||
}
|
||||
} else {
|
||||
final discovery = await showModalBottomSheet<_NewDiscoveryData>(
|
||||
context: context,
|
||||
context: navigatorKey.currentContext ?? context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
|
|
@ -336,13 +337,15 @@ class ScanTabState extends State<ScanTab> {
|
|||
year: discovery.year,
|
||||
);
|
||||
await _addToCollection(collection.id, hwId, notes: discovery.notes);
|
||||
if (!mounted) return;
|
||||
if (!mounted) return false;
|
||||
showGlobalSnackBar('$hwId added to "${collection.name}"! 🎉');
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _isBusy = false);
|
||||
showGlobalSnackBar('DB error: $e', isError: true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ class CarCard extends StatelessWidget {
|
|||
final DateTime? addedAt;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onLongPress;
|
||||
final VoidCallback? onImageError;
|
||||
final bool isSelected;
|
||||
|
||||
const CarCard({
|
||||
|
|
@ -25,6 +26,7 @@ class CarCard extends StatelessWidget {
|
|||
this.addedAt,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.onImageError,
|
||||
this.isSelected = false,
|
||||
});
|
||||
|
||||
|
|
@ -65,8 +67,10 @@ class CarCard extends StatelessWidget {
|
|||
? Image.network(
|
||||
imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
_PlaceholderIcon(isDark: isDark),
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
onImageError?.call();
|
||||
return _PlaceholderIcon(isDark: isDark);
|
||||
},
|
||||
)
|
||||
: _PlaceholderIcon(isDark: isDark),
|
||||
),
|
||||
|
|
|
|||
Loading…
Reference in a new issue