import 'dart:io'; import 'dart:async'; import 'package:flutter/material.dart'; import 'package:camera/camera.dart'; import 'package:permission_handler/permission_handler.dart'; import '../services/tflite_service.dart'; import '../widgets/palm_bounding_box.dart'; import '../constants/mpob_colors.dart'; import '../services/app_strings.dart'; import '../theme/locale_controller.dart'; enum DetectionState { searching, locking, capturing, cooldown } class LiveAnalysisScreen extends StatefulWidget { const LiveAnalysisScreen({super.key}); @override State createState() => _LiveAnalysisScreenState(); } class _LiveAnalysisScreenState extends State { CameraController? _controller; final TfliteService _tfliteService = TfliteService(); bool _isInitialized = false; bool _isProcessing = false; List? _detections; // Detection Lock Logic DetectionState _state = DetectionState.searching; static const double _lockThreshold = 0.60; // Frame Throttle removed: Atomic Idle Lock active. final List _detectionHistory = List.filled(15, false, growable: true); // 15 frames buffer static const int _requiredHits = 5; // Target hits for momentum lock int _currentHits = 0; // Track hits for the timer Timer? _lockTimer; Timer? _cooldownTimer; double _lockProgress = 0.0; bool _showFlash = false; // Raw CameraImage stream frames arrive in the sensor's native (landscape) // orientation, never pre-rotated for display — unlike CameraPreview's own // texture, which is rotated internally to appear upright. Detection boxes // decoded from the stream must be rotated by this same amount before // rendering, or they land in the wrong place/orientation relative to what's // shown on screen. Defaults to 90 (the standard rear-camera value on // Android) until the actual camera's sensorOrientation is known. int _sensorOrientation = 90; @override void initState() { super.initState(); _initializeCamera(); } Future _initializeCamera() async { final status = await Permission.camera.request(); if (status.isDenied) return; final cameras = await availableCameras(); if (cameras.isEmpty) return; _sensorOrientation = cameras[0].sensorOrientation; _controller = CameraController( cameras[0], ResolutionPreset.medium, // Upgraded resolution for YOLO26 performance enableAudio: false, imageFormatGroup: Platform.isAndroid ? ImageFormatGroup.yuv420 : ImageFormatGroup.bgra8888, ); try { await _controller!.initialize(); await _tfliteService.initModel(); _controller!.startImageStream(_handleImageStream); if (mounted) { setState(() { _isInitialized = true; }); } } catch (e) { debugPrint("Camera init error: $e"); } } void _handleImageStream(CameraImage image) { // Atomic Idle Lock: Only process if the Persistent Isolate is truly idle if (_isProcessing || _tfliteService.isIsolateBusy || _state == DetectionState.capturing || _state == DetectionState.cooldown) { return; } _processStreamFrame(image); } Future _processStreamFrame(CameraImage image) async { setState(() => _isProcessing = true); try { final detections = _rotateDetections(await _tfliteService.runInferenceOnStream(image)); bool currentFrameHasFruit = false; if (detections.isNotEmpty) { currentFrameHasFruit = detections.any((d) => d.confidence > _lockThreshold); } // Update Sliding Window Buffer _detectionHistory.removeAt(0); _detectionHistory.add(currentFrameHasFruit); _currentHits = _detectionHistory.where((h) => h).length; if (!mounted) return; setState(() { _detections = detections; }); if (_state == DetectionState.searching) { if (_currentHits >= _requiredHits) { setState(() { _state = DetectionState.locking; _lockProgress = 0.0; }); _startLockTimer(); } } // Removed the old strict cancel logic. // _startLockTimer now safely handles momentum drain. } catch (e) { debugPrint("Stream processing error: $e"); } finally { if (mounted) { setState(() => _isProcessing = false); } } } /// Rotates each detection's normalized box from raw sensor-space into /// display-space, matching the rotation CameraPreview's own texture /// undergoes internally. Confidence/class are untouched. List _rotateDetections(List detections) { final quarterTurns = (_sensorOrientation ~/ 90) % 4; if (quarterTurns == 0) return detections; return detections .map((d) => DetectionResult( className: d.className, classIndex: d.classIndex, confidence: d.confidence, normalizedBox: _rotateNormalizedRect(d.normalizedBox, quarterTurns), )) .toList(); } /// Rotates an axis-aligned rect in normalized [0,1]x[0,1] space by /// [quarterTurns] * 90° clockwise. Rect _rotateNormalizedRect(Rect r, int quarterTurns) { switch (quarterTurns) { case 1: // 90° clockwise return Rect.fromLTRB(1 - r.bottom, r.left, 1 - r.top, r.right); case 2: // 180° return Rect.fromLTRB(1 - r.right, 1 - r.bottom, 1 - r.left, 1 - r.top); case 3: // 270° clockwise (90° counter-clockwise) return Rect.fromLTRB(r.top, 1 - r.right, r.bottom, 1 - r.left); default: return r; } } void _startLockTimer() { _lockTimer?.cancel(); const duration = Duration(milliseconds: 100); int momentumTicks = 0; _lockTimer = Timer.periodic(duration, (timer) { if (!mounted) { timer.cancel(); return; } // Momentum logic: add or subtract if (_currentHits >= _requiredHits) { momentumTicks++; } else { momentumTicks--; } if (momentumTicks < 0) momentumTicks = 0; setState(() { _lockProgress = (momentumTicks / 2.0).clamp(0.0, 1.0); // 2 ticks target }); if (momentumTicks >= 2) { timer.cancel(); if (_state == DetectionState.locking) { _triggerCapture(); } } else if (momentumTicks <= 0 && _state == DetectionState.locking) { // Complete momentum loss -> Cancel lock timer.cancel(); setState(() { _state = DetectionState.searching; _lockProgress = 0.0; }); } }); } Future _triggerCapture() async { setState(() { _state = DetectionState.capturing; _lockProgress = 1.0; _showFlash = true; }); // Quick 200ms white flash without blocking Future.delayed(const Duration(milliseconds: 200), () { if (mounted) setState(() => _showFlash = false); }); await _captureAndAnalyze(); } /// Live Inference is a quick verification tool, not a capture mode — the /// result is shown once and then discarded. Nothing is written to history; /// use Snap & Analyze if you want the result kept. Future _captureAndAnalyze() async { if (_controller == null || !_controller!.value.isInitialized) return; // 1. Stop stream to avoid resource conflict await _controller!.stopImageStream(); if (!mounted) return; try { // 2. Take high-res picture final XFile photo = await _controller!.takePicture(); // 3. Run final inference on high-res final detections = await _tfliteService.runInference(photo.path); // 4. Discard the temp capture — nothing persists past this quick check. unawaited(File(photo.path).delete().catchError((_) => File(photo.path))); if (detections.isNotEmpty) { final best = detections.first; if (mounted) { await _showResultSheet(best.className, best.confidence); _startCooldown(); } } else { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(AppStrings.t('live_no_detections_snackbar'))) ); _startCooldown(); } } } catch (e) { debugPrint("Capture error: $e"); if (mounted) _startCooldown(); } } void _startCooldown() { if (!mounted) return; setState(() { _state = DetectionState.cooldown; _detections = null; // Clear boxes }); // Clear detection history to ignore old hits _detectionHistory.fillRange(0, _detectionHistory.length, false); _cooldownTimer?.cancel(); _cooldownTimer = Timer(const Duration(seconds: 3), () { if (!mounted) return; setState(() { _state = DetectionState.searching; }); _controller?.startImageStream(_handleImageStream); }); } Future _showResultSheet(String ripenessClass, double confidence) async { final Color statusColor = getStatusColor(ripenessClass); final colorScheme = Theme.of(context).colorScheme; await showModalBottomSheet( context: context, isScrollControlled: true, isDismissible: false, enableDrag: false, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), builder: (context) => Container( padding: const EdgeInsets.all(24), decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.check_circle, color: statusColor, size: 64), const SizedBox(height: 16), Text(AppStrings.className(ripenessClass), style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: colorScheme.onSurface)), Text( AppStrings.tp('confidence_label', {'percent': (confidence * 100).toStringAsFixed(1)}), style: TextStyle(color: colorScheme.onSurface.withValues(alpha: 0.6)), ), const SizedBox(height: 24), if (ripenessClass == 'Empty_Bunch' || ripenessClass == 'Abnormal') Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: Colors.red.shade50, borderRadius: BorderRadius.circular(8)), child: Text(AppStrings.t('health_alert_abnormal'), style: const TextStyle(color: Colors.red, fontWeight: FontWeight.bold)), ), const SizedBox(height: 24), SizedBox( width: double.infinity, child: ElevatedButton( onPressed: () => Navigator.pop(context), child: Text(AppStrings.t('done_button')), ), ), ], ), ), ); } @override Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: LocaleController.localeCode, builder: (context, _, _) => _buildScaffold(context), ); } Widget _buildScaffold(BuildContext context) { if (!_isInitialized || _controller == null) { return const Scaffold(body: Center(child: CircularProgressIndicator())); } final isLockedVisual = _state == DetectionState.locking || _state == DetectionState.capturing; return Scaffold( backgroundColor: Colors.black, body: Stack( children: [ // Camera Preview + Bounding Box Overlays. The overlay is passed as // CameraPreview's own `child` slot (not a separate sibling) so it's // measured against the exact same orientation-aware AspectRatio box // CameraPreview computes internally for the texture itself — // otherwise the overlay measures the full screen while the preview // is letterboxed/pillarboxed to the camera's real aspect ratio, // causing misaligned/stretched boxes. Center( child: CameraPreview( _controller!, child: _detections != null && _state != DetectionState.capturing && _state != DetectionState.cooldown ? LayoutBuilder( builder: (context, constraints) { return Stack( children: _detections! .map((d) => PalmBoundingBox( normalizedRect: d.normalizedBox, label: d.className, confidence: d.confidence, constraints: constraints, isLocked: (_state == DetectionState.locking || _state == DetectionState.capturing) && d.confidence > _lockThreshold, )) .toList(), ); }, ) : null, ), ), // Top Info Bar Positioned( top: 40, left: 20, right: 20, child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: Colors.black54, borderRadius: BorderRadius.circular(20), ), child: Row( children: [ Icon( _state == DetectionState.cooldown ? Icons.pause_circle_filled : isLockedVisual ? Icons.lock : Icons.center_focus_weak, color: _state == DetectionState.cooldown ? Colors.blue : isLockedVisual ? Colors.green : Colors.yellow, ), const SizedBox(width: 8), Text( _state == DetectionState.cooldown ? AppStrings.t('live_state_cooldown') : isLockedVisual ? AppStrings.t('live_state_locking') : AppStrings.t('live_state_searching'), style: TextStyle( color: _state == DetectionState.cooldown ? Colors.blue : isLockedVisual ? Colors.green : Colors.yellow, fontWeight: FontWeight.bold, ), ), const Spacer(), IconButton( icon: const Icon(Icons.close, color: Colors.white), onPressed: () => Navigator.pop(context), ), ], ), ), ), // Progress Overlay for Locking if (_state == DetectionState.locking) Positioned.fill( child: Center( child: SizedBox( width: 120, height: 120, child: TweenAnimationBuilder( tween: Tween(begin: 0.0, end: _lockProgress), duration: const Duration(milliseconds: 100), builder: (context, value, _) => CircularProgressIndicator( value: value, strokeWidth: 8, color: Colors.greenAccent, backgroundColor: Colors.white24, ), ), ), ), ), // White flash overlay Positioned.fill( child: IgnorePointer( child: AnimatedOpacity( opacity: _showFlash ? 1.0 : 0.0, duration: const Duration(milliseconds: 200), child: Container(color: Colors.white), ), ), ), if (_state == DetectionState.capturing && !_showFlash) Positioned.fill( child: Container( color: Colors.black45, child: const Center( child: CircularProgressIndicator(color: Colors.white), ), ), ), if (_state == DetectionState.cooldown) Positioned.fill( child: Container( color: Colors.black45, child: Center( child: Text( AppStrings.t('live_resuming_scan'), style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold), ), ), ), ), // Bottom Hint if (_state == DetectionState.searching) Positioned( bottom: 40, left: 0, right: 0, child: Center( child: Text( AppStrings.t('live_hold_steady_hint'), style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w500), ), ), ), ], ), ); } @override void dispose() { _lockTimer?.cancel(); _cooldownTimer?.cancel(); _controller?.dispose(); _tfliteService.dispose(); super.dispose(); } }