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).
|
/// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue