Fix camera review flow with dedicated route and lost-data recovery

This commit is contained in:
gary
2026-02-21 23:17:48 +01:00
parent 94eec93e13
commit 4cc7de158e

View File

@@ -40,10 +40,10 @@ class MosaicHomePage extends StatefulWidget {
State<MosaicHomePage> createState() => _MosaicHomePageState();
}
class _MosaicHomePageState extends State<MosaicHomePage> {
class _MosaicHomePageState extends State<MosaicHomePage>
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<MosaicHomePage> {
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<MosaicHomePage> {
@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<MosaicHomePage> {
});
}
Future<void> _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<File> _pendingCaptureFile() async {
final docs = await getApplicationDocumentsDirectory();
return File('${docs.path}/pending_cap_capture.jpg');
}
Future<void> _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<Uint8List?> _readPendingCapture() async {
final file = await _pendingCaptureFile();
if (!await file.exists()) return null;
return file.readAsBytes();
}
Future<void> _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<void> _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<void> _openCaptureReviewFlow(
Uint8List bytes, {
bool recoveredFromPending = false,
}) async {
Map<String, dynamic> detected;
String? detectionWarning;
try {
@@ -198,263 +277,41 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
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<void>(
context: context,
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setDialogState) {
Future<void> recalculate({bool updateDialog = true}) async {
final localToken = ++recalcToken;
if (updateDialog) {
setDialogState(() {});
}
try {
final adjusted = await compute(
_extractCapFromAdjustedCircleIsolate,
<String, dynamic>{
'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<ColorExtractionMode>(
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<MosaicHomePage> {
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<MosaicHomePage> {
}
}
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<String, dynamic> 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<void> _recalculate({bool updateUiBefore = true}) async {
final localToken = ++_recalcToken;
if (updateUiBefore && mounted) setState(() {});
try {
final adjusted = await compute(
_extractCapFromAdjustedCircleIsolate,
<String, dynamic>{
'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<ColorExtractionMode>(
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;