|
|
@@ -0,0 +1,462 @@
|
|
|
+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<DetectionData> existingDetections;
|
|
|
+
|
|
|
+ const DetectionEditorScreen({super.key, required this.imagePath, required this.existingDetections});
|
|
|
+
|
|
|
+ @override
|
|
|
+ State<DetectionEditorScreen> createState() => _DetectionEditorScreenState();
|
|
|
+}
|
|
|
+
|
|
|
+class _DetectionEditorScreenState extends State<DetectionEditorScreen> {
|
|
|
+ static const double _defaultBoxSize = 0.15;
|
|
|
+ static const double _minBoxSize = 0.03;
|
|
|
+
|
|
|
+ final List<_DraftBox> _draftBoxes = [];
|
|
|
+ final List<List<_DraftBox>> _undoStack = [];
|
|
|
+ final List<List<_DraftBox>> _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<void> _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<Offset> 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<Widget> _buildHandles() {
|
|
|
+ const half = _handleSize / 2;
|
|
|
+ final specs = <_HandlePosition, Map<String, double>>{
|
|
|
+ _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();
|
|
|
+ }
|
|
|
+}
|