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:
Lukas Müllner 2026-03-04 10:34:24 +01:00
parent 647448d7e6
commit 012ff411a1
4 changed files with 99 additions and 22 deletions

View file

@ -10,7 +10,9 @@ import 'theme/app_colors.dart';
/// ///
/// The detected ID is returned via Navigator.pop(context, hwId). /// The detected ID is returned via Navigator.pop(context, hwId).
class ScannerScreen extends StatefulWidget { class ScannerScreen extends StatefulWidget {
const ScannerScreen({super.key}); final Future<bool> Function(String hwId)? onDetected;
const ScannerScreen({super.key, this.onDetected});
@override @override
State<ScannerScreen> createState() => _ScannerScreenState(); State<ScannerScreen> createState() => _ScannerScreenState();
@ -94,6 +96,9 @@ class _ScannerScreenState extends State<ScannerScreen> {
if (found != null) { if (found != null) {
setState(() => _lastDetected = found); setState(() => _lastDetected = found);
if (widget.onDetected != null) {
await _submitDetected(found);
}
} else { } else {
// Show all detected text so user knows what was seen. // Show all detected text so user knows what was seen.
final allText = recognized.blocks.map((b) => b.text).join('\n'); 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 @override
void dispose() { void dispose() {
_cameraController?.dispose(); _cameraController?.dispose();
@ -229,7 +257,9 @@ class _ScannerScreenState extends State<ScannerScreen> {
), ),
), ),
ElevatedButton( ElevatedButton(
onPressed: () => Navigator.of(context).pop(_lastDetected), onPressed: _isBusy || _lastDetected == null
? null
: () => _submitDetected(_lastDetected!),
child: const Text('Use This'), child: const Text('Use This'),
), ),
], ],
@ -298,7 +328,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
); );
if (result != null && context.mounted) { if (result != null && context.mounted) {
Navigator.of(context).pop(result); await _submitDetected(result);
} }
} }
} }

View file

