|
|
@@ -0,0 +1,414 @@
|
|
|
+import 'dart:io';
|
|
|
+import 'dart:ui';
|
|
|
+import 'package:flutter/material.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 AnalysisScreen extends StatefulWidget {
|
|
|
+ const AnalysisScreen({super.key});
|
|
|
+
|
|
|
+ @override
|
|
|
+ State<AnalysisScreen> createState() => _AnalysisScreenState();
|
|
|
+}
|
|
|
+
|
|
|
+class _AnalysisScreenState extends State<AnalysisScreen> with SingleTickerProviderStateMixin {
|
|
|
+ final TfliteService _tfliteService = TfliteService();
|
|
|
+ final DatabaseHelper _dbHelper = DatabaseHelper();
|
|
|
+
|
|
|
+ late AnimationController _scanningController;
|
|
|
+ bool _isAnalyzing = false;
|
|
|
+ File? _selectedImage;
|
|
|
+ List<DetectionResult>? _detections;
|
|
|
+ String? _errorMessage;
|
|
|
+
|
|
|
+ @override
|
|
|
+ void initState() {
|
|
|
+ super.initState();
|
|
|
+ _scanningController = AnimationController(
|
|
|
+ vsync: this,
|
|
|
+ duration: const Duration(seconds: 2),
|
|
|
+ );
|
|
|
+ _initModel();
|
|
|
+ }
|
|
|
+
|
|
|
+ Future<void> _initModel() async {
|
|
|
+ try {
|
|
|
+ await _tfliteService.initModel();
|
|
|
+ } catch (e) {
|
|
|
+ if (mounted) {
|
|
|
+ setState(() {
|
|
|
+ _errorMessage = "Failed to initialize AI model: $e";
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ Future<void> _processGalleryImage() async {
|
|
|
+ final image = await _tfliteService.pickImage();
|
|
|
+ if (image == null) return;
|
|
|
+
|
|
|
+ if (mounted) {
|
|
|
+ setState(() {
|
|
|
+ _isAnalyzing = true;
|
|
|
+ _selectedImage = File(image.path);
|
|
|
+ _detections = null;
|
|
|
+ _errorMessage = null;
|
|
|
+ });
|
|
|
+ _scanningController.repeat(reverse: true);
|
|
|
+ // Give the UI a frame to paint the loading state
|
|
|
+ await Future.delayed(const Duration(milliseconds: 50));
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 1. Run Inference
|
|
|
+ final detections = await _tfliteService.runInference(image.path);
|
|
|
+
|
|
|
+ if (detections.isNotEmpty) {
|
|
|
+ // 2. Persist Image to Documents Directory
|
|
|
+ final appDocDir = await getApplicationDocumentsDirectory();
|
|
|
+ final fileName = p.basename(image.path);
|
|
|
+ final persistentPath = p.join(appDocDir.path, 'palm_${DateTime.now().millisecondsSinceEpoch}_$fileName');
|
|
|
+ await File(image.path).copy(persistentPath);
|
|
|
+
|
|
|
+ // 3. Update UI
|
|
|
+ if (mounted) {
|
|
|
+ setState(() {
|
|
|
+ _detections = detections;
|
|
|
+ _selectedImage = File(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);
|
|
|
+ } else {
|
|
|
+ if (mounted) {
|
|
|
+ setState(() {
|
|
|
+ _errorMessage = "No palm bunches detected.";
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ if (mounted) {
|
|
|
+ setState(() {
|
|
|
+ _errorMessage = "Analysis error: $e";
|
|
|
+ });
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ if (mounted) {
|
|
|
+ setState(() {
|
|
|
+ _isAnalyzing = false;
|
|
|
+ });
|
|
|
+ _scanningController.stop();
|
|
|
+ _scanningController.reset();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ bool _isHealthAlert(String label) {
|
|
|
+ return label == 'Empty_Bunch' || label == 'Abnormal';
|
|
|
+ }
|
|
|
+
|
|
|
+ @override
|
|
|
+ Widget build(BuildContext context) {
|
|
|
+ return Scaffold(
|
|
|
+ appBar: AppBar(title: const Text('AI Analysis')),
|
|
|
+ body: Stack(
|
|
|
+ children: [
|
|
|
+ Column(
|
|
|
+ children: [
|
|
|
+ Expanded(
|
|
|
+ child: Center(
|
|
|
+ child: _selectedImage == null
|
|
|
+ ? const Text("Select a photo to start AI analysis")
|
|
|
+ : AspectRatio(
|
|
|
+ aspectRatio: 1.0,
|
|
|
+ child: Padding(
|
|
|
+ padding: const EdgeInsets.all(8.0),
|
|
|
+ child: Stack(
|
|
|
+ children: [
|
|
|
+ // Main Image
|
|
|
+ ClipRRect(
|
|
|
+ borderRadius: BorderRadius.circular(12),
|
|
|
+ child: Image.file(
|
|
|
+ _selectedImage!,
|
|
|
+ fit: BoxFit.contain,
|
|
|
+ width: double.infinity,
|
|
|
+ height: double.infinity,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ // Bounding Box Overlays
|
|
|
+ if (_detections != null)
|
|
|
+ Positioned.fill(
|
|
|
+ child: LayoutBuilder(
|
|
|
+ builder: (context, constraints) {
|
|
|
+ return Stack(
|
|
|
+ children: _detections!
|
|
|
+ .map((d) => _buildBoundingBox(d, constraints))
|
|
|
+ .toList(),
|
|
|
+ );
|
|
|
+ },
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ // Scanning Animation
|
|
|
+ if (_isAnalyzing)
|
|
|
+ _ScanningOverlay(animation: _scanningController),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ if (_detections != null) _buildResultSummary(),
|
|
|
+ Padding(
|
|
|
+ padding: const EdgeInsets.all(32.0),
|
|
|
+ child: ElevatedButton.icon(
|
|
|
+ onPressed: _isAnalyzing ? null : _processGalleryImage,
|
|
|
+ icon: const Icon(Icons.add_a_photo),
|
|
|
+ label: const Text("Pick Image from Gallery"),
|
|
|
+ style: ElevatedButton.styleFrom(
|
|
|
+ minimumSize: const Size(double.infinity, 54),
|
|
|
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ if (_isAnalyzing) _buildLoadingOverlay(),
|
|
|
+ if (_errorMessage != null) _buildErrorToast(),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ Widget _buildBoundingBox(DetectionResult detection, BoxConstraints constraints) {
|
|
|
+ final rect = detection.normalizedBox;
|
|
|
+ final color = _getStatusColor(detection.className);
|
|
|
+
|
|
|
+ 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),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ Color _getStatusColor(String label) {
|
|
|
+ if (label == 'Empty_Bunch' || label == 'Abnormal') return Colors.red;
|
|
|
+ if (label == 'Ripe' || label == 'Overripe') return Colors.green;
|
|
|
+ return Colors.orange;
|
|
|
+ }
|
|
|
+
|
|
|
+ Widget _buildResultSummary() {
|
|
|
+ final best = _detections!.first;
|
|
|
+ return Container(
|
|
|
+ padding: const EdgeInsets.all(16),
|
|
|
+ margin: const EdgeInsets.symmetric(horizontal: 16),
|
|
|
+ decoration: BoxDecoration(
|
|
|
+ color: Colors.white,
|
|
|
+ borderRadius: BorderRadius.circular(12),
|
|
|
+ boxShadow: [const BoxShadow(color: Colors.black12, blurRadius: 10)],
|
|
|
+ ),
|
|
|
+ child: Column(
|
|
|
+ children: [
|
|
|
+ Row(
|
|
|
+ mainAxisAlignment: MainAxisAlignment.center,
|
|
|
+ children: [
|
|
|
+ Icon(Icons.check_circle, color: _getStatusColor(best.className), size: 32),
|
|
|
+ const SizedBox(width: 12),
|
|
|
+ Text(
|
|
|
+ best.className,
|
|
|
+ style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ Text(
|
|
|
+ "Confidence: ${(best.confidence * 100).toStringAsFixed(1)}%",
|
|
|
+ style: const TextStyle(fontSize: 14, color: Colors.grey),
|
|
|
+ ),
|
|
|
+ if (_isHealthAlert(best.className))
|
|
|
+ const Padding(
|
|
|
+ padding: EdgeInsets.only(top: 8.0),
|
|
|
+ child: Text(
|
|
|
+ "HEALTH ALERT: Abnormal detected!",
|
|
|
+ style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ if (_detections!.length > 1)
|
|
|
+ Padding(
|
|
|
+ padding: const EdgeInsets.only(top: 8.0),
|
|
|
+ child: Text(
|
|
|
+ "${_detections!.length} bunches detected",
|
|
|
+ style: const TextStyle(fontSize: 13, color: Colors.grey),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ Widget _buildLoadingOverlay() {
|
|
|
+ return Positioned.fill(
|
|
|
+ child: BackdropFilter(
|
|
|
+ filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
|
|
|
+ child: Container(
|
|
|
+ color: Colors.black.withValues(alpha: 0.3),
|
|
|
+ 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,
|
|
|
+ letterSpacing: 1.2,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ Text(
|
|
|
+ "Optimizing detection parameters",
|
|
|
+ style: TextStyle(color: Colors.white70, fontSize: 14),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ Widget _buildErrorToast() {
|
|
|
+ return Positioned(
|
|
|
+ bottom: 20,
|
|
|
+ left: 20,
|
|
|
+ right: 20,
|
|
|
+ child: Container(
|
|
|
+ padding: const EdgeInsets.all(16),
|
|
|
+ decoration: BoxDecoration(
|
|
|
+ color: Colors.red.shade800,
|
|
|
+ borderRadius: BorderRadius.circular(12),
|
|
|
+ boxShadow: [const BoxShadow(color: Colors.black26, blurRadius: 8)],
|
|
|
+ ),
|
|
|
+ child: Column(
|
|
|
+ mainAxisSize: MainAxisSize.min,
|
|
|
+ crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
+ children: [
|
|
|
+ const Row(
|
|
|
+ children: [
|
|
|
+ Icon(Icons.error_outline, color: Colors.white),
|
|
|
+ SizedBox(width: 8),
|
|
|
+ Text("Error Details", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ const SizedBox(height: 8),
|
|
|
+ Text(
|
|
|
+ _errorMessage!,
|
|
|
+ style: const TextStyle(color: Colors.white, fontSize: 12),
|
|
|
+ ),
|
|
|
+ const SizedBox(height: 8),
|
|
|
+ TextButton(
|
|
|
+ onPressed: () => setState(() => _errorMessage = null),
|
|
|
+ child: const Text("Dismiss", style: TextStyle(color: Colors.white)),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ @override
|
|
|
+ void dispose() {
|
|
|
+ _scanningController.dispose();
|
|
|
+ _tfliteService.dispose();
|
|
|
+ super.dispose();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+class _ScanningOverlay extends StatelessWidget {
|
|
|
+ final Animation<double> animation;
|
|
|
+
|
|
|
+ const _ScanningOverlay({required this.animation});
|
|
|
+
|
|
|
+ @override
|
|
|
+ Widget build(BuildContext context) {
|
|
|
+ return AnimatedBuilder(
|
|
|
+ animation: animation,
|
|
|
+ builder: (context, child) {
|
|
|
+ return Stack(
|
|
|
+ children: [
|
|
|
+ Positioned(
|
|
|
+ top: animation.value * MediaQuery.of(context).size.width, // Rough estimation since AspectRatio is 1.0
|
|
|
+ left: 0,
|
|
|
+ right: 0,
|
|
|
+ child: Container(
|
|
|
+ height: 4,
|
|
|
+ decoration: BoxDecoration(
|
|
|
+ gradient: LinearGradient(
|
|
|
+ colors: [
|
|
|
+ Colors.transparent,
|
|
|
+ Colors.greenAccent.withValues(alpha: 0.8),
|
|
|
+ Colors.transparent,
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ boxShadow: [
|
|
|
+ BoxShadow(
|
|
|
+ color: Colors.greenAccent.withValues(alpha: 0.6),
|
|
|
+ blurRadius: 15,
|
|
|
+ spreadRadius: 2,
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ );
|
|
|
+ },
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|