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).
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);
}
}
}

View file

@ -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(),
),

View file

@ -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;
}
}

View file

@ -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),
),