detection_editor_screen.dart 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. import 'dart:io';
  2. import 'package:flutter/material.dart';
  3. import '../constants/mpob_colors.dart';
  4. import '../widgets/palm_bounding_box.dart';
  5. import '../widgets/class_picker_sheet.dart';
  6. import 'record_detail_screen.dart' show DetectionData;
  7. enum _HandlePosition { topLeft, topRight, bottomLeft, bottomRight }
  8. class _DraftBox {
  9. Rect normalizedBox;
  10. String? className;
  11. _DraftBox({required this.normalizedBox});
  12. _DraftBox copy() => _DraftBox(normalizedBox: normalizedBox)..className = className;
  13. }
  14. /// Full-screen editor for adding bunches the AI missed. Existing detections
  15. /// are shown dimmed for spatial reference only — geometry edits here apply
  16. /// exclusively to newly-placed boxes; nothing about the AI's own boxes is
  17. /// changed. Nothing persists until the checkmark is tapped.
  18. class DetectionEditorScreen extends StatefulWidget {
  19. final String imagePath;
  20. final List<DetectionData> existingDetections;
  21. const DetectionEditorScreen({super.key, required this.imagePath, required this.existingDetections});
  22. @override
  23. State<DetectionEditorScreen> createState() => _DetectionEditorScreenState();
  24. }
  25. class _DetectionEditorScreenState extends State<DetectionEditorScreen> {
  26. static const double _defaultBoxSize = 0.15;
  27. static const double _minBoxSize = 0.03;
  28. final List<_DraftBox> _draftBoxes = [];
  29. final List<List<_DraftBox>> _undoStack = [];
  30. final List<List<_DraftBox>> _redoStack = [];
  31. bool _showReference = true;
  32. int? _selectedIndex;
  33. int? _resizingIndex;
  34. bool get _canSave => _draftBoxes.isNotEmpty && _draftBoxes.every((b) => b.className != null);
  35. bool get _canUndo => _undoStack.isNotEmpty;
  36. bool get _canRedo => _redoStack.isNotEmpty;
  37. List<_DraftBox> _snapshot() => _draftBoxes.map((b) => b.copy()).toList();
  38. /// Records the current state onto the undo stack before a mutation.
  39. /// Must be called before the mutating statement, inside the same
  40. /// [setState] closure, so the snapshot captures the pre-mutation state.
  41. void _pushHistory() {
  42. _undoStack.add(_snapshot());
  43. _redoStack.clear();
  44. }
  45. void _undo() {
  46. if (!_canUndo) return;
  47. setState(() {
  48. _redoStack.add(_snapshot());
  49. final prev = _undoStack.removeLast();
  50. _draftBoxes
  51. ..clear()
  52. ..addAll(prev);
  53. _selectedIndex = null;
  54. });
  55. }
  56. void _redo() {
  57. if (!_canRedo) return;
  58. setState(() {
  59. _undoStack.add(_snapshot());
  60. final next = _redoStack.removeLast();
  61. _draftBoxes
  62. ..clear()
  63. ..addAll(next);
  64. _selectedIndex = null;
  65. });
  66. }
  67. Future<void> _pickClassFor(int index) async {
  68. final result = await showClassPickerSheet(context, currentClass: _draftBoxes[index].className);
  69. if (result != null && result.className != null && mounted) {
  70. setState(() {
  71. _pushHistory();
  72. _draftBoxes[index].className = result.className;
  73. });
  74. }
  75. }
  76. void _placeBoxAt(Offset normalizedCenter) {
  77. const half = _defaultBoxSize / 2;
  78. final left = (normalizedCenter.dx - half).clamp(0.0, 1.0 - _defaultBoxSize);
  79. final top = (normalizedCenter.dy - half).clamp(0.0, 1.0 - _defaultBoxSize);
  80. final rect = Rect.fromLTWH(left, top, _defaultBoxSize, _defaultBoxSize);
  81. final newIndex = _draftBoxes.length;
  82. setState(() {
  83. _pushHistory();
  84. _draftBoxes.add(_DraftBox(normalizedBox: rect));
  85. _selectedIndex = newIndex;
  86. });
  87. _pickClassFor(newIndex);
  88. }
  89. /// Every tap on empty canvas places a new box — placing is the sole
  90. /// purpose of this screen, so there's no separate "Add" mode to enter.
  91. void _handleCanvasTap(Offset localPosition, BoxConstraints constraints) {
  92. final normalized = Offset(
  93. (localPosition.dx / constraints.maxWidth).clamp(0.0, 1.0),
  94. (localPosition.dy / constraints.maxHeight).clamp(0.0, 1.0),
  95. );
  96. _placeBoxAt(normalized);
  97. }
  98. void _moveBox(int index, Offset normalizedDelta) {
  99. setState(() {
  100. final box = _draftBoxes[index];
  101. final w = box.normalizedBox.width;
  102. final h = box.normalizedBox.height;
  103. final left = (box.normalizedBox.left + normalizedDelta.dx).clamp(0.0, 1.0 - w);
  104. final top = (box.normalizedBox.top + normalizedDelta.dy).clamp(0.0, 1.0 - h);
  105. box.normalizedBox = Rect.fromLTWH(left, top, w, h);
  106. });
  107. }
  108. void _resizeBox(int index, _HandlePosition handle, Offset normalizedDelta) {
  109. setState(() {
  110. final box = _draftBoxes[index];
  111. final rect = box.normalizedBox;
  112. double left = rect.left, top = rect.top, right = rect.right, bottom = rect.bottom;
  113. switch (handle) {
  114. case _HandlePosition.topLeft:
  115. left = (left + normalizedDelta.dx).clamp(0.0, right - _minBoxSize);
  116. top = (top + normalizedDelta.dy).clamp(0.0, bottom - _minBoxSize);
  117. break;
  118. case _HandlePosition.topRight:
  119. right = (right + normalizedDelta.dx).clamp(left + _minBoxSize, 1.0);
  120. top = (top + normalizedDelta.dy).clamp(0.0, bottom - _minBoxSize);
  121. break;
  122. case _HandlePosition.bottomLeft:
  123. left = (left + normalizedDelta.dx).clamp(0.0, right - _minBoxSize);
  124. bottom = (bottom + normalizedDelta.dy).clamp(top + _minBoxSize, 1.0);
  125. break;
  126. case _HandlePosition.bottomRight:
  127. right = (right + normalizedDelta.dx).clamp(left + _minBoxSize, 1.0);
  128. bottom = (bottom + normalizedDelta.dy).clamp(top + _minBoxSize, 1.0);
  129. break;
  130. }
  131. box.normalizedBox = Rect.fromLTRB(left, top, right, bottom);
  132. });
  133. }
  134. void _deleteBox(int index) {
  135. setState(() {
  136. _pushHistory();
  137. _draftBoxes.removeAt(index);
  138. _selectedIndex = null;
  139. });
  140. }
  141. void _save() {
  142. final result = _draftBoxes
  143. .map((b) => DetectionData(className: b.className!, confidence: 1.0, normalizedBox: b.normalizedBox, isManual: true))
  144. .toList();
  145. Navigator.pop(context, result);
  146. }
  147. @override
  148. Widget build(BuildContext context) {
  149. final colorScheme = Theme.of(context).colorScheme;
  150. return Scaffold(
  151. appBar: AppBar(
  152. title: const Text("Add Missed Bunch"),
  153. actions: [
  154. IconButton(
  155. tooltip: "Undo",
  156. onPressed: _canUndo ? _undo : null,
  157. icon: const Icon(Icons.undo),
  158. ),
  159. IconButton(
  160. tooltip: "Redo",
  161. onPressed: _canRedo ? _redo : null,
  162. icon: const Icon(Icons.redo),
  163. ),
  164. IconButton(
  165. tooltip: _showReference ? "Hide AI reference boxes" : "Show AI reference boxes",
  166. onPressed: () => setState(() => _showReference = !_showReference),
  167. icon: Icon(_showReference ? Icons.visibility_outlined : Icons.visibility_off_outlined),
  168. ),
  169. ],
  170. ),
  171. body: Column(
  172. children: [
  173. Padding(
  174. padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
  175. child: Text(
  176. "Tap anywhere a bunch was missed to place a box, then grade it and drag to fit. Existing detections are dimmed for reference.",
  177. style: TextStyle(fontSize: 13, color: colorScheme.onSurface.withOpacity(0.7)),
  178. ),
  179. ),
  180. Expanded(
  181. child: Padding(
  182. padding: const EdgeInsets.symmetric(horizontal: 16),
  183. child: AspectRatio(
  184. aspectRatio: 1,
  185. child: Container(
  186. decoration: BoxDecoration(
  187. color: colorScheme.surfaceContainerHighest,
  188. borderRadius: BorderRadius.circular(12),
  189. ),
  190. child: widget.imagePath.isNotEmpty && File(widget.imagePath).existsSync()
  191. ? ClipRRect(
  192. borderRadius: BorderRadius.circular(12),
  193. child: LayoutBuilder(
  194. builder: (context, constraints) {
  195. return GestureDetector(
  196. behavior: HitTestBehavior.opaque,
  197. onTapUp: (details) => _handleCanvasTap(details.localPosition, constraints),
  198. child: Stack(
  199. children: [
  200. Positioned.fill(child: Image.file(File(widget.imagePath), fit: BoxFit.contain)),
  201. if (_showReference)
  202. ...widget.existingDetections.map((d) => PalmBoundingBox(
  203. normalizedRect: d.normalizedBox,
  204. label: d.className,
  205. confidence: d.confidence,
  206. constraints: constraints,
  207. opacity: 0.35,
  208. )),
  209. ..._draftBoxes.asMap().entries.map((entry) {
  210. final i = entry.key;
  211. return _DraggableBox(
  212. box: entry.value,
  213. constraints: constraints,
  214. isSelected: _selectedIndex == i,
  215. isResizing: _resizingIndex == i,
  216. onSelect: () => setState(() => _selectedIndex = i),
  217. onMoveStart: () => setState(_pushHistory),
  218. onMove: (delta) => _moveBox(i, delta),
  219. onResizeStart: () => setState(() {
  220. _pushHistory();
  221. _resizingIndex = i;
  222. }),
  223. onResize: (handle, delta) => _resizeBox(i, handle, delta),
  224. onResizeEnd: () => setState(() => _resizingIndex = null),
  225. );
  226. }),
  227. ],
  228. ),
  229. );
  230. },
  231. ),
  232. )
  233. : Center(child: Icon(Icons.image, size: 64, color: colorScheme.onSurface.withOpacity(0.4))),
  234. ),
  235. ),
  236. ),
  237. ),
  238. if (_selectedIndex != null) _buildSelectedBoxToolbar(colorScheme),
  239. _buildBottomBar(colorScheme),
  240. ],
  241. ),
  242. );
  243. }
  244. Widget _buildSelectedBoxToolbar(ColorScheme colorScheme) {
  245. final box = _draftBoxes[_selectedIndex!];
  246. return Padding(
  247. padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
  248. child: Row(
  249. children: [
  250. Expanded(
  251. child: OutlinedButton.icon(
  252. onPressed: () => _pickClassFor(_selectedIndex!),
  253. icon: Icon(
  254. Icons.circle,
  255. size: 12,
  256. color: box.className == null ? colorScheme.onSurface.withOpacity(0.4) : getMPOBColor(box.className!),
  257. ),
  258. label: Text(box.className ?? "Pick grade"),
  259. ),
  260. ),
  261. const SizedBox(width: 8),
  262. IconButton(
  263. onPressed: () => _deleteBox(_selectedIndex!),
  264. icon: Icon(Icons.delete_outline, color: colorScheme.error),
  265. ),
  266. ],
  267. ),
  268. );
  269. }
  270. Widget _buildBottomBar(ColorScheme colorScheme) {
  271. final count = _draftBoxes.length;
  272. return SafeArea(
  273. top: false,
  274. child: Padding(
  275. padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
  276. child: Row(
  277. children: [
  278. IconButton.filled(
  279. onPressed: () => Navigator.pop(context),
  280. style: IconButton.styleFrom(
  281. backgroundColor: colorScheme.surfaceContainerHighest,
  282. foregroundColor: colorScheme.onSurface,
  283. minimumSize: const Size(52, 52),
  284. ),
  285. icon: const Icon(Icons.close),
  286. ),
  287. Expanded(
  288. child: Text(
  289. count == 0 ? "Tap the photo to add a bunch" : "$count bunch${count == 1 ? '' : 'es'} added",
  290. textAlign: TextAlign.center,
  291. style: TextStyle(fontSize: 13, color: colorScheme.onSurface.withOpacity(0.7)),
  292. ),
  293. ),
  294. IconButton.filled(
  295. onPressed: _canSave ? _save : null,
  296. style: IconButton.styleFrom(
  297. backgroundColor: colorScheme.primary,
  298. foregroundColor: colorScheme.onPrimary,
  299. disabledBackgroundColor: colorScheme.onSurface.withOpacity(0.12),
  300. minimumSize: const Size(52, 52),
  301. ),
  302. icon: const Icon(Icons.check),
  303. ),
  304. ],
  305. ),
  306. ),
  307. );
  308. }
  309. }
  310. class _DraggableBox extends StatelessWidget {
  311. final _DraftBox box;
  312. final BoxConstraints constraints;
  313. final bool isSelected;
  314. final bool isResizing;
  315. final VoidCallback onSelect;
  316. final VoidCallback onMoveStart;
  317. final ValueChanged<Offset> onMove;
  318. final VoidCallback onResizeStart;
  319. final void Function(_HandlePosition handle, Offset delta) onResize;
  320. final VoidCallback onResizeEnd;
  321. const _DraggableBox({
  322. required this.box,
  323. required this.constraints,
  324. required this.isSelected,
  325. required this.isResizing,
  326. required this.onSelect,
  327. required this.onMoveStart,
  328. required this.onMove,
  329. required this.onResizeStart,
  330. required this.onResize,
  331. required this.onResizeEnd,
  332. });
  333. static const double _handleSize = 20;
  334. @override
  335. Widget build(BuildContext context) {
  336. final rect = box.normalizedBox;
  337. final color = box.className == null ? Colors.grey : getMPOBColor(box.className!);
  338. return Positioned(
  339. left: rect.left * constraints.maxWidth,
  340. top: rect.top * constraints.maxHeight,
  341. width: rect.width * constraints.maxWidth,
  342. height: rect.height * constraints.maxHeight,
  343. child: Stack(
  344. clipBehavior: Clip.none,
  345. children: [
  346. GestureDetector(
  347. behavior: HitTestBehavior.opaque,
  348. onTap: onSelect,
  349. onPanStart: (_) => onMoveStart(),
  350. onPanUpdate: (details) => onMove(
  351. Offset(details.delta.dx / constraints.maxWidth, details.delta.dy / constraints.maxHeight),
  352. ),
  353. child: Container(
  354. decoration: BoxDecoration(
  355. border: Border.all(color: color, width: 3),
  356. borderRadius: BorderRadius.circular(4),
  357. ),
  358. child: box.className != null
  359. ? Align(
  360. alignment: Alignment.topLeft,
  361. child: Container(
  362. padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
  363. color: color,
  364. child: Text(
  365. box.className!,
  366. style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
  367. ),
  368. ),
  369. )
  370. : null,
  371. ),
  372. ),
  373. if (isSelected) ..._buildHandles(),
  374. if (isResizing)
  375. Positioned(
  376. top: -32,
  377. left: 0,
  378. right: 0,
  379. child: Center(
  380. child: Container(
  381. padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
  382. decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(12)),
  383. child: Text(
  384. "${(rect.width * 100).toStringAsFixed(0)}% × ${(rect.height * 100).toStringAsFixed(0)}%",
  385. style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w600),
  386. ),
  387. ),
  388. ),
  389. ),
  390. ],
  391. ),
  392. );
  393. }
  394. List<Widget> _buildHandles() {
  395. const half = _handleSize / 2;
  396. final specs = <_HandlePosition, Map<String, double>>{
  397. _HandlePosition.topLeft: {'left': -half, 'top': -half},
  398. _HandlePosition.topRight: {'right': -half, 'top': -half},
  399. _HandlePosition.bottomLeft: {'left': -half, 'bottom': -half},
  400. _HandlePosition.bottomRight: {'right': -half, 'bottom': -half},
  401. };
  402. return specs.entries.map((entry) {
  403. final pos = entry.value;
  404. return Positioned(
  405. left: pos['left'],
  406. right: pos['right'],
  407. top: pos['top'],
  408. bottom: pos['bottom'],
  409. child: GestureDetector(
  410. behavior: HitTestBehavior.opaque,
  411. onPanStart: (_) => onResizeStart(),
  412. onPanUpdate: (details) => onResize(
  413. entry.key,
  414. Offset(details.delta.dx / constraints.maxWidth, details.delta.dy / constraints.maxHeight),
  415. ),
  416. onPanEnd: (_) => onResizeEnd(),
  417. onPanCancel: onResizeEnd,
  418. child: Container(
  419. width: _handleSize,
  420. height: _handleSize,
  421. decoration: BoxDecoration(
  422. color: Colors.white,
  423. shape: BoxShape.circle,
  424. border: Border.all(color: Colors.black45, width: 2),
  425. ),
  426. ),
  427. ),
  428. );
  429. }).toList();
  430. }
  431. }