Jelajahi Sumber

Add missed/phantom detection corrections; fix invisible tab highlighting

Harvesters can now fix detections the AI missed entirely (a new
full-screen box editor with tap-to-place, drag-to-move, corner-handle
resize, undo/redo, and a show/hide toggle for the dimmed AI reference
boxes) or remove a phantom detection (folded into the existing grade
picker). Corrections default to opening on the "Your Correction" tab
once one exists.

Also fixes a real contrast bug: the app's secondaryContainer color was
identical to the page background, so Material 3's default selected
state for SegmentedButton/ChoiceChip was invisible. Both now use an
explicit primary-colored highlight.

Adds PRODUCTION_OPTIONS.md covering release signing and distribution
strategy for field rollout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
enzo 1 Minggu lalu
induk
melakukan
68c7064f74

+ 3 - 0
.gitignore

@@ -12,6 +12,9 @@
 .swiftpm/
 migrate_working_dir/
 
+# MCP server config — contains a live bearer token, keep local-only
+.mcp.json
+
 # IntelliJ related
 *.iml
 *.ipr

+ 73 - 0
PRODUCTION_OPTIONS.md

@@ -0,0 +1,73 @@
+# Production Options
+
+This app is a field tool for FFB harvesters, not a public consumer app, so the
+distribution strategy should optimize for controlled rollout to a known user
+base rather than App Store/Play Store discoverability.
+
+## Current blocker
+
+`android/app/build.gradle.kts` still points the `release` build type at the
+**debug** signing config:
+
+```kotlin
+buildTypes {
+    release {
+        signingConfig = signingConfigs.getByName("debug")
+    }
+}
+```
+
+A real release keystore (generated once, kept private, referenced via a
+`key.properties` file that is **not** committed to git) is required before any
+of the options below produce a distributable build. `flutter build apk` today
+produces a debug-signed APK that will install fine for testing but should not
+be handed out as "production."
+
+## Option 1 — Direct APK distribution
+
+Sign a release build with a real keystore and share the APK directly (email,
+internal file share, Firebase App Distribution, or a private download link).
+
+- **Pros**: no store review, no delay, full control over rollout timing,
+  works for a small/known group of harvesters.
+- **Cons**: users must enable "install unknown apps" for the source; you own
+  update delivery (no auto-update) unless paired with a tool like Firebase
+  App Distribution or an in-app update check; no Play Protect scanning
+  reassurance for end users.
+- **Best fit**: pilot rollout, a handful of estates/harvesters, fast
+  iteration during early field testing.
+
+## Option 2 — Google Play (Internal or Closed Testing track)
+
+Upload a signed release build (AAB preferred) to Play Console under an
+Internal Testing or Closed Testing track, and invite harvesters by email or
+a shareable opt-in link.
+
+- **Pros**: auto-updates, no "unknown sources" friction, Play Protect
+  scanning, easy to promote the same build to a wider track later without
+  re-signing.
+- **Cons**: requires a Play Console account (~$25 one-time), a privacy
+  policy URL, and a short review pass per release (usually fast for
+  testing tracks, slower for public production).
+- **Best fit**: once the app is stable enough for ongoing use across many
+  harvesters/estates and update convenience matters more than rollout speed.
+
+## Recommendation
+
+Start with **Option 1** for the current field-testing phase (small,
+known audience, fast iteration), then move to **Option 2** once the app
+stabilizes and the user base grows enough that manual APK distribution
+becomes a bottleneck.
+
+## Next steps
+
+1. Generate a release keystore and store it outside the repo (e.g. a
+   password manager or secure ops storage) — never commit it.
+2. Add a `key.properties` file (gitignored) referencing the keystore, and
+   wire `android/app/build.gradle.kts` to use it for the `release` build
+   type instead of the debug config.
+3. Build and smoke-test a signed release APK (`flutter build apk
+   --release`) on a real device before distributing.
+4. Decide on update delivery (manual re-share vs. Firebase App
+   Distribution vs. Play Console track) based on how many harvesters are
+   testing at that point.

+ 462 - 0
lib/screens/detection_editor_screen.dart

@@ -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();
+  }
+}

