From 4cc7de158eb5811593c7d4d79b6c19802e233e1d Mon Sep 17 00:00:00 2001 From: gary Date: Sat, 21 Feb 2026 23:17:48 +0100 Subject: [PATCH] Fix camera review flow with dedicated route and lost-data recovery --- lib/main.dart | 737 +++++++++++++++++++++++++++++++------------------- 1 file changed, 458 insertions(+), 279 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 15ea070..73ed5eb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -40,10 +40,10 @@ class MosaicHomePage extends StatefulWidget { State createState() => _MosaicHomePageState(); } -class _MosaicHomePageState extends State { +class _MosaicHomePageState extends State + with WidgetsBindingObserver { final ImagePicker _picker = ImagePicker(); - final TextEditingController _photoCapNameCtrl = TextEditingController(); - final TextEditingController _photoCapHexCtrl = TextEditingController(); + // Capture review state lives in a dedicated full-screen route. final TextEditingController _gridWidthCtrl = TextEditingController(text: '40'); final TextEditingController _gridHeightCtrl = @@ -59,6 +59,8 @@ class _MosaicHomePageState extends State { bool _isCatalogLoaded = false; CatalogViewMode _catalogViewMode = CatalogViewMode.grid; ColorExtractionMode _colorExtractionMode = ColorExtractionMode.dominant; + bool _isCaptureFlowInProgress = false; + bool _isRecoveringCapture = false; double _fidelityStructure = 0.5; double _ditheringStrength = 0.35; @@ -71,20 +73,31 @@ class _MosaicHomePageState extends State { @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _gridWidthCtrl.addListener(_scheduleRegenerate); _gridHeightCtrl.addListener(_scheduleRegenerate); _capSizeCtrl.addListener(_scheduleRegenerate); _loadCatalog(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _recoverCaptureOnResumeOrStart(); + }); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _recoverCaptureOnResumeOrStart(); + } } @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _debounceTimer?.cancel(); _gridWidthCtrl.dispose(); _gridHeightCtrl.dispose(); _capSizeCtrl.dispose(); - _photoCapNameCtrl.dispose(); - _photoCapHexCtrl.dispose(); + // No extra capture controllers to dispose. super.dispose(); } @@ -147,30 +160,96 @@ class _MosaicHomePageState extends State { }); } - Future _captureCapPhoto() async { - final XFile? captured = await _picker.pickImage( - source: ImageSource.camera, - preferredCameraDevice: CameraDevice.rear, - imageQuality: 95, - maxWidth: 1800, - ); - if (captured == null) { - debugPrint('[cap-photo] Capture cancelled by user.'); + Future _pendingCaptureFile() async { + final docs = await getApplicationDocumentsDirectory(); + return File('${docs.path}/pending_cap_capture.jpg'); + } + + Future _setPendingCapture(Uint8List? bytes) async { + final file = await _pendingCaptureFile(); + if (bytes == null) { + if (await file.exists()) { + await file.delete(); + } return; } + await file.writeAsBytes(bytes, flush: true); + } - Uint8List bytes; + Future _readPendingCapture() async { + final file = await _pendingCaptureFile(); + if (!await file.exists()) return null; + return file.readAsBytes(); + } + + Future _recoverCaptureOnResumeOrStart() async { + if (_isRecoveringCapture || _isCaptureFlowInProgress || !mounted) return; + _isRecoveringCapture = true; try { - bytes = await captured.readAsBytes(); - } catch (e, st) { - debugPrint('[cap-photo] Failed to read captured bytes: $e\n$st'); - final fallbackImage = img.Image(width: 1, height: 1) - ..setPixelRgb(0, 0, 255, 152, 0); - bytes = Uint8List.fromList( - img.encodePng(fallbackImage, level: 1), - ); + Uint8List? recovered; + try { + final lost = await _picker.retrieveLostData(); + if (!lost.isEmpty) { + final file = lost.file ?? + (lost.files?.isNotEmpty == true ? lost.files!.first : null); + if (file != null) { + recovered = await file.readAsBytes(); + await _setPendingCapture(recovered); + } + } + } catch (e, st) { + debugPrint('[cap-photo] retrieveLostData failed: $e\n$st'); + } + recovered ??= await _readPendingCapture(); + if (recovered == null || !mounted) return; + await _openCaptureReviewFlow(recovered, recoveredFromPending: true); + } finally { + _isRecoveringCapture = false; } + } + Future _captureCapPhoto() async { + if (_isCaptureFlowInProgress) return; + + setState(() => _isCaptureFlowInProgress = true); + try { + final XFile? captured = await _picker.pickImage( + source: ImageSource.camera, + preferredCameraDevice: CameraDevice.rear, + imageQuality: 95, + maxWidth: 1800, + ); + if (captured == null) { + debugPrint('[cap-photo] Capture cancelled by user.'); + await _setPendingCapture(null); + return; + } + + Uint8List bytes; + try { + bytes = await captured.readAsBytes(); + } catch (e, st) { + debugPrint('[cap-photo] Failed to read captured bytes: $e\n$st'); + final fallbackImage = img.Image(width: 1, height: 1) + ..setPixelRgb(0, 0, 255, 152, 0); + bytes = Uint8List.fromList(img.encodePng(fallbackImage, level: 1)); + } + + await _setPendingCapture(bytes); + await _openCaptureReviewFlow(bytes); + } finally { + if (mounted) { + setState(() => _isCaptureFlowInProgress = false); + } else { + _isCaptureFlowInProgress = false; + } + } + } + + Future _openCaptureReviewFlow( + Uint8List bytes, { + bool recoveredFromPending = false, + }) async { Map detected; String? detectionWarning; try { @@ -198,263 +277,41 @@ class _MosaicHomePageState extends State { if (!mounted) return; - Color dominantColor = Color(detected['dominantColor'] as int); - Color averageColor = Color(detected['averageColor'] as int); - Uint8List previewBytes = detected['previewPng'] as Uint8List; - final bool usedFallback = detected['usedFallback'] as bool? ?? false; - final imageW = (detected['imageW'] as num).toDouble(); - final imageH = (detected['imageH'] as num).toDouble(); - double circleX = (detected['circleX'] as num).toDouble() / imageW; - double circleY = (detected['circleY'] as num).toDouble() / imageH; - double circleR = - (detected['circleR'] as num).toDouble() / math.min(imageW, imageH); - - ColorExtractionMode mode = _colorExtractionMode; - Color selected = - mode == ColorExtractionMode.dominant ? dominantColor : averageColor; - _photoCapNameCtrl.text = 'Fotografierter Deckel'; - _photoCapHexCtrl.text = _colorToHex(selected); - - Timer? liveDebounce; - int recalcToken = 0; - - final initialCircle = _clampNormalizedCircleToImage( - imageW: imageW, - imageH: imageH, - circleX: circleX, - circleY: circleY, - circleR: circleR, - ); - circleX = initialCircle.x; - circleY = initialCircle.y; - circleR = initialCircle.r; - - await showDialog( - context: context, - builder: (ctx) { - return StatefulBuilder( - builder: (ctx, setDialogState) { - Future recalculate({bool updateDialog = true}) async { - final localToken = ++recalcToken; - if (updateDialog) { - setDialogState(() {}); - } - try { - final adjusted = await compute( - _extractCapFromAdjustedCircleIsolate, - { - 'sourceBytes': bytes, - 'circleX': circleX, - 'circleY': circleY, - 'circleR': circleR, - }, - ); - if (localToken != recalcToken) return; - dominantColor = Color(adjusted['dominantColor'] as int); - averageColor = Color(adjusted['averageColor'] as int); - previewBytes = adjusted['previewPng'] as Uint8List; - selected = mode == ColorExtractionMode.dominant - ? dominantColor - : averageColor; - _photoCapHexCtrl.text = _colorToHex(selected); - } catch (e, st) { - debugPrint('[cap-photo] Recalculate failed: $e\n$st'); - if (localToken != recalcToken) return; - detectionWarning = - '⚠️ Aktualisierung fehlgeschlagen. Bitte Farbe manuell prüfen.'; - const fallback = Colors.orange; - dominantColor = fallback; - averageColor = fallback; - selected = fallback; - _photoCapHexCtrl.text = _colorToHex(selected); - } - if (ctx.mounted) { - setDialogState(() {}); - } - } - - void scheduleLiveRecalculate() { - liveDebounce?.cancel(); - liveDebounce = Timer(const Duration(milliseconds: 180), () { - recalculate(updateDialog: false); - }); - } - - void setAndClampCircle({double? x, double? y, double? r}) { - if (x != null) circleX = x; - if (y != null) circleY = y; - if (r != null) circleR = r; - final clamped = _clampNormalizedCircleToImage( - imageW: imageW, - imageH: imageH, - circleX: circleX, - circleY: circleY, - circleR: circleR, - ); - circleX = clamped.x; - circleY = clamped.y; - circleR = clamped.r; - } - - return AlertDialog( - title: const Text('Deckel fotografieren'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _CircleAdjustOverlay( - imageBytes: bytes, - imageWidth: imageW, - imageHeight: imageH, - circleX: circleX, - circleY: circleY, - circleR: circleR, - onCircleChanged: (x, y, r) { - setDialogState(() { - setAndClampCircle(x: x, y: y, r: r); - }); - scheduleLiveRecalculate(); - }, - ), - const SizedBox(height: 8), - Text( - usedFallback - ? 'Kreiserkennung per Fallback. Kreis direkt im Foto verschieben/zoomen.' - : 'Kreis erkannt. Direkt im Foto ziehen oder mit Pinch/Slider anpassen.', - style: Theme.of(context).textTheme.bodySmall, - ), - if (detectionWarning != null) ...[ - const SizedBox(height: 6), - Text( - detectionWarning!, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.error, - fontWeight: FontWeight.w600, - ), - ), - ], - const SizedBox(height: 8), - SegmentedButton( - segments: const [ - ButtonSegment( - value: ColorExtractionMode.dominant, - label: Text('Dominant (robust)'), - ), - ButtonSegment( - value: ColorExtractionMode.average, - label: Text('Gewichteter Mittelwert'), - ), - ], - selected: {mode}, - onSelectionChanged: (selection) { - mode = selection.first; - selected = mode == ColorExtractionMode.dominant - ? dominantColor - : averageColor; - _photoCapHexCtrl.text = _colorToHex(selected); - setDialogState(() {}); - }, - ), - Slider( - value: circleR, - min: 0.08, - max: 0.49, - label: 'Radius', - onChanged: (v) { - setDialogState(() => setAndClampCircle(r: v)); - scheduleLiveRecalculate(); - }, - ), - const SizedBox(height: 8), - Row( - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: selected, - border: Border.all(color: Colors.black26), - borderRadius: BorderRadius.circular(8), - ), - ), - const SizedBox(width: 10), - Expanded( - child: Text( - 'Erkannte Farbe: ${_colorToHex(selected)}', - style: const TextStyle(fontWeight: FontWeight.w600), - ), - ), - ], - ), - const SizedBox(height: 10), - TextField( - controller: _photoCapNameCtrl, - decoration: const InputDecoration(labelText: 'Name'), - ), - const SizedBox(height: 8), - TextField( - controller: _photoCapHexCtrl, - decoration: - const InputDecoration(labelText: 'Hex (#RRGGBB)'), - onChanged: (value) { - final parsed = _parseHex(value); - if (parsed == null) return; - setDialogState(() => selected = parsed); - }, - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Abbrechen')), - FilledButton( - onPressed: () async { - final name = _photoCapNameCtrl.text.trim(); - if (name.isEmpty) return; - final parsed = _parseHex(_photoCapHexCtrl.text); - final entry = CapCatalogEntry.newEntry( - name: name, color: parsed ?? selected); - _colorExtractionMode = mode; - entry.imagePath = - await _saveThumbnail(previewBytes, entry.id); - _catalog.add(entry); - await _persistCatalog(); - if (!mounted) return; - setState(() {}); - if (!ctx.mounted) return; - Navigator.pop(ctx); - _scheduleRegenerate(); - }, - child: const Text('Zum Katalog hinzufügen'), - ), - ], - ); - }, - ); - }, + final reviewResult = + await Navigator.of(context).push<_CapPhotoReviewResult>( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => _CapPhotoReviewPage( + imageBytes: bytes, + detected: detected, + initialMode: _colorExtractionMode, + initialName: 'Fotografierter Deckel', + initialWarning: detectionWarning, + ), + ), ); - liveDebounce?.cancel(); + if (reviewResult == null) { + await _setPendingCapture(null); + return; + } + + _colorExtractionMode = reviewResult.mode; + final entry = CapCatalogEntry.newEntry( + name: reviewResult.name, + color: reviewResult.color, + ); + entry.imagePath = await _saveThumbnail(reviewResult.previewBytes, entry.id); + _catalog.add(entry); + await _persistCatalog(); + await _setPendingCapture(null); + + if (!mounted) return; + setState(() {}); + _scheduleRegenerate(); } - _NormalizedCircle _clampNormalizedCircleToImage({ - required double imageW, - required double imageH, - required double circleX, - required double circleY, - required double circleR, - }) { - return _clampCircleNormalized( - imageW: imageW, - imageH: imageH, - circleX: circleX, - circleY: circleY, - circleR: circleR, - ); - } + // Circle clamping helper is used by the dedicated review route. void _scheduleRegenerate() { if (_sourceImageBytes == null || _result == null) return; @@ -888,9 +745,19 @@ class _MosaicHomePageState extends State { tooltip: 'Rasteransicht', ), OutlinedButton.icon( - onPressed: _captureCapPhoto, - icon: const Icon(Icons.photo_camera_outlined), - label: const Text('Foto')), + onPressed: _isCaptureFlowInProgress + ? null + : _captureCapPhoto, + icon: _isCaptureFlowInProgress + ? const SizedBox( + width: 16, + height: 16, + child: + CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.photo_camera_outlined), + label: Text( + _isCaptureFlowInProgress ? 'Läuft…' : 'Foto')), const SizedBox(width: 8), IconButton( onPressed: _addCapDialog, @@ -1033,6 +900,318 @@ class _MosaicHomePageState extends State { } } +class _CapPhotoReviewResult { + final String name; + final Color color; + final ColorExtractionMode mode; + final Uint8List previewBytes; + + const _CapPhotoReviewResult({ + required this.name, + required this.color, + required this.mode, + required this.previewBytes, + }); +} + +class _CapPhotoReviewPage extends StatefulWidget { + final Uint8List imageBytes; + final Map detected; + final ColorExtractionMode initialMode; + final String initialName; + final String? initialWarning; + + const _CapPhotoReviewPage({ + required this.imageBytes, + required this.detected, + required this.initialMode, + required this.initialName, + this.initialWarning, + }); + + @override + State<_CapPhotoReviewPage> createState() => _CapPhotoReviewPageState(); +} + +class _CapPhotoReviewPageState extends State<_CapPhotoReviewPage> { + late final TextEditingController _nameCtrl; + late final TextEditingController _hexCtrl; + + late Color _dominantColor; + late Color _averageColor; + late Uint8List _previewBytes; + late bool _usedFallback; + late double _imageW; + late double _imageH; + late double _circleX; + late double _circleY; + late double _circleR; + late ColorExtractionMode _mode; + late Color _selected; + String? _warning; + + Timer? _liveDebounce; + int _recalcToken = 0; + + @override + void initState() { + super.initState(); + final detected = widget.detected; + _dominantColor = Color(detected['dominantColor'] as int); + _averageColor = Color(detected['averageColor'] as int); + _previewBytes = detected['previewPng'] as Uint8List; + _usedFallback = detected['usedFallback'] as bool? ?? false; + _imageW = (detected['imageW'] as num).toDouble(); + _imageH = (detected['imageH'] as num).toDouble(); + _circleX = (detected['circleX'] as num).toDouble() / _imageW; + _circleY = (detected['circleY'] as num).toDouble() / _imageH; + _circleR = + (detected['circleR'] as num).toDouble() / math.min(_imageW, _imageH); + + final clamped = _clampCircleNormalized( + imageW: _imageW, + imageH: _imageH, + circleX: _circleX, + circleY: _circleY, + circleR: _circleR, + ); + _circleX = clamped.x; + _circleY = clamped.y; + _circleR = clamped.r; + + _mode = widget.initialMode; + _selected = + _mode == ColorExtractionMode.dominant ? _dominantColor : _averageColor; + _nameCtrl = TextEditingController(text: widget.initialName); + _hexCtrl = TextEditingController(text: _colorToHexStatic(_selected)); + _warning = widget.initialWarning; + } + + @override + void dispose() { + _liveDebounce?.cancel(); + _nameCtrl.dispose(); + _hexCtrl.dispose(); + super.dispose(); + } + + void _setAndClampCircle({double? x, double? y, double? r}) { + if (x != null) _circleX = x; + if (y != null) _circleY = y; + if (r != null) _circleR = r; + final clamped = _clampCircleNormalized( + imageW: _imageW, + imageH: _imageH, + circleX: _circleX, + circleY: _circleY, + circleR: _circleR, + ); + _circleX = clamped.x; + _circleY = clamped.y; + _circleR = clamped.r; + } + + void _scheduleLiveRecalculate() { + _liveDebounce?.cancel(); + _liveDebounce = Timer(const Duration(milliseconds: 180), () { + _recalculate(updateUiBefore: false); + }); + } + + Future _recalculate({bool updateUiBefore = true}) async { + final localToken = ++_recalcToken; + if (updateUiBefore && mounted) setState(() {}); + try { + final adjusted = await compute( + _extractCapFromAdjustedCircleIsolate, + { + 'sourceBytes': widget.imageBytes, + 'circleX': _circleX, + 'circleY': _circleY, + 'circleR': _circleR, + }, + ); + if (localToken != _recalcToken) return; + _dominantColor = Color(adjusted['dominantColor'] as int); + _averageColor = Color(adjusted['averageColor'] as int); + _previewBytes = adjusted['previewPng'] as Uint8List; + _selected = _mode == ColorExtractionMode.dominant + ? _dominantColor + : _averageColor; + _hexCtrl.text = _colorToHexStatic(_selected); + } catch (e, st) { + debugPrint('[cap-photo] Recalculate failed: $e\n$st'); + if (localToken != _recalcToken) return; + _warning = + '⚠️ Aktualisierung fehlgeschlagen. Bitte Farbe manuell prüfen.'; + const fallback = Colors.orange; + _dominantColor = fallback; + _averageColor = fallback; + _selected = fallback; + _hexCtrl.text = _colorToHexStatic(_selected); + } + if (mounted) setState(() {}); + } + + Color? _parseHex(String raw) { + final hex = raw.trim().replaceFirst('#', ''); + if (hex.length != 6) return null; + final value = int.tryParse(hex, radix: 16); + if (value == null) return null; + return Color(0xFF000000 | value); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Deckel prüfen')), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(12), + children: [ + _CircleAdjustOverlay( + imageBytes: widget.imageBytes, + imageWidth: _imageW, + imageHeight: _imageH, + circleX: _circleX, + circleY: _circleY, + circleR: _circleR, + onCircleChanged: (x, y, r) { + setState(() { + _setAndClampCircle(x: x, y: y, r: r); + }); + _scheduleLiveRecalculate(); + }, + ), + const SizedBox(height: 8), + Text( + _usedFallback + ? 'Kreiserkennung per Fallback. Kreis direkt im Foto verschieben/zoomen.' + : 'Kreis erkannt. Direkt im Foto ziehen oder mit Pinch/Slider anpassen.', + style: Theme.of(context).textTheme.bodySmall, + ), + if (_warning != null) ...[ + const SizedBox(height: 6), + Text( + _warning!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + fontWeight: FontWeight.w600, + ), + ), + ], + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment( + value: ColorExtractionMode.dominant, + label: Text('Dominant (robust)'), + ), + ButtonSegment( + value: ColorExtractionMode.average, + label: Text('Gewichteter Mittelwert'), + ), + ], + selected: {_mode}, + onSelectionChanged: (selection) { + _mode = selection.first; + _selected = _mode == ColorExtractionMode.dominant + ? _dominantColor + : _averageColor; + _hexCtrl.text = _colorToHexStatic(_selected); + setState(() {}); + }, + ), + Slider( + value: _circleR, + min: 0.08, + max: 0.49, + label: 'Radius', + onChanged: (v) { + setState(() => _setAndClampCircle(r: v)); + _scheduleLiveRecalculate(); + }, + ), + const SizedBox(height: 8), + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: _selected, + border: Border.all(color: Colors.black26), + borderRadius: BorderRadius.circular(8), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Erkannte Farbe: ${_colorToHexStatic(_selected)}', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + const SizedBox(height: 10), + TextField( + controller: _nameCtrl, + decoration: const InputDecoration(labelText: 'Name'), + ), + const SizedBox(height: 8), + TextField( + controller: _hexCtrl, + decoration: const InputDecoration(labelText: 'Hex (#RRGGBB)'), + onChanged: (value) { + final parsed = _parseHex(value); + if (parsed == null) return; + setState(() => _selected = parsed); + }, + ), + ], + ), + ), + bottomNavigationBar: SafeArea( + minimum: const EdgeInsets.fromLTRB(12, 8, 12, 12), + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.pop(context), + child: const Text('Abbrechen'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilledButton( + onPressed: () { + final name = _nameCtrl.text.trim(); + if (name.isEmpty) return; + final parsed = _parseHex(_hexCtrl.text); + Navigator.pop( + context, + _CapPhotoReviewResult( + name: name, + color: parsed ?? _selected, + mode: _mode, + previewBytes: _previewBytes, + ), + ); + }, + child: const Text('Zum Katalog hinzufügen'), + ), + ), + ], + ), + ), + ); + } +} + +String _colorToHexStatic(Color color) { + return '#${color.toARGB32().toRadixString(16).substring(2).toUpperCase()}'; +} + class _CapThumb extends StatelessWidget { final CapCatalogEntry entry; final bool large;