Fix camera review flow with dedicated route and lost-data recovery
This commit is contained in:
693
lib/main.dart
693
lib/main.dart
@@ -40,10 +40,10 @@ class MosaicHomePage extends StatefulWidget {
|
|||||||
State<MosaicHomePage> createState() => _MosaicHomePageState();
|
State<MosaicHomePage> createState() => _MosaicHomePageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MosaicHomePageState extends State<MosaicHomePage> {
|
class _MosaicHomePageState extends State<MosaicHomePage>
|
||||||
|
with WidgetsBindingObserver {
|
||||||
final ImagePicker _picker = ImagePicker();
|
final ImagePicker _picker = ImagePicker();
|
||||||
final TextEditingController _photoCapNameCtrl = TextEditingController();
|
// Capture review state lives in a dedicated full-screen route.
|
||||||
final TextEditingController _photoCapHexCtrl = TextEditingController();
|
|
||||||
final TextEditingController _gridWidthCtrl =
|
final TextEditingController _gridWidthCtrl =
|
||||||
TextEditingController(text: '40');
|
TextEditingController(text: '40');
|
||||||
final TextEditingController _gridHeightCtrl =
|
final TextEditingController _gridHeightCtrl =
|
||||||
@@ -59,6 +59,8 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
|||||||
bool _isCatalogLoaded = false;
|
bool _isCatalogLoaded = false;
|
||||||
CatalogViewMode _catalogViewMode = CatalogViewMode.grid;
|
CatalogViewMode _catalogViewMode = CatalogViewMode.grid;
|
||||||
ColorExtractionMode _colorExtractionMode = ColorExtractionMode.dominant;
|
ColorExtractionMode _colorExtractionMode = ColorExtractionMode.dominant;
|
||||||
|
bool _isCaptureFlowInProgress = false;
|
||||||
|
bool _isRecoveringCapture = false;
|
||||||
|
|
||||||
double _fidelityStructure = 0.5;
|
double _fidelityStructure = 0.5;
|
||||||
double _ditheringStrength = 0.35;
|
double _ditheringStrength = 0.35;
|
||||||
@@ -71,20 +73,31 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_gridWidthCtrl.addListener(_scheduleRegenerate);
|
_gridWidthCtrl.addListener(_scheduleRegenerate);
|
||||||
_gridHeightCtrl.addListener(_scheduleRegenerate);
|
_gridHeightCtrl.addListener(_scheduleRegenerate);
|
||||||
_capSizeCtrl.addListener(_scheduleRegenerate);
|
_capSizeCtrl.addListener(_scheduleRegenerate);
|
||||||
_loadCatalog();
|
_loadCatalog();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_recoverCaptureOnResumeOrStart();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
_recoverCaptureOnResumeOrStart();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_debounceTimer?.cancel();
|
_debounceTimer?.cancel();
|
||||||
_gridWidthCtrl.dispose();
|
_gridWidthCtrl.dispose();
|
||||||
_gridHeightCtrl.dispose();
|
_gridHeightCtrl.dispose();
|
||||||
_capSizeCtrl.dispose();
|
_capSizeCtrl.dispose();
|
||||||
_photoCapNameCtrl.dispose();
|
// No extra capture controllers to dispose.
|
||||||
_photoCapHexCtrl.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +160,59 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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 {
|
Future<void> _captureCapPhoto() async {
|
||||||
|
if (_isCaptureFlowInProgress) return;
|
||||||
|
|
||||||
|
setState(() => _isCaptureFlowInProgress = true);
|
||||||
|
try {
|
||||||
final XFile? captured = await _picker.pickImage(
|
final XFile? captured = await _picker.pickImage(
|
||||||
source: ImageSource.camera,
|
source: ImageSource.camera,
|
||||||
preferredCameraDevice: CameraDevice.rear,
|
preferredCameraDevice: CameraDevice.rear,
|
||||||
@@ -156,6 +221,7 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
|||||||
);
|
);
|
||||||
if (captured == null) {
|
if (captured == null) {
|
||||||
debugPrint('[cap-photo] Capture cancelled by user.');
|
debugPrint('[cap-photo] Capture cancelled by user.');
|
||||||
|
await _setPendingCapture(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,11 +232,24 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
|||||||
debugPrint('[cap-photo] Failed to read captured bytes: $e\n$st');
|
debugPrint('[cap-photo] Failed to read captured bytes: $e\n$st');
|
||||||
final fallbackImage = img.Image(width: 1, height: 1)
|
final fallbackImage = img.Image(width: 1, height: 1)
|
||||||
..setPixelRgb(0, 0, 255, 152, 0);
|
..setPixelRgb(0, 0, 255, 152, 0);
|
||||||
bytes = Uint8List.fromList(
|
bytes = Uint8List.fromList(img.encodePng(fallbackImage, level: 1));
|
||||||
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;
|
Map<String, dynamic> detected;
|
||||||
String? detectionWarning;
|
String? detectionWarning;
|
||||||
try {
|
try {
|
||||||
@@ -198,263 +277,41 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
|||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
Color dominantColor = Color(detected['dominantColor'] as int);
|
final reviewResult =
|
||||||
Color averageColor = Color(detected['averageColor'] as int);
|
await Navigator.of(context).push<_CapPhotoReviewResult>(
|
||||||
Uint8List previewBytes = detected['previewPng'] as Uint8List;
|
MaterialPageRoute(
|
||||||
final bool usedFallback = detected['usedFallback'] as bool? ?? false;
|
fullscreenDialog: true,
|
||||||
final imageW = (detected['imageW'] as num).toDouble();
|
builder: (_) => _CapPhotoReviewPage(
|
||||||
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,
|
imageBytes: bytes,
|
||||||
imageWidth: imageW,
|
detected: detected,
|
||||||
imageHeight: imageH,
|
initialMode: _colorExtractionMode,
|
||||||
circleX: circleX,
|
initialName: 'Fotografierter Deckel',
|
||||||
circleY: circleY,
|
initialWarning: detectionWarning,
|
||||||
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>(
|
if (reviewResult == null) {
|
||||||
segments: const [
|
await _setPendingCapture(null);
|
||||||
ButtonSegment(
|
return;
|
||||||
value: ColorExtractionMode.dominant,
|
}
|
||||||
label: Text('Dominant (robust)'),
|
|
||||||
),
|
_colorExtractionMode = reviewResult.mode;
|
||||||
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(
|
final entry = CapCatalogEntry.newEntry(
|
||||||
name: name, color: parsed ?? selected);
|
name: reviewResult.name,
|
||||||
_colorExtractionMode = mode;
|
color: reviewResult.color,
|
||||||
entry.imagePath =
|
);
|
||||||
await _saveThumbnail(previewBytes, entry.id);
|
entry.imagePath = await _saveThumbnail(reviewResult.previewBytes, entry.id);
|
||||||
_catalog.add(entry);
|
_catalog.add(entry);
|
||||||
await _persistCatalog();
|
await _persistCatalog();
|
||||||
|
await _setPendingCapture(null);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {});
|
setState(() {});
|
||||||
if (!ctx.mounted) return;
|
|
||||||
Navigator.pop(ctx);
|
|
||||||
_scheduleRegenerate();
|
_scheduleRegenerate();
|
||||||
},
|
|
||||||
child: const Text('Zum Katalog hinzufügen'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
liveDebounce?.cancel();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_NormalizedCircle _clampNormalizedCircleToImage({
|
// Circle clamping helper is used by the dedicated review route.
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _scheduleRegenerate() {
|
void _scheduleRegenerate() {
|
||||||
if (_sourceImageBytes == null || _result == null) return;
|
if (_sourceImageBytes == null || _result == null) return;
|
||||||
@@ -888,9 +745,19 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
|||||||
tooltip: 'Rasteransicht',
|
tooltip: 'Rasteransicht',
|
||||||
),
|
),
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: _captureCapPhoto,
|
onPressed: _isCaptureFlowInProgress
|
||||||
icon: const Icon(Icons.photo_camera_outlined),
|
? null
|
||||||
label: const Text('Foto')),
|
: _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),
|
const SizedBox(width: 8),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: _addCapDialog,
|
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 {
|
class _CapThumb extends StatelessWidget {
|
||||||
final CapCatalogEntry entry;
|
final CapCatalogEntry entry;
|
||||||
final bool large;
|
final bool large;
|
||||||
|
|||||||
Reference in New Issue
Block a user