@ -40,6 +40,7 @@ class GarageScreenState extends State<GarageScreen> {
bool _selectionMode = false; bool _selectionMode = false;
final Set<int> _selectedIds = <int>{}; final Set<int> _selectedIds = <int>{};
final Set<int> _refreshingImageIds = <int>{};
@override @override
void initState() { void initState() {
@ -266,6 +267,7 @@ class GarageScreenState extends State<GarageScreen> {
(context, index) { (context, index) {
final car = _filteredCars[index]; final car = _filteredCars[index];
final global = car['global_cars'] as Map<String, dynamic>?; final global = car['global_cars'] as Map<String, dynamic>?;
final carId = car['id'] as int;
return CarCard( return CarCard(
hwId: car['hw_id'] as String? ?? '???', hwId: car['hw_id'] as String? ?? '???',
name: global?['name'] as String?, name: global?['name'] as String?,
@ -273,10 +275,11 @@ class GarageScreenState extends State<GarageScreen> {
year: global?['year'] as int?, year: global?['year'] as int?,
color: global?['color'] as String?, color: global?['color'] as String?,
imageUrl: car['signed_image_url'] 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 addedAt: car['created_at'] != null
? DateTime.tryParse(car['created_at']) ? DateTime.tryParse(car['created_at'])
: null, : null,
onImageError: () => _refreshSignedUrlForCar(carId),
onTap: () => _selectionMode onTap: () => _selectionMode
? _toggleCarSelection(car) ? _toggleCarSelection(car)
: _showCarDetails(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) { void _showCarDetails(Map<String, dynamic> car) {
if (_selectionMode) { if (_selectionMode) {
_toggleCarSelection(car); _toggleCarSelection(car);
@ -485,7 +512,20 @@ class GarageScreenState extends State<GarageScreen> {
? Image.network( ? Image.network(
imageUrl, imageUrl,
fit: BoxFit.cover, 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(), : _imagePlaceholder(),
), ),

View file

@ -209,12 +209,13 @@ class ScanTabState extends State<ScanTab> {
} }
Future<void> _openScanner() async { Future<void> _openScanner() async {
final hwId = await navigatorKey.currentState!.push<String>( await navigatorKey.currentState!.push<void>(
MaterialPageRoute(builder: (_) => const ScannerScreen()), MaterialPageRoute(
builder: (_) => ScannerScreen(
onDetected: (hwId) => _processHwId(hwId),
),
),
); );
if (hwId == null || !mounted) return;
await _processHwId(hwId);
} }
Future<void> _manualEntry() async { Future<void> _manualEntry() async {
@ -253,9 +254,9 @@ class ScanTabState extends State<ScanTab> {
await _processHwId(result); await _processHwId(result);
} }
Future<void> _processHwId(String hwId) async { Future<bool> _processHwId(String hwId) async {
final collection = _selectedCollection; final collection = _selectedCollection;
if (collection == null) return; if (collection == null) return false;
setState(() => _isBusy = true); setState(() => _isBusy = true);
@ -268,10 +269,10 @@ class ScanTabState extends State<ScanTab> {
.maybeSingle(); .maybeSingle();
if (existing != null) { if (existing != null) {
if (!mounted) return; if (!mounted) return false;
setState(() => _isBusy = false); setState(() => _isBusy = false);
await showDialog( await showDialog(
context: context, context: navigatorKey.currentContext ?? context,
builder: (_) => AlertDialog( builder: (_) => AlertDialog(
icon: const Icon(Icons.check_circle, icon: const Icon(Icons.check_circle,
color: AppColors.success, size: 48), color: AppColors.success, size: 48),
@ -285,7 +286,7 @@ class ScanTabState extends State<ScanTab> {
], ],
), ),
); );
return; return true;
} }
final globalCar = await supabase final globalCar = await supabase
@ -294,12 +295,12 @@ class ScanTabState extends State<ScanTab> {
.eq('hw_id', hwId) .eq('hw_id', hwId)
.maybeSingle(); .maybeSingle();
if (!mounted) return; if (!mounted) return false;
setState(() => _isBusy = false); setState(() => _isBusy = false);
if (globalCar != null) { if (globalCar != null) {
final addConfirmed = await showModalBottomSheet<bool>( final addConfirmed = await showModalBottomSheet<bool>(
context: context, context: navigatorKey.currentContext ?? context,
isScrollControlled: true, isScrollControlled: true,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)), borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
@ -312,12 +313,12 @@ class ScanTabState extends State<ScanTab> {
if (addConfirmed == true) { if (addConfirmed == true) {
await _addToCollection(collection.id, hwId); await _addToCollection(collection.id, hwId);
if (!mounted) return; if (!mounted) return false;
showGlobalSnackBar('$hwId added to "${collection.name}"! 🎉'); showGlobalSnackBar('$hwId added to "${collection.name}"! 🎉');
} }
} else { } else {
final discovery = await showModalBottomSheet<_NewDiscoveryData>( final discovery = await showModalBottomSheet<_NewDiscoveryData>(
context: context, context: navigatorKey.currentContext ?? context,
isScrollControlled: true, isScrollControlled: true,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)), borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
@ -336,13 +337,15 @@ class ScanTabState extends State<ScanTab> {
year: discovery.year, year: discovery.year,
); );
await _addToCollection(collection.id, hwId, notes: discovery.notes); await _addToCollection(collection.id, hwId, notes: discovery.notes);
if (!mounted) return; if (!mounted) return false;
showGlobalSnackBar('$hwId added to "${collection.name}"! 🎉'); showGlobalSnackBar('$hwId added to "${collection.name}"! 🎉');
} }
} }
return true;
} catch (e) { } catch (e) {
if (mounted) setState(() => _isBusy = false); if (mounted) setState(() => _isBusy = false);
showGlobalSnackBar('DB error: $e', isError: true); showGlobalSnackBar('DB error: $e', isError: true);
return true;
} }
} }

View file

@ -12,6 +12,7 @@ class CarCard extends StatelessWidget {
final DateTime? addedAt; final DateTime? addedAt;
final VoidCallback? onTap; final VoidCallback? onTap;
final VoidCallback? onLongPress; final VoidCallback? onLongPress;
final VoidCallback? onImageError;
final bool isSelected; final bool isSelected;
const CarCard({ const CarCard({
@ -25,6 +26,7 @@ class CarCard extends StatelessWidget {
this.addedAt, this.addedAt,
this.onTap, this.onTap,
this.onLongPress, this.onLongPress,
this.onImageError,
this.isSelected = false, this.isSelected = false,
}); });
@ -65,8 +67,10 @@ class CarCard extends StatelessWidget {
? Image.network( ? Image.network(
imageUrl!, imageUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => errorBuilder: (context, error, stackTrace) {
_PlaceholderIcon(isDark: isDark), onImageError?.call();
return _PlaceholderIcon(isDark: isDark);
},
) )
: _PlaceholderIcon(isDark: isDark), : _PlaceholderIcon(isDark: isDark),
), ),