This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
palm_oil_mobile is a Flutter app that runs on-device YOLO26 (NMS-free) inference to detect palm oil fresh fruit bunch (FFB) ripeness against MPOB standards, entirely offline (no server/network calls — contrast with frontend/ and server-desktop/ in the parent monorepo, which do remote inference over sockets). Detection classes: Ripe, Unripe, Underripe, Overripe, Abnormal, Empty_Bunch.
flutter pub get # install dependencies
flutter run # run on connected device/emulator
flutter build apk # release Android build
flutter test # run all tests
flutter test test/widget_test.dart # run a single test file
flutter analyze # static analysis (flutter_lints, see analysis_options.yaml)
There is no backend to start — the model (assets/best.tflite) and labels (assets/labels.txt) are bundled assets loaded at runtime.
All TFLite inference lives in lib/services/tflite_service.dart. TfliteService spawns a single persistent isolate on initModel() (not one isolate per inference) that owns the Interpreter. The main isolate communicates via SendPort/ReceivePort message passing with commands init, inference_static, inference_stream. This keeps camera-stream frame processing off the UI thread. isIsolateBusy is used as a gate so callers never queue up a second in-flight inference against the same isolate.
Two inference paths decode differently before running the same model:
_inferenceStaticTask): decodes a file (from camera capture or gallery pick) via the image package, center-crops to square, resizes to 640×640._inferenceStreamTask): consumes raw CameraImage planes and manually converts YUV420 (Android) or BGRA8888 (iOS) to RGB before the same crop/resize — see _convertYUV420ToImage for the manual colorspace math.Detection decoding (_decodeDetections) assumes the model is NMS-free (YOLO26 end-to-end) — output boxes are used directly without a secondary non-max-suppression pass in Dart. Confidence threshold is 0.25 (_confidenceThreshold); box coordinates come back normalized and are re-mapped from crop-space to full-frame-space when crop offsets are provided.
lib/screens/live_analysis_screen.dart implements an auto-capture flow driven by a DetectionState enum (searching → locking → capturing → cooldown) rather than a manual shutter button:
_detectionHistory) tracks recent high-confidence hits; reaching _requiredHits (5) transitions searching → locking._startLockTimer) accumulates/drains "momentum ticks" — sustained detection advances toward auto-capture, a lost target drains it back to searching._triggerCapture stops the camera stream, takes a high-res still, re-runs inference on that still (the stream inference is only used for lock detection, not the persisted result), saves to history, then a 3s cooldown timer restarts the stream.static_capture_screen.dart (manual shutter) and analysis_screen.dart (gallery picker) follow simpler capture → infer → persist → save flows without the state machine.
lib/services/database_helper.dart is a singleton wrapping a sqflite table history. Each row is a PalmRecord (lib/models/palm_record.dart): the best (highest-confidence) detection's box/class/confidence are stored as first-class columns, while the full detection list for that frame is serialized as a JSON blob in the detections column (bump _onUpgrade's version check when changing schema). Captured images are copied out of the ephemeral camera/picker temp path into getApplicationDocumentsDirectory() before the DB row is written, so history entries survive app restarts.
lib/widgets/palm_bounding_box.dart (PalmBoundingBox) is the canonical bounding-box overlay with the MPOB color palette (green=Ripe, orange/yellow=Underripe, blue=Unripe, red=Abnormal, gray=Empty_Bunch) and is reused across the live, gallery, and history-detail screens. static_capture_screen.dart still has its own local _buildBoundingBox — prefer PalmBoundingBox for any new box-drawing code.
main.dart → HomeScreen is a plain card-based Navigator.push menu into four independent screens (AnalysisScreen, StaticCaptureScreen, LiveAnalysisScreen, HistoryScreen) — no named routes, no shared state/provider layer. Each screen owns its own TfliteService and DatabaseHelper instance and must call _tfliteService.dispose() in dispose() to kill its isolate.