import 'dart:io'; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:camera/camera.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as p; import '../services/tflite_service.dart'; import '../services/database_helper.dart'; import '../models/palm_record.dart'; class LiveAnalysisScreen extends StatefulWidget { const LiveAnalysisScreen({super.key}); @override State createState() => _LiveAnalysisScreenState(); } class _LiveAnalysisScreenState extends State { CameraController? _controller; final TfliteService _tfliteService = TfliteService(); final DatabaseHelper _dbHelper = DatabaseHelper(); bool _isInitialized = false; bool _isProcessing = false; int _frameCount = 0; List? _detections; // Detection Lock Logic bool _isLocked = false; static const double _lockThreshold = 0.60; static const int _frameThrottle = 2; // Check frames more frequently int _consecutiveDetections = 0; static const int _requiredConsecutive = 3; // Number of frames to hold @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; _controller = CameraController( cameras[0], ResolutionPreset.medium, enableAudio: false, imageFormatGroup: Platform.isAndroid ? ImageFormatGroup.yuv420 : ImageFormatGroup.bgra8888, ); try { await _controller!.initialize(); await _tfliteService.initModel(); _controller!.startImageStream((image) { if (_isProcessing) return; _frameCount++; if (_frameCount % _frameThrottle != 0) return; _processStreamFrame(image); }); if (mounted) { setState(() { _isInitialized = true; }); } } catch (e) { print("Camera init error: $e"); } } Future _processStreamFrame(CameraImage image) async { setState(() => _isProcessing = true); try { final detections = await _tfliteService.runInferenceOnStream(image); bool currentFrameHasFruit = false; if (detections.isNotEmpty) { currentFrameHasFruit = detections.any((d) => d.confidence > _lockThreshold); } if (currentFrameHasFruit) { _consecutiveDetections++; } else { _consecutiveDetections--; // Just drop by one, don't kill the whole lock } _consecutiveDetections = _consecutiveDetections.clamp(0, _requiredConsecutive); if (mounted) { setState(() { _detections = detections; _isLocked = _consecutiveDetections >= _requiredConsecutive; }); } } catch (e) { print("Stream processing error: $e"); } finally { _isProcessing = false; } } Future _captureAndAnalyze() async { if (_controller == null || !_controller!.value.isInitialized) return; // 1. Stop stream to avoid resource conflict await _controller!.stopImageStream(); // Show loading dialog if (!mounted) return; _showLoadingDialog(); 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); if (detections.isNotEmpty) { // 4. Archive final appDocDir = await getApplicationDocumentsDirectory(); final fileName = p.basename(photo.path); final persistentPath = p.join(appDocDir.path, 'palm_live_${DateTime.now().millisecondsSinceEpoch}_$fileName'); await File(photo.path).copy(persistentPath); final best = detections.first; final record = PalmRecord( imagePath: persistentPath, ripenessClass: best.className, confidence: best.confidence, timestamp: DateTime.now(), x1: best.normalizedBox.left, y1: best.normalizedBox.top, x2: best.normalizedBox.right, y2: best.normalizedBox.bottom, detections: detections.map((d) => { 'className': d.className, 'classIndex': d.classIndex, 'confidence': d.confidence, 'x1': d.normalizedBox.left, 'y1': d.normalizedBox.top, 'x2': d.normalizedBox.right, 'y2': d.normalizedBox.bottom, }).toList(), ); await _dbHelper.insertRecord(record); // 5. Show result and resume camera if (mounted) { Navigator.of(context).pop(); // Close loading _showResultSheet(record); } } else { if (mounted) { Navigator.of(context).pop(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text("No palm bunches detected in final snap.")) ); } } } catch (e) { if (mounted) Navigator.of(context).pop(); print("Capture error: $e"); } finally { // Restart stream _controller!.startImageStream((image) { if (_isProcessing) return; _frameCount++; if (_frameCount % _frameThrottle != 0) return; _processStreamFrame(image); }); } } void _showLoadingDialog() { showDialog( context: context, barrierDismissible: false, builder: (context) => const Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(color: Colors.white), SizedBox(height: 16), Text("Final Grading...", style: TextStyle(color: Colors.white)), ], ), ), ); } void _showResultSheet(PalmRecord record) { // Determine color based on ripeness class Color statusColor = const Color(0xFFFF9800); // Default orange if (record.ripenessClass == 'Empty_Bunch' || record.ripenessClass == 'Abnormal') { statusColor = const Color(0xFFF44336); } else if (record.ripenessClass == 'Ripe' || record.ripenessClass == 'Overripe') { statusColor = const Color(0xFF4CAF50); } showModalBottomSheet( context: context, isScrollControlled: true, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), builder: (context) => Container( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.check_circle, color: statusColor, size: 64), const SizedBox(height: 16), Text(record.ripenessClass, style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold)), Text("Confidence: ${(record.confidence * 100).toStringAsFixed(1)}%", style: const TextStyle(color: Colors.grey)), const SizedBox(height: 24), if (record.ripenessClass == 'Empty_Bunch' || record.ripenessClass == 'Abnormal') Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: Colors.red.shade50, borderRadius: BorderRadius.circular(8)), child: const Text("HEALTH ALERT: Abnormal detected!", style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold)), ), const SizedBox(height: 24), SizedBox( width: double.infinity, child: ElevatedButton( onPressed: () => Navigator.pop(context), child: const Text("Done"), ), ), ], ), ), ); } @override Widget build(BuildContext context) { if (!_isInitialized || _controller == null) { return const Scaffold(body: Center(child: CircularProgressIndicator())); } return Scaffold( backgroundColor: Colors.black, body: Stack( children: [ // Camera Preview Center( child: CameraPreview(_controller!), ), // Bounding Box Overlays if (_detections != null) Positioned.fill( child: LayoutBuilder( builder: (context, constraints) { return Stack( children: _detections! .map((d) => _buildOverlayBox(d, constraints)) .toList(), ); }, ), ), // 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( _isLocked ? Icons.lock : Icons.center_focus_weak, color: _isLocked ? Colors.green : Colors.yellow, ), const SizedBox(width: 8), Text( _isLocked ? "LOCKED" : "TARGETING...", style: TextStyle( color: _isLocked ? Colors.green : Colors.yellow, fontWeight: FontWeight.bold, ), ), const Spacer(), IconButton( icon: const Icon(Icons.close, color: Colors.white), onPressed: () => Navigator.pop(context), ), ], ), ), ), // Bottom Controls Positioned( bottom: 40, left: 0, right: 0, child: Center( child: GestureDetector( onTap: _isLocked ? _captureAndAnalyze : null, child: Container( width: 80, height: 80, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 4), color: _isLocked ? Colors.green.withValues(alpha: 0.8) : Colors.white24, ), child: Icon( _isLocked ? Icons.camera_alt : Icons.hourglass_empty, color: Colors.white, size: 32, ), ), ), ), ), if (!_isLocked) const Positioned( bottom: 130, left: 0, right: 0, child: Center( child: Text( "Hold steady to lock target", style: TextStyle(color: Colors.white, fontWeight: FontWeight.w500), ), ), ), ], ), ); } Widget _buildOverlayBox(DetectionResult detection, BoxConstraints constraints) { final rect = detection.normalizedBox; final color = detection.confidence > _lockThreshold ? Colors.green : Colors.yellow; return Positioned( left: rect.left * constraints.maxWidth, top: rect.top * constraints.maxHeight, width: rect.width * constraints.maxWidth, height: rect.height * constraints.maxHeight, child: Container( decoration: BoxDecoration( border: Border.all(color: color, width: 2), borderRadius: BorderRadius.circular(4), ), child: Align( alignment: Alignment.topLeft, child: Container( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), color: color, child: Text( "${(detection.confidence * 100).toStringAsFixed(0)}%", style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold), ), ), ), ), ); } @override void dispose() { _controller?.dispose(); _tfliteService.dispose(); super.dispose(); } }