| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473 |
- 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 '../services/app_strings.dart';
- import '../theme/locale_controller.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) {
- return ValueListenableBuilder<String>(
- valueListenable: LocaleController.localeCode,
- builder: (context, _, _) => _buildScaffold(context),
- );
- }
- Widget _buildScaffold(BuildContext context) {
- final colorScheme = Theme.of(context).colorScheme;
- return Scaffold(
- appBar: AppBar(
- title: Text(AppStrings.t('add_missed_bunch_button')),
- actions: [
- IconButton(
- tooltip: AppStrings.t('undo_tooltip'),
- onPressed: _canUndo ? _undo : null,
- icon: const Icon(Icons.undo),
- ),
- IconButton(
- tooltip: AppStrings.t('redo_tooltip'),
- onPressed: _canRedo ? _redo : null,
- icon: const Icon(Icons.redo),
- ),
- IconButton(
- tooltip: _showReference ? AppStrings.t('hide_reference_tooltip') : AppStrings.t('show_reference_tooltip'),
- 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(
- AppStrings.t('editor_instruction_text'),
- style: TextStyle(fontSize: 13, color: colorScheme.onSurface.withValues(alpha: 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.withValues(alpha: 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.withValues(alpha: 0.4) : getMPOBColor(box.className!),
- ),
- label: Text(box.className == null ? AppStrings.t('pick_grade_placeholder') : AppStrings.className(box.className!)),
- ),
- ),
- 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
- ? AppStrings.t('editor_tap_to_add_hint')
- : AppStrings.tp(count == 1 ? 'editor_bunches_added_singular' : 'editor_bunches_added_plural', {'count': '$count'}),
- textAlign: TextAlign.center,
- style: TextStyle(fontSize: 13, color: colorScheme.onSurface.withValues(alpha: 0.7)),
- ),
- ),
- IconButton.filled(
- onPressed: _canSave ? _save : null,
- style: IconButton.styleFrom(
- backgroundColor: colorScheme.primary,
- foregroundColor: colorScheme.onPrimary,
- disabledBackgroundColor: colorScheme.onSurface.withValues(alpha: 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(
- AppStrings.className(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();
- }
- }
|