|
|
@@ -0,0 +1,444 @@
|
|
|
+import 'dart:io';
|
|
|
+import 'dart:ui';
|
|
|
+import 'package:flutter/material.dart';
|
|
|
+import 'package:camera/camera.dart';
|
|
|
+import 'package:path_provider/path_provider.dart';
|
|
|
+import 'package:path/path.dart' as p;
|
|
|
+import 'package:permission_handler/permission_handler.dart';
|
|
|
+import '../services/tflite_service.dart';
|
|
|
+import '../services/database_helper.dart';
|
|
|
+import '../models/palm_record.dart';
|
|
|
+
|
|
|
+class StaticCaptureScreen extends StatefulWidget {
|
|
|
+ const StaticCaptureScreen({super.key});
|
|
|
+
|
|
|
+ @override
|
|
|
+ State<StaticCaptureScreen> createState() => _StaticCaptureScreenState();
|
|
|
+}
|
|
|
+
|
|
|
+class _StaticCaptureScreenState extends State<StaticCaptureScreen> {
|
|
|
+ CameraController? _controller;
|
|
|
+ final TfliteService _tfliteService = TfliteService();
|
|
|
+ final DatabaseHelper _dbHelper = DatabaseHelper();
|
|
|
+
|
|
|
+ bool _isInitialized = false;
|
|
|
+ bool _isAnalyzing = false;
|
|
|
+ XFile? _capturedPhoto;
|
|
|
+ List<DetectionResult>? _detections;
|
|
|
+ String? _errorMessage;
|
|
|
+
|
|
|
+ @override
|
|
|
+ void initState() {
|
|
|
+ super.initState();
|
|
|
+ _initializeCamera();
|
|
|
+ }
|
|
|
+
|
|
|
+ Future<void> _initializeCamera() async {
|
|
|
+ final status = await Permission.camera.request();
|
|
|
+ if (status.isDenied) {
|
|
|
+ if (mounted) {
|
|
|
+ setState(() => _errorMessage = "Camera permission denied.");
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ final cameras = await availableCameras();
|
|
|
+ if (cameras.isEmpty) {
|
|
|
+ if (mounted) {
|
|
|
+ setState(() => _errorMessage = "No cameras found.");
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ _controller = CameraController(
|
|
|
+ cameras[0],
|
|
|
+ ResolutionPreset.high,
|
|
|
+ enableAudio: false,
|
|
|
+ );
|
|
|
+
|
|
|
+ try {
|
|
|
+ await _controller!.initialize();
|
|
|
+ await _tfliteService.initModel();
|
|
|
+ if (mounted) {
|
|
|
+ setState(() => _isInitialized = true);
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ if (mounted) {
|
|
|
+ setState(() => _errorMessage = "Camera init error: $e");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ Future<void> _takeAndAnalyze() async {
|
|
|
+ if (_controller == null || !_controller!.value.isInitialized || _isAnalyzing) return;
|
|
|
+
|
|
|
+ setState(() {
|
|
|
+ _isAnalyzing = true;
|
|
|
+ _errorMessage = null;
|
|
|
+ _detections = null;
|
|
|
+ });
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 1. Capture Photo
|
|
|
+ final XFile photo = await _controller!.takePicture();
|
|
|
+ if (mounted) {
|
|
|
+ setState(() => _capturedPhoto = photo);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. Run Inference
|
|
|
+ final detections = await _tfliteService.runInference(photo.path);
|
|
|
+
|
|
|
+ if (detections.isNotEmpty) {
|
|
|
+ // 3. Persist Image
|
|
|
+ final appDocDir = await getApplicationDocumentsDirectory();
|
|
|
+ final fileName = p.basename(photo.path);
|
|
|
+ final persistentPath = p.join(appDocDir.path, 'palm_static_${DateTime.now().millisecondsSinceEpoch}_$fileName');
|
|
|
+ await File(photo.path).copy(persistentPath);
|
|
|
+
|
|
|
+ // 4. Save to Database
|
|
|
+ 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);
|
|
|
+
|
|
|
+ if (mounted) {
|
|
|
+ setState(() {
|
|
|
+ _detections = detections;
|
|
|
+ _capturedPhoto = XFile(persistentPath);
|
|
|
+ });
|
|
|
+ _showResultSheet(record);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ if (mounted) {
|
|
|
+ setState(() => _errorMessage = "No palm bunches detected.");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ if (mounted) {
|
|
|
+ setState(() => _errorMessage = "Error during capture/analysis: $e");
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ if (mounted) {
|
|
|
+ setState(() => _isAnalyzing = false);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ Future<void> _showResultSheet(PalmRecord record) async {
|
|
|
+ await showModalBottomSheet(
|
|
|
+ context: context,
|
|
|
+ isScrollControlled: true,
|
|
|
+ backgroundColor: Colors.transparent,
|
|
|
+ builder: (context) => _ResultSheet(record: record),
|
|
|
+ );
|
|
|
+
|
|
|
+ // Auto-reset once dismissed
|
|
|
+ if (mounted) {
|
|
|
+ setState(() {
|
|
|
+ _capturedPhoto = null;
|
|
|
+ _detections = null;
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @override
|
|
|
+ Widget build(BuildContext context) {
|
|
|
+ if (_errorMessage != null) {
|
|
|
+ return Scaffold(
|
|
|
+ appBar: AppBar(title: const Text('Capture & Analyze')),
|
|
|
+ body: Center(
|
|
|
+ child: Column(
|
|
|
+ mainAxisAlignment: MainAxisAlignment.center,
|
|
|
+ children: [
|
|
|
+ Text(_errorMessage!, style: const TextStyle(color: Colors.red)),
|
|
|
+ const SizedBox(height: 16),
|
|
|
+ ElevatedButton(onPressed: _initializeCamera, child: const Text("Retry")),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!_isInitialized || _controller == null) {
|
|
|
+ return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
|
|
+ }
|
|
|
+
|
|
|
+ return Scaffold(
|
|
|
+ backgroundColor: Colors.black,
|
|
|
+ body: Stack(
|
|
|
+ children: [
|
|
|
+ // Camera Preview or Captured Image
|
|
|
+ Center(
|
|
|
+ child: _capturedPhoto != null && _detections != null
|
|
|
+ ? AspectRatio(
|
|
|
+ aspectRatio: _controller!.value.aspectRatio,
|
|
|
+ child: Stack(
|
|
|
+ children: [
|
|
|
+ Image.file(File(_capturedPhoto!.path), fit: BoxFit.cover, width: double.infinity),
|
|
|
+ Positioned.fill(
|
|
|
+ child: LayoutBuilder(
|
|
|
+ builder: (context, constraints) {
|
|
|
+ return Stack(
|
|
|
+ children: _detections!
|
|
|
+ .map((d) => _buildBoundingBox(d, constraints))
|
|
|
+ .toList(),
|
|
|
+ );
|
|
|
+ },
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ : CameraPreview(_controller!),
|
|
|
+ ),
|
|
|
+
|
|
|
+ // Top UI
|
|
|
+ Positioned(
|
|
|
+ top: 40,
|
|
|
+ left: 20,
|
|
|
+ right: 20,
|
|
|
+ child: Row(
|
|
|
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
+ children: [
|
|
|
+ CircleAvatar(
|
|
|
+ backgroundColor: Colors.black54,
|
|
|
+ child: IconButton(
|
|
|
+ icon: const Icon(Icons.arrow_back, color: Colors.white),
|
|
|
+ onPressed: () => Navigator.pop(context),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ if (_capturedPhoto != null)
|
|
|
+ CircleAvatar(
|
|
|
+ backgroundColor: Colors.black54,
|
|
|
+ child: IconButton(
|
|
|
+ icon: const Icon(Icons.refresh, color: Colors.white),
|
|
|
+ onPressed: () => setState(() {
|
|
|
+ _capturedPhoto = null;
|
|
|
+ _detections = null;
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+
|
|
|
+ // Capture Button
|
|
|
+ if (_capturedPhoto == null && !_isAnalyzing)
|
|
|
+ Align(
|
|
|
+ alignment: Alignment.bottomCenter,
|
|
|
+ child: Padding(
|
|
|
+ padding: const EdgeInsets.only(bottom: 48.0),
|
|
|
+ child: GestureDetector(
|
|
|
+ onTap: _takeAndAnalyze,
|
|
|
+ child: Container(
|
|
|
+ height: 80,
|
|
|
+ width: 80,
|
|
|
+ padding: const EdgeInsets.all(4),
|
|
|
+ decoration: BoxDecoration(
|
|
|
+ shape: BoxShape.circle,
|
|
|
+ border: Border.all(color: Colors.white, width: 4),
|
|
|
+ ),
|
|
|
+ child: Container(
|
|
|
+ decoration: const BoxDecoration(
|
|
|
+ color: Colors.white,
|
|
|
+ shape: BoxShape.circle,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+
|
|
|
+ // Analyzing Overlay
|
|
|
+ if (_isAnalyzing) _buildLoadingOverlay(),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ Widget _buildBoundingBox(DetectionResult detection, BoxConstraints constraints) {
|
|
|
+ final rect = detection.normalizedBox;
|
|
|
+ final color = detection.getStatusColor();
|
|
|
+
|
|
|
+ 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: 3),
|
|
|
+ borderRadius: BorderRadius.circular(4),
|
|
|
+ ),
|
|
|
+ child: Align(
|
|
|
+ alignment: Alignment.topLeft,
|
|
|
+ child: Container(
|
|
|
+ padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
|
|
+ color: color,
|
|
|
+ child: Text(
|
|
|
+ "${detection.className} ${(detection.confidence * 100).toStringAsFixed(0)}%",
|
|
|
+ style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ Widget _buildLoadingOverlay() {
|
|
|
+ return Positioned.fill(
|
|
|
+ child: BackdropFilter(
|
|
|
+ filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
|
|
|
+ child: Container(
|
|
|
+ color: Colors.black.withOpacity(0.5),
|
|
|
+ child: const Center(
|
|
|
+ child: Column(
|
|
|
+ mainAxisSize: MainAxisSize.min,
|
|
|
+ children: [
|
|
|
+ CircularProgressIndicator(color: Colors.white, strokeWidth: 6),
|
|
|
+ SizedBox(height: 24),
|
|
|
+ Text(
|
|
|
+ "AI is Analyzing...",
|
|
|
+ style: TextStyle(color: Colors.white, fontSize: 22, fontWeight: FontWeight.bold),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ @override
|
|
|
+ void dispose() {
|
|
|
+ _controller?.dispose();
|
|
|
+ _tfliteService.dispose();
|
|
|
+ super.dispose();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+class _ResultSheet extends StatelessWidget {
|
|
|
+ final PalmRecord record;
|
|
|
+ const _ResultSheet({required this.record});
|
|
|
+
|
|
|
+ @override
|
|
|
+ Widget build(BuildContext context) {
|
|
|
+ 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);
|
|
|
+ }
|
|
|
+
|
|
|
+ final bool isWarning = record.ripenessClass == 'Unripe' || record.ripenessClass == 'Underripe';
|
|
|
+
|
|
|
+ return Container(
|
|
|
+ decoration: const BoxDecoration(
|
|
|
+ color: Colors.white,
|
|
|
+ borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
|
|
+ ),
|
|
|
+ padding: const EdgeInsets.all(24),
|
|
|
+ child: Column(
|
|
|
+ mainAxisSize: MainAxisSize.min,
|
|
|
+ children: [
|
|
|
+ Container(
|
|
|
+ width: 40,
|
|
|
+ height: 4,
|
|
|
+ decoration: BoxDecoration(color: Colors.grey[300], borderRadius: BorderRadius.circular(2)),
|
|
|
+ ),
|
|
|
+ const SizedBox(height: 24),
|
|
|
+ Row(
|
|
|
+ children: [
|
|
|
+ Icon(Icons.analytics, color: statusColor, size: 32),
|
|
|
+ const SizedBox(width: 12),
|
|
|
+ const Text("Analysis Result", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ const SizedBox(height: 24),
|
|
|
+ _buildInfoRow("Ripeness", record.ripenessClass, statusColor),
|
|
|
+ _buildInfoRow("Confidence", "${(record.confidence * 100).toStringAsFixed(1)}%", Colors.grey[700]!),
|
|
|
+
|
|
|
+ if (record.ripenessClass == 'Empty_Bunch' || record.ripenessClass == 'Abnormal')
|
|
|
+ _buildAlertCard("HEALTH ALERT", "Abnormal features detected. Check palm health.", Colors.red),
|
|
|
+
|
|
|
+ if (isWarning)
|
|
|
+ _buildAlertCard("YIELD WARNING", "Potential Yield Loss due to premature harvest.", Colors.orange),
|
|
|
+
|
|
|
+ const SizedBox(height: 32),
|
|
|
+ SizedBox(
|
|
|
+ width: double.infinity,
|
|
|
+ child: ElevatedButton(
|
|
|
+ onPressed: () => Navigator.pop(context),
|
|
|
+ style: ElevatedButton.styleFrom(
|
|
|
+ backgroundColor: statusColor,
|
|
|
+ foregroundColor: Colors.white,
|
|
|
+ padding: const EdgeInsets.symmetric(vertical: 16),
|
|
|
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
|
+ ),
|
|
|
+ child: const Text("Acknowledge", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ const SizedBox(height: 12),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ Widget _buildInfoRow(String label, String value, Color color) {
|
|
|
+ return Padding(
|
|
|
+ padding: const EdgeInsets.symmetric(vertical: 8.0),
|
|
|
+ child: Row(
|
|
|
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
+ children: [
|
|
|
+ Text(label, style: const TextStyle(fontSize: 16, color: Colors.grey)),
|
|
|
+ Text(value, style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: color)),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ Widget _buildAlertCard(String title, String message, Color color) {
|
|
|
+ return Container(
|
|
|
+ margin: const EdgeInsets.only(top: 16),
|
|
|
+ padding: const EdgeInsets.all(16),
|
|
|
+ decoration: BoxDecoration(
|
|
|
+ color: color.withOpacity(0.1),
|
|
|
+ borderRadius: BorderRadius.circular(12),
|
|
|
+ border: Border.all(color: color.withOpacity(0.3)),
|
|
|
+ ),
|
|
|
+ child: Row(
|
|
|
+ children: [
|
|
|
+ Icon(Icons.warning_amber_rounded, color: color),
|
|
|
+ const SizedBox(width: 12),
|
|
|
+ Expanded(
|
|
|
+ child: Column(
|
|
|
+ crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
+ children: [
|
|
|
+ Text(title, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 14)),
|
|
|
+ Text(message, style: TextStyle(color: color.withOpacity(0.8), fontSize: 12)),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|