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();
|
||||
}
|
||||
|
||||
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,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 {
|
||||
if (_isCaptureFlowInProgress) return;
|
||||
|
||||
setState(() => _isCaptureFlowInProgress = true);
|
||||
try {
|
||||
final XFile? captured = await _picker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
preferredCameraDevice: CameraDevice.rear,
|
||||
@@ -156,6 +221,7 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
||||
);
|
||||
if (captured == null) {
|
||||
debugPrint('[cap-photo] Capture cancelled by user.');
|
||||
await _setPendingCapture(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -166,11 +232,24 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
||||
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),
|
||||
);
|
||||
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(
|
||||
final reviewResult =
|
||||
await Navigator.of(context).push<_CapPhotoReviewResult>(
|
||||
MaterialPageRoute(
|
||||
fullscreenDialog: true,
|
||||
builder: (_) => _CapPhotoReviewPage(
|
||||
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,
|
||||
detected: detected,
|
||||
initialMode: _colorExtractionMode,
|
||||
initialName: 'Fotografierter Deckel',
|
||||
initialWarning: detectionWarning,
|
||||
),
|
||||
),
|
||||
],
|
||||
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);
|
||||
);
|
||||
|
||||
if (reviewResult == null) {
|
||||
await _setPendingCapture(null);
|
||||
return;
|
||||
}
|
||||
|
||||
_colorExtractionMode = reviewResult.mode;
|
||||
final entry = CapCatalogEntry.newEntry(
|
||||
name: name, color: parsed ?? selected);
|
||||
_colorExtractionMode = mode;
|
||||
entry.imagePath =
|
||||
await _saveThumbnail(previewBytes, entry.id);
|
||||
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(() {});
|
||||
if (!ctx.mounted) return;
|
||||
Navigator.pop(ctx);
|
||||
_scheduleRegenerate();
|
||||
},
|
||||
child: const Text('Zum Katalog hinzufügen'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
liveDebounce?.cancel();
|
||||
}
|
||||
|
||||
_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;
|
||||
|
||||
Reference in New Issue
Block a user