detection_editor_screen.dart 17 KB

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