+ 5 - 0
lib/screens/history_screen.dart

@@ -155,6 +155,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
       builder: (sheetContext) {
         return StatefulBuilder(
           builder: (sheetContext, setSheetState) {
+            final colorScheme = Theme.of(sheetContext).colorScheme;
             return Padding(
               padding: const EdgeInsets.all(24),
               child: Column(
@@ -183,6 +184,8 @@ class _HistoryScreenState extends State<HistoryScreen> {
                         child: ChoiceChip(
                           label: const Text("Latest First"),
                           selected: !_sortAscending,
+                          selectedColor: colorScheme.primary,
+                          labelStyle: TextStyle(color: !_sortAscending ? colorScheme.onPrimary : colorScheme.onSurface),
                           onSelected: (_) {
                             setState(() => _sortAscending = false);
                             setSheetState(() {});
@@ -195,6 +198,8 @@ class _HistoryScreenState extends State<HistoryScreen> {
                         child: ChoiceChip(
                           label: const Text("Oldest First"),
                           selected: _sortAscending,
+                          selectedColor: colorScheme.primary,
+                          labelStyle: TextStyle(color: _sortAscending ? colorScheme.onPrimary : colorScheme.onSurface),
                           onSelected: (_) {
                             setState(() => _sortAscending = true);
                             setSheetState(() {});

+ 57 - 33
lib/screens/record_detail_screen.dart

@@ -8,6 +8,8 @@ import '../services/database_helper.dart';
 import '../constants/mpob_colors.dart';
 import '../theme/app_theme.dart';
 import '../widgets/palm_bounding_box.dart';
+import '../widgets/class_picker_sheet.dart';
+import 'detection_editor_screen.dart';
 
 /// Detection data parsed from the JSON-serialized `detections` list stored
 /// on a [PalmRecord] (or a [PalmCorrection]'s corrected detections).
@@ -15,11 +17,13 @@ class DetectionData {
   final String className;
   final double confidence;
   final Rect normalizedBox;
+  final bool isManual;
 
   const DetectionData({
     required this.className,
     required this.confidence,
     required this.normalizedBox,
+    this.isManual = false,
   });
 
   factory DetectionData.fromMap(Map<String, dynamic> m) {
@@ -32,6 +36,7 @@ class DetectionData {
         (m['x2'] as num?)?.toDouble() ?? 1.0,
         (m['y2'] as num?)?.toDouble() ?? 1.0,
       ),
+      isManual: m['isManual'] == true,
     );
   }
 
@@ -44,14 +49,16 @@ class DetectionData {
       'y1': normalizedBox.top,
       'x2': normalizedBox.right,
       'y2': normalizedBox.bottom,
+      'isManual': isManual,
     };
   }
 
-  DetectionData copyWith({String? className}) {
+  DetectionData copyWith({String? className, bool? isManual}) {
     return DetectionData(
       className: className ?? this.className,
       confidence: confidence,
       normalizedBox: normalizedBox,
+      isManual: isManual ?? this.isManual,
     );
   }
 }
@@ -169,6 +176,7 @@ class _RecordDetailBodyState extends State<_RecordDetailBody> {
       if (existing != null) {
         _correctionDraft = existing.correctedDetections.map((d) => DetectionData.fromMap(d)).toList();
         _lastCorrectedAt = existing.timestamp;
+        _viewMode = _ViewMode.corrected;
       } else {
         _correctionDraft = List.of(_aiDetections);
       }
@@ -177,40 +185,19 @@ class _RecordDetailBodyState extends State<_RecordDetailBody> {
   }
 
   Future<void> _editDetection(int index) async {
-    final colorScheme = Theme.of(context).colorScheme;
-    final selected = await showModalBottomSheet<String>(
-      context: context,
-      shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
-      builder: (sheetContext) {
-        return SafeArea(
-          child: Column(
-            mainAxisSize: MainAxisSize.min,
-            children: [
-              Padding(
-                padding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
-                child: Align(
-                  alignment: Alignment.centerLeft,
-                  child: Text("Correct Grade", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
-                ),
-              ),
-              ...mpobClasses.map((className) {
-                return ListTile(
-                  leading: Container(width: 14, height: 14, decoration: BoxDecoration(color: getMPOBColor(className), shape: BoxShape.circle)),
-                  title: Text(className),
-                  trailing: _correctionDraft[index].className == className ? const Icon(Icons.check) : null,
-                  onTap: () => Navigator.pop(sheetContext, className),
-                );
-              }),
-              const SizedBox(height: 8),
-            ],
-          ),
-        );
-      },
+    final result = await showClassPickerSheet(
+      context,
+      currentClass: _correctionDraft[index].className,
+      showRemoveOption: true,
     );
+    if (result == null) return;
 
-    if (selected != null) {
+    if (result.remove) {
+      setState(() => _correctionDraft.removeAt(index));
+      await _persistCorrection();
+    } else if (result.className != null) {
       setState(() {
-        _correctionDraft[index] = _correctionDraft[index].copyWith(className: selected);
+        _correctionDraft[index] = _correctionDraft[index].copyWith(className: result.className);
       });
       await _persistCorrection();
     }
@@ -223,6 +210,28 @@ class _RecordDetailBodyState extends State<_RecordDetailBody> {
     await _editDetection(index);
   }
 
+  /// Opens the full-screen editor for placing boxes the AI missed entirely.
+  /// Always available — even a photo with zero AI detections needs a way
+  /// into correction mode.
+  Future<void> _openDetectionEditor() async {
+    if (_viewMode != _ViewMode.corrected) {
+      setState(() => _viewMode = _ViewMode.corrected);
+    }
+    final added = await Navigator.push<List<DetectionData>>(
+      context,
+      MaterialPageRoute(
+        builder: (context) => DetectionEditorScreen(
+          imagePath: widget.record.imagePath,
+          existingDetections: _correctionDraft,
+        ),
+      ),
+    );
+    if (added != null && added.isNotEmpty) {
+      setState(() => _correctionDraft.addAll(added));
+      await _persistCorrection();
+    }
+  }
+
   /// Writes the current [_correctionDraft] as a new correction row. Called
   /// automatically after every box edit and after a reset — there is no
   /// separate manual "save" step to forget.
@@ -294,6 +303,11 @@ class _RecordDetailBodyState extends State<_RecordDetailBody> {
                 ButtonSegment(value: _ViewMode.corrected, label: Text("Your Correction"), icon: Icon(Icons.edit_outlined)),
               ],
               selected: {_viewMode},
+              showSelectedIcon: false,
+              style: SegmentedButton.styleFrom(
+                selectedBackgroundColor: colorScheme.primary,
+                selectedForegroundColor: colorScheme.onPrimary,
+              ),
               onSelectionChanged: (selection) => setState(() => _viewMode = selection.first),
             ),
             const SizedBox(height: 8),
@@ -316,6 +330,15 @@ class _RecordDetailBodyState extends State<_RecordDetailBody> {
                 ),
             ],
           ),
+          const SizedBox(height: 8),
+          Align(
+            alignment: Alignment.centerLeft,
+            child: OutlinedButton.icon(
+              onPressed: _isSaving ? null : _openDetectionEditor,
+              icon: const Icon(Icons.add_box_outlined, size: 18),
+              label: const Text("Add Missed Bunch"),
+            ),
+          ),
           const SizedBox(height: 16),
 
           // Image with Bounding Boxes
@@ -343,6 +366,7 @@ class _RecordDetailBodyState extends State<_RecordDetailBody> {
                                     label: d.className,
                                     confidence: d.confidence,
                                     constraints: constraints,
+                                    isManual: d.isManual,
                                   );
                                 }).toList();
 
@@ -405,7 +429,7 @@ class _RecordDetailBodyState extends State<_RecordDetailBody> {
 
           const Divider(height: 32),
           Text(
-            _viewMode == _ViewMode.ai ? "Analytical Breakdown" : "Corrected Breakdown (tap a bunch above to edit)",
+            _viewMode == _ViewMode.ai ? "Analytical Breakdown" : "Corrected Breakdown (tap a bunch above to edit or remove)",
             style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: colorScheme.onSurface),
           ),
           const SizedBox(height: 12),

+ 58 - 0
lib/widgets/class_picker_sheet.dart

@@ -0,0 +1,58 @@
+import 'package:flutter/material.dart';
+import '../constants/mpob_colors.dart';
+
+/// Result of [showClassPickerSheet]: either a picked [className], or
+/// [remove] set when the harvester chose to delete the detection entirely.
+class ClassPickResult {
+  final String? className;
+  final bool remove;
+
+  const ClassPickResult({this.className, this.remove = false});
+}
+
+/// Shared MPOB class-picker bottom sheet, used both to correct an existing
+/// detection's grade and to grade a newly-added one.
+Future<ClassPickResult?> showClassPickerSheet(
+  BuildContext context, {
+  required String? currentClass,
+  bool showRemoveOption = false,
+}) {
+  final colorScheme = Theme.of(context).colorScheme;
+  return showModalBottomSheet<ClassPickResult>(
+    context: context,
+    shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
+    builder: (sheetContext) {
+      return SafeArea(
+        child: Column(
+          mainAxisSize: MainAxisSize.min,
+          children: [
+            Padding(
+              padding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
+              child: Align(
+                alignment: Alignment.centerLeft,
+                child: Text("Correct Grade", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
+              ),
+            ),
+            ...mpobClasses.map((className) {
+              return ListTile(
+                leading: Container(width: 14, height: 14, decoration: BoxDecoration(color: getMPOBColor(className), shape: BoxShape.circle)),
+                title: Text(className),
+                trailing: currentClass == className ? const Icon(Icons.check) : null,
+                onTap: () => Navigator.pop(sheetContext, ClassPickResult(className: className)),
+              );
+            }),
+            if (showRemoveOption) ...[
+              const Divider(height: 1),
+              ListTile(
+                leading: Icon(Icons.delete_outline, color: colorScheme.error),
+                title: Text("Remove this detection", style: TextStyle(color: colorScheme.error)),
+                onTap: () => Navigator.pop(sheetContext, const ClassPickResult(remove: true)),
+              ),
+            ],
+            const SizedBox(height: 8),
+          ],
+        ),
+      );
+    },
+  );
+}

+ 23 - 16
lib/widgets/palm_bounding_box.dart

@@ -8,6 +8,8 @@ class PalmBoundingBox extends StatelessWidget {
   final double confidence;
   final BoxConstraints constraints;
   final bool isLocked;
+  final bool isManual;
+  final double opacity;
 
   const PalmBoundingBox({
     super.key,
@@ -16,6 +18,8 @@ class PalmBoundingBox extends StatelessWidget {
     required this.confidence,
     required this.constraints,
     this.isLocked = false,
+    this.isManual = false,
+    this.opacity = 1.0,
   });
 
   /// MPOB-standard color palette
@@ -33,22 +37,25 @@ class PalmBoundingBox extends StatelessWidget {
       top: normalizedRect.top * constraints.maxHeight,
       width: normalizedRect.width * constraints.maxWidth,
       height: normalizedRect.height * constraints.maxHeight,
-      child: Container(
-        decoration: BoxDecoration(
-          border: Border.all(color: color, width: 3),
-          borderRadius: BorderRadius.circular(4),
-        ),
-        child: Align(
-          alignment: Alignment.topLeft,
-          child: Container(
-            padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
-            color: color,
-            child: Text(
-              "$label ${(confidence * 100).toStringAsFixed(0)}%",
-              style: const TextStyle(
-                color: Colors.white,
-                fontSize: 10,
-                fontWeight: FontWeight.bold,
+      child: Opacity(
+        opacity: opacity,
+        child: Container(
+          decoration: BoxDecoration(
+            border: Border.all(color: color, width: 3),
+            borderRadius: BorderRadius.circular(4),
+          ),
+          child: Align(
+            alignment: Alignment.topLeft,
+            child: Container(
+              padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
+              color: color,
+              child: Text(
+                "$label ${(confidence * 100).toStringAsFixed(0)}%${isManual ? ' • Added' : ''}",
+                style: const TextStyle(
+                  color: Colors.white,
+                  fontSize: 10,
+                  fontWeight: FontWeight.bold,
+                ),
               ),
             ),
           ),