From e392b99bef54348ad59c6f66b7fedf5bc60798c1 Mon Sep 17 00:00:00 2001 From: gary Date: Sat, 21 Feb 2026 22:46:35 +0100 Subject: [PATCH] Add interactive manual circle drag/pinch with live color preview --- lib/main.dart | 293 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 242 insertions(+), 51 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 0b062bd..ccc1900 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -177,12 +177,30 @@ class _MosaicHomePageState extends State { _photoCapNameCtrl.text = 'Fotografierter Deckel'; _photoCapHexCtrl.text = _colorToHex(selected); - showDialog( + 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() async { + Future recalculate({bool updateDialog = true}) async { + final localToken = ++recalcToken; + if (updateDialog) { + setDialogState(() {}); + } final adjusted = await compute( _extractCapFromAdjustedCircleIsolate, { @@ -192,6 +210,7 @@ class _MosaicHomePageState extends State { 'circleR': circleR, }, ); + if (localToken != recalcToken) return; dominantColor = Color(adjusted['dominantColor'] as int); averageColor = Color(adjusted['averageColor'] as int); previewBytes = adjusted['previewPng'] as Uint8List; @@ -199,7 +218,32 @@ class _MosaicHomePageState extends State { ? dominantColor : averageColor; _photoCapHexCtrl.text = _colorToHex(selected); - setDialogState(() {}); + 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( @@ -209,16 +253,25 @@ class _MosaicHomePageState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.memory(previewBytes, - width: 220, height: 220, fit: BoxFit.cover), + _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. Bei Bedarf Kreis manuell korrigieren.' - : 'Kreis automatisch erkannt. Optional unten feinjustieren.', + ? '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, ), const SizedBox(height: 8), @@ -243,42 +296,15 @@ class _MosaicHomePageState extends State { setDialogState(() {}); }, ), - const SizedBox(height: 8), - Text( - 'Robuste Analyse: Schatten/Outlier werden reduziert, nur geeignete Pixel im Kreis werden gewichtet.', - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 8), - const Text('Manuelle Kreis-Korrektur', - style: TextStyle(fontWeight: FontWeight.w600)), Slider( - value: circleX.clamp(0.15, 0.85), - min: 0.15, - max: 0.85, - label: 'X', - onChanged: (v) => setDialogState(() => circleX = v), - ), - Slider( - value: circleY.clamp(0.15, 0.85), - min: 0.15, - max: 0.85, - label: 'Y', - onChanged: (v) => setDialogState(() => circleY = v), - ), - Slider( - value: circleR.clamp(0.12, 0.48), - min: 0.12, - max: 0.48, + value: circleR, + min: 0.08, + max: 0.49, label: 'Radius', - onChanged: (v) => setDialogState(() => circleR = v), - ), - Align( - alignment: Alignment.centerRight, - child: OutlinedButton.icon( - onPressed: recalculate, - icon: const Icon(Icons.tune), - label: const Text('Kreis anwenden & neu berechnen'), - ), + onChanged: (v) { + setDialogState(() => setAndClampCircle(r: v)); + scheduleLiveRecalculate(); + }, ), const SizedBox(height: 8), Row( @@ -350,6 +376,24 @@ class _MosaicHomePageState extends State { ); }, ); + + 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, + ); } void _scheduleRegenerate() { @@ -996,6 +1040,130 @@ class _SliderRow extends StatelessWidget { } } +class _NormalizedCircle { + final double x; + final double y; + final double r; + + const _NormalizedCircle({required this.x, required this.y, required this.r}); +} + +class _CircleAdjustOverlay extends StatefulWidget { + final Uint8List imageBytes; + final double imageWidth; + final double imageHeight; + final double circleX; + final double circleY; + final double circleR; + final void Function(double x, double y, double r) onCircleChanged; + + const _CircleAdjustOverlay({ + required this.imageBytes, + required this.imageWidth, + required this.imageHeight, + required this.circleX, + required this.circleY, + required this.circleR, + required this.onCircleChanged, + }); + + @override + State<_CircleAdjustOverlay> createState() => _CircleAdjustOverlayState(); +} + +class _CircleAdjustOverlayState extends State<_CircleAdjustOverlay> { + double? _baseRadius; + + @override + Widget build(BuildContext context) { + return LayoutBuilder(builder: (context, constraints) { + final maxW = math.min(320.0, constraints.maxWidth); + final shownH = maxW * widget.imageHeight / widget.imageWidth; + + return Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + width: maxW, + height: shownH, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onScaleStart: (_) => _baseRadius = widget.circleR, + onScaleUpdate: (details) { + final local = details.localFocalPoint; + final x = (local.dx / maxW).clamp(0.0, 1.0); + final y = (local.dy / shownH).clamp(0.0, 1.0); + final scale = details.scale; + final r = ((_baseRadius ?? widget.circleR) * scale) + .clamp(0.06, 0.49); + widget.onCircleChanged(x, y, r); + }, + onScaleEnd: (_) => _baseRadius = null, + child: Stack( + fit: StackFit.expand, + children: [ + Image.memory(widget.imageBytes, fit: BoxFit.cover), + CustomPaint( + painter: _CircleOverlayPainter( + x: widget.circleX, + y: widget.circleY, + r: widget.circleR, + ), + ), + ], + ), + ), + ), + ), + ); + }); + } +} + +class _CircleOverlayPainter extends CustomPainter { + final double x; + final double y; + final double r; + + const _CircleOverlayPainter({required this.x, required this.y, required this.r}); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(x * size.width, y * size.height); + final radius = r * math.min(size.width, size.height); + + final overlayPaint = Paint()..color = Colors.black.withValues(alpha: 0.35); + final overlayPath = Path()..addRect(Offset.zero & size); + overlayPath.addOval(Rect.fromCircle(center: center, radius: radius)); + overlayPath.fillType = PathFillType.evenOdd; + canvas.drawPath(overlayPath, overlayPaint); + + final ringPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2.5 + ..color = Colors.white; + canvas.drawCircle(center, radius, ringPaint); + + final ringPaint2 = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.2 + ..color = const Color(0xFF2196F3); + canvas.drawCircle(center, radius - 3, ringPaint2); + + final cross = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1 + ..color = Colors.white70; + canvas.drawLine(Offset(center.dx - 10, center.dy), Offset(center.dx + 10, center.dy), cross); + canvas.drawLine(Offset(center.dx, center.dy - 10), Offset(center.dx, center.dy + 10), cross); + } + + @override + bool shouldRepaint(covariant _CircleOverlayPainter oldDelegate) { + return oldDelegate.x != x || oldDelegate.y != y || oldDelegate.r != r; + } +} + class CapCatalogEntry { final String id; String name; @@ -1369,15 +1537,20 @@ Map _extractCapFromAdjustedCircleIsolate( }; } - final cx = ((request['circleX'] as num).toDouble() * decoded.width) - .clamp(0.0, decoded.width.toDouble()); - final cy = ((request['circleY'] as num).toDouble() * decoded.height) - .clamp(0.0, decoded.height.toDouble()); - final r = ((request['circleR'] as num).toDouble() * - math.min(decoded.width, decoded.height)) - .clamp(8.0, math.min(decoded.width, decoded.height) * 0.49); + final minSide = math.min(decoded.width, decoded.height).toDouble(); + final normalized = _clampCircleNormalized( + imageW: decoded.width.toDouble(), + imageH: decoded.height.toDouble(), + circleX: (request['circleX'] as num).toDouble(), + circleY: (request['circleY'] as num).toDouble(), + circleR: (request['circleR'] as num).toDouble(), + ); - final circle = _DetectedCircle(cx: cx, cy: cy, r: r); + final circle = _DetectedCircle( + cx: normalized.x * decoded.width, + cy: normalized.y * decoded.height, + r: normalized.r * minSide, + ); final stats = _sampleCapColors(decoded, circle); const int analysisMaxSize = 480; @@ -1408,6 +1581,24 @@ Map _extractCapFromAdjustedCircleIsolate( }; } +_NormalizedCircle _clampCircleNormalized({ + required double imageW, + required double imageH, + required double circleX, + required double circleY, + required double circleR, +}) { + final minSide = math.min(imageW, imageH); + final r = circleR.clamp(8 / minSide, 0.49); + final minX = r * (minSide / imageW); + final minY = r * (minSide / imageH); + return _NormalizedCircle( + x: circleX.clamp(minX, 1 - minX), + y: circleY.clamp(minY, 1 - minY), + r: r, + ); +} + img.Image _buildCirclePreview(img.Image analysis, _DetectedCircle detected) { final preview = img.copyResize(analysis, width: math.min(analysis.width, 320),