import 'dart:io'; import 'package:flutter/material.dart'; import '../constants/mpob_colors.dart'; import '../widgets/palm_bounding_box.dart'; import '../widgets/class_picker_sheet.dart'; import 'record_detail_screen.dart' show DetectionData; enum _HandlePosition { topLeft, topRight, bottomLeft, bottomRight } class _DraftBox { Rect normalizedBox; String? className; _DraftBox({required this.normalizedBox}); _DraftBox copy() => _DraftBox(normalizedBox: normalizedBox)..className = className; } /// Full-screen editor for adding bunches the AI missed. Existing detections /// are shown dimmed for spatial reference only — geometry edits here apply /// exclusively to newly-placed boxes; nothing about the AI's own boxes is /// changed. Nothing persists until the checkmark is tapped. class DetectionEditorScreen extends StatefulWidget { final String imagePath; final List existingDetections; const DetectionEditorScreen({super.key, required this.imagePath, required this.existingDetections}); @override State createState() => _DetectionEditorScreenState(); } class _DetectionEditorScreenState extends State { static const double _defaultBoxSize = 0.15; static const double _minBoxSize = 0.03; final List<_DraftBox> _draftBoxes = []; final List> _undoStack = []; final List> _redoStack = []; bool _showReference = true; int? _selectedIndex; int? _resizingIndex; bool get _canSave => _draftBoxes.isNotEmpty && _draftBoxes.every((b) => b.className != null); bool get _canUndo => _undoStack.isNotEmpty; bool get _canRedo => _redoStack.isNotEmpty; List<_DraftBox> _snapshot() => _draftBoxes.map((b) => b.copy()).toList(); /// Records the current state onto the undo stack before a mutation. /// Must be called before the mutating statement, inside the same /// [setState] closure, so the snapshot captures the pre-mutation state. void _pushHistory() { _undoStack.add(_snapshot()); _redoStack.clear(); } void _undo() { if (!_canUndo) return; setState(() { _redoStack.add(_snapshot()); final prev = _undoStack.removeLast(); _draftBoxes ..clear() ..addAll(prev); _selectedIndex = null; }); } void _redo() { if (!_canRedo) return; setState(() { _undoStack.add(_snapshot()); final next = _redoStack.removeLast(); _draftBoxes ..clear() ..addAll(next); _selectedIndex = null; }); } Future _pickClassFor(int index) async { final result = await showClassPickerSheet(context, currentClass: _draftBoxes[index].className); if (result != null && result.className != null && mounted) { setState(() { _pushHistory(); _draftBoxes[index].className = result.className; }); } } void _placeBoxAt(Offset normalizedCenter) { const half = _defaultBoxSize / 2; final left = (normalizedCenter.dx - half).clamp(0.0, 1.0 - _defaultBoxSize); final top = (normalizedCenter.dy - half).clamp(0.0, 1.0 - _defaultBoxSize); final rect = Rect.fromLTWH(left, top, _defaultBoxSize, _defaultBoxSize); final newIndex = _draftBoxes.length; setState(() { _pushHistory(); _draftBoxes.add(_DraftBox(normalizedBox: rect)); _selectedIndex = newIndex; }); _pickClassFor(newIndex); } /// Every tap on empty canvas places a new box — placing is the sole /// purpose of this screen, so there's no separate "Add" mode to enter. void _handleCanvasTap(Offset localPosition, BoxConstraints constraints) { final normalized = Offset( (localPosition.dx / constraints.maxWidth).clamp(0.0, 1.0), (localPosition.dy / constraints.maxHeight).clamp(0.0, 1.0), ); _placeBoxAt(normalized); } void _moveBox(int index, Offset normalizedDelta) { setState(() { final box = _draftBoxes[index]; final w = box.normalizedBox.width; final h = box.normalizedBox.height; final left = (box.normalizedBox.left + normalizedDelta.dx).clamp(0.0, 1.0 - w); final top = (box.normalizedBox.top + normalizedDelta.dy).clamp(0.0, 1.0 - h); box.normalizedBox = Rect.fromLTWH(left, top, w, h); }); } void _resizeBox(int index, _HandlePosition handle, Offset normalizedDelta) { setState(() { final box = _draftBoxes[index]; final rect = box.normalizedBox; double left = rect.left, top = rect.top, right = rect.right, bottom = rect.bottom; switch (handle) { case _HandlePosition.topLeft: left = (left + normalizedDelta.dx).clamp(0.0, right - _minBoxSize); top = (top + normalizedDelta.dy).clamp(0.0, bottom - _minBoxSize); break; case _HandlePosition.topRight: right = (right + normalizedDelta.dx).clamp(left + _minBoxSize, 1.0); top = (top + normalizedDelta.dy).clamp(0.0, bottom - _minBoxSize); break; case _HandlePosition.bottomLeft: left = (left + normalizedDelta.dx).clamp(0.0, right - _minBoxSize); bottom = (bottom + normalizedDelta.dy).clamp(top + _minBoxSize, 1.0); break; case _HandlePosition.bottomRight: right = (right + normalizedDelta.dx).clamp(left + _minBoxSize, 1.0); bottom = (bottom + normalizedDelta.dy).clamp(top + _minBoxSize, 1.0); break; } box.normalizedBox = Rect.fromLTRB(left, top, right, bottom); }); } void _deleteBox(int index) { setState(() { _pushHistory(); _draftBoxes.removeAt(index); _selectedIndex = null; }); } void _save() { final result = _draftBoxes .map((b) => DetectionData(className: b.className!, confidence: 1.0, normalizedBox: b.normalizedBox, isManual: true)) .toList(); Navigator.pop(context, result); } @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return Scaffold( appBar: AppBar( title: const Text("Add Missed Bunch"), actions: [ IconButton( tooltip: "Undo", onPressed: _canUndo ? _undo : null, icon: const Icon(Icons.undo), ), IconButton( tooltip: "Redo", onPressed: _canRedo ? _redo : null, icon: const Icon(Icons.redo), ), IconButton( tooltip: _showReference ? "Hide AI reference boxes" : "Show AI reference boxes", onPressed: () => setState(() => _showReference = !_showReference), icon: Icon(_showReference ? Icons.visibility_outlined : Icons.visibility_off_outlined), ), ], ), body: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), child: Text( "Tap anywhere a bunch was missed to place a box, then grade it and drag to fit. Existing detections are dimmed for reference.", style: TextStyle(fontSize: 13, color: colorScheme.onSurface.withOpacity(0.7)), ), ), Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: AspectRatio( aspectRatio: 1, child: Container( decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), ), child: widget.imagePath.isNotEmpty && File(widget.imagePath).existsSync() ? ClipRRect( borderRadius: BorderRadius.circular(12), child: LayoutBuilder( builder: (context, constraints) { return GestureDetector( behavior: HitTestBehavior.opaque, onTapUp: (details) => _handleCanvasTap(details.localPosition, constraints), child: Stack( children: [ Positioned.fill(child: Image.file(File(widget.imagePath), fit: BoxFit.contain)), if (_showReference) ...widget.existingDetections.map((d) => PalmBoundingBox( normalizedRect: d.normalizedBox, label: d.className, confidence: d.confidence, constraints: constraints, opacity: 0.35, )), ..._draftBoxes.asMap().entries.map((entry) { final i = entry.key; return _DraggableBox( box: entry.value, constraints: constraints, isSelected: _selectedIndex == i, isResizing: _resizingIndex == i, onSelect: () => setState(() => _selectedIndex = i), onMoveStart: () => setState(_pushHistory), onMove: (delta) => _moveBox(i, delta), onResizeStart: () => setState(() { _pushHistory(); _resizingIndex = i; }), onResize: (handle, delta) => _resizeBox(i, handle, delta), onResizeEnd: () => setState(() => _resizingIndex = null), ); }), ], ), ); }, ), ) : Center(child: Icon(Icons.image, size: 64, color: colorScheme.onSurface.withOpacity(0.4))), ), ), ), ), if (_selectedIndex != null) _buildSelectedBoxToolbar(colorScheme), _buildBottomBar(colorScheme), ], ), ); } Widget _buildSelectedBoxToolbar(ColorScheme colorScheme) { final box = _draftBoxes[_selectedIndex!]; return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: Row( children: [ Expanded( child: OutlinedButton.icon( onPressed: () => _pickClassFor(_selectedIndex!), icon: Icon( Icons.circle, size: 12, color: box.className == null ? colorScheme.onSurface.withOpacity(0.4) : getMPOBColor(box.className!), ), label: Text(box.className ?? "Pick grade"), ), ), const SizedBox(width: 8), IconButton( onPressed: () => _deleteBox(_selectedIndex!), icon: Icon(Icons.delete_outline, color: colorScheme.error), ), ], ), ); } Widget _buildBottomBar(ColorScheme colorScheme) { final count = _draftBoxes.length; return SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), child: Row( children: [ IconButton.filled( onPressed: () => Navigator.pop(context), style: IconButton.styleFrom( backgroundColor: colorScheme.surfaceContainerHighest, foregroundColor: colorScheme.onSurface, minimumSize: const Size(52, 52), ), icon: const Icon(Icons.close), ), Expanded( child: Text( count == 0 ? "Tap the photo to add a bunch" : "$count bunch${count == 1 ? '' : 'es'} added", textAlign: TextAlign.center, style: TextStyle(fontSize: 13, color: colorScheme.onSurface.withOpacity(0.7)), ), ), IconButton.filled( onPressed: _canSave ? _save : null, style: IconButton.styleFrom( backgroundColor: colorScheme.primary, foregroundColor: colorScheme.onPrimary, disabledBackgroundColor: colorScheme.onSurface.withOpacity(0.12), minimumSize: const Size(52, 52), ), icon: const Icon(Icons.check), ), ], ), ), ); } } class _DraggableBox extends StatelessWidget { final _DraftBox box; final BoxConstraints constraints; final bool isSelected; final bool isResizing; final VoidCallback onSelect; final VoidCallback onMoveStart; final ValueChanged onMove; final VoidCallback onResizeStart; final void Function(_HandlePosition handle, Offset delta) onResize; final VoidCallback onResizeEnd; const _DraggableBox({ required this.box, required this.constraints, required this.isSelected, required this.isResizing, required this.onSelect, required this.onMoveStart, required this.onMove, required this.onResizeStart, required this.onResize, required this.onResizeEnd, }); static const double _handleSize = 20; @override Widget build(BuildContext context) { final rect = box.normalizedBox; final color = box.className == null ? Colors.grey : getMPOBColor(box.className!); return Positioned( left: rect.left * constraints.maxWidth, top: rect.top * constraints.maxHeight, width: rect.width * constraints.maxWidth, height: rect.height * constraints.maxHeight, child: Stack( clipBehavior: Clip.none, children: [ GestureDetector( behavior: HitTestBehavior.opaque, onTap: onSelect, onPanStart: (_) => onMoveStart(), onPanUpdate: (details) => onMove( Offset(details.delta.dx / constraints.maxWidth, details.delta.dy / constraints.maxHeight), ), child: Container( decoration: BoxDecoration( border: Border.all(color: color, width: 3), borderRadius: BorderRadius.circular(4), ), child: box.className != null ? Align( alignment: Alignment.topLeft, child: Container( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), color: color, child: Text( box.className!, style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold), ), ), ) : null, ), ), if (isSelected) ..._buildHandles(), if (isResizing) Positioned( top: -32, left: 0, right: 0, child: Center( child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(12)), child: Text( "${(rect.width * 100).toStringAsFixed(0)}% × ${(rect.height * 100).toStringAsFixed(0)}%", style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w600), ), ), ), ), ], ), ); } List _buildHandles() { const half = _handleSize / 2; final specs = <_HandlePosition, Map>{ _HandlePosition.topLeft: {'left': -half, 'top': -half}, _HandlePosition.topRight: {'right': -half, 'top': -half}, _HandlePosition.bottomLeft: {'left': -half, 'bottom': -half}, _HandlePosition.bottomRight: {'right': -half, 'bottom': -half}, }; return specs.entries.map((entry) { final pos = entry.value; return Positioned( left: pos['left'], right: pos['right'], top: pos['top'], bottom: pos['bottom'], child: GestureDetector( behavior: HitTestBehavior.opaque, onPanStart: (_) => onResizeStart(), onPanUpdate: (details) => onResize( entry.key, Offset(details.delta.dx / constraints.maxWidth, details.delta.dy / constraints.maxHeight), ), onPanEnd: (_) => onResizeEnd(), onPanCancel: onResizeEnd, child: Container( width: _handleSize, height: _handleSize, decoration: BoxDecoration( color: Colors.white, shape: BoxShape.circle, border: Border.all(color: Colors.black45, width: 2), ), ), ), ); }).toList(); } }