Dr-Swopt пре 1 недеља
родитељ
комит
cbb375850f

+ 5 - 9
.gitignore

@@ -1,13 +1,11 @@
-# Virtual Environments
+# Virtual Environments (Standard Python)
 venv/
 .venv/
 env/
-bin/
-lib/
-share/
-Include/
-Lib/
-Scripts/
+# These are often used by venv on Windows, but too broad in a multi-project repo
+# Include/
+# Lib/
+# Scripts/
 pyvenv.cfg
 
 # Python
@@ -16,8 +14,6 @@ __pycache__/
 *$py.class
 *.so
 .Python
-build/
-develop-eggs/
 dist/
 downloads/
 eggs/

+ 27 - 0
palm_oil_mobile/lib/main.dart

@@ -0,0 +1,27 @@
+import 'package:flutter/material.dart';
+import 'screens/home_screen.dart';
+
+void main() {
+  runApp(const PalmOilApp());
+}
+
+class PalmOilApp extends StatelessWidget {
+  const PalmOilApp({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return MaterialApp(
+      title: 'Palm Oil Ripeness AI',
+      debugShowCheckedModeBanner: false,
+      theme: ThemeData(
+        colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
+        useMaterial3: true,
+        appBarTheme: const AppBarTheme(
+          backgroundColor: Colors.green,
+          foregroundColor: Colors.white,
+        ),
+      ),
+      home: const HomeScreen(),
+    );
+  }
+}

+ 63 - 0
palm_oil_mobile/lib/models/palm_record.dart

@@ -0,0 +1,63 @@
+import 'dart:convert';
+
+class PalmRecord {
+  final int? id;
+  final String imagePath;
+  final String ripenessClass;
+  final double confidence;
+  final DateTime timestamp;
+  final double x1;
+  final double y1;
+  final double x2;
+  final double y2;
+  final List<Map<String, dynamic>> detections;
+
+  PalmRecord({
+    this.id,
+    required this.imagePath,
+    required this.ripenessClass,
+    required this.confidence,
+    required this.timestamp,
+    required this.x1,
+    required this.y1,
+    required this.x2,
+    required this.y2,
+    this.detections = const [],
+  });
+
+  Map<String, dynamic> toMap() {
+    return {
+      'id': id,
+      'image_path': imagePath,
+      'ripeness_class': ripenessClass,
+      'confidence': confidence,
+      'timestamp': timestamp.toIso8601String(),
+      'x1': x1,
+      'y1': y1,
+      'x2': x2,
+      'y2': y2,
+      'detections': json.encode(detections),
+    };
+  }
+
+  factory PalmRecord.fromMap(Map<String, dynamic> map) {
+    return PalmRecord(
+      id: map['id'],
+      imagePath: map['image_path'],
+      ripenessClass: map['ripeness_class'],
+      confidence: map['confidence'],
+      timestamp: DateTime.parse(map['timestamp']),
+      x1: map['x1'],
+      y1: map['y1'],
+      x2: map['x2'],
+      y2: map['y2'],
+      detections: map['detections'] != null 
+          ? List<Map<String, dynamic>>.from(json.decode(map['detections']))
+          : [],
+    );
+  }
+
+  String toJson() => json.encode(toMap());
+
+  factory PalmRecord.fromJson(String source) => PalmRecord.fromMap(json.decode(source));
+}

+ 414 - 0
palm_oil_mobile/lib/screens/analysis_screen.dart

@@ -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,
+                    ),
+                  ],
+                ),
+              ),
+            ),
+          ],
+        );
+      },
+    );
+  }
+}

+ 295 - 0
palm_oil_mobile/lib/screens/history_screen.dart

@@ -0,0 +1,295 @@
+import 'dart:io';
+import 'dart:ui';
+import 'package:flutter/material.dart';
+import 'package:path/path.dart' as p;
+import '../services/database_helper.dart';
+import '../models/palm_record.dart';
+
+/// Simple detection data parsed from the stored Map in the database.
+class _DetectionData {
+  final String className;
+  final double confidence;
+  final Rect normalizedBox;
+
+  const _DetectionData({
+    required this.className,
+    required this.confidence,
+    required this.normalizedBox,
+  });
+
+  factory _DetectionData.fromMap(Map<String, dynamic> m) {
+    return _DetectionData(
+      className: (m['className'] ?? m['class'] ?? 'Unknown').toString(),
+      confidence: (m['confidence'] as num?)?.toDouble() ?? 0.0,
+      normalizedBox: Rect.fromLTRB(
+        (m['x1'] as num?)?.toDouble() ?? 0.0,
+        (m['y1'] as num?)?.toDouble() ?? 0.0,
+        (m['x2'] as num?)?.toDouble() ?? 1.0,
+        (m['y2'] as num?)?.toDouble() ?? 1.0,
+      ),
+    );
+  }
+}
+
+class HistoryScreen extends StatefulWidget {
+  const HistoryScreen({super.key});
+
+  @override
+  State<HistoryScreen> createState() => _HistoryScreenState();
+}
+
+class _HistoryScreenState extends State<HistoryScreen> {
+  final DatabaseHelper _dbHelper = DatabaseHelper();
+  List<PalmRecord> _records = [];
+  bool _isLoading = true;
+
+  @override
+  void initState() {
+    super.initState();
+    _loadHistory();
+  }
+
+  Future<void> _loadHistory() async {
+    final records = await _dbHelper.getAllRecords();
+    setState(() {
+      _records = records;
+      _isLoading = false;
+    });
+  }
+
+  Future<void> _resetHistory() async {
+    final confirm = await showDialog<bool>(
+      context: context,
+      builder: (context) => AlertDialog(
+        title: const Text("Reset History"),
+        content: const Text("Are you sure you want to clear all analysis records? This action is irreversible."),
+        actions: [
+          TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("Cancel")),
+          TextButton(
+            onPressed: () => Navigator.pop(context, true),
+            child: const Text("Reset", style: TextStyle(color: Colors.red)),
+          ),
+        ],
+      ),
+    );
+
+    if (confirm == true) {
+      await _dbHelper.clearAllRecords();
+      _loadHistory();
+      if (mounted) {
+        ScaffoldMessenger.of(context).showSnackBar(
+          const SnackBar(content: Text("History cleared successfully.")),
+        );
+      }
+    }
+  }
+
+  Color _getStatusColor(String label) {
+    if (label == 'Empty_Bunch' || label == 'Abnormal') return Colors.red;
+    if (label == 'Ripe' || label == 'Overripe') return Colors.green;
+    return Colors.orange;
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      appBar: AppBar(
+        title: const Text('History Vault'),
+        actions: [
+          if (_records.isNotEmpty)
+            IconButton(
+              icon: const Icon(Icons.delete_sweep),
+              tooltip: 'Reset History',
+              onPressed: _resetHistory,
+            ),
+        ],
+      ),
+      body: _isLoading
+          ? const Center(child: CircularProgressIndicator())
+          : _records.isEmpty
+              ? const Center(child: Text("No records found yet."))
+              : ListView.builder(
+                  itemCount: _records.length,
+                  padding: const EdgeInsets.all(12),
+                  itemBuilder: (context, index) {
+                    final record = _records[index];
+                    final statusColor = _getStatusColor(record.ripenessClass);
+                    final fileName = p.basename(record.imagePath);
+                    
+                    return Card(
+                      margin: const EdgeInsets.only(bottom: 12),
+                      elevation: 3,
+                      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
+                      child: ListTile(
+                        leading: CircleAvatar(
+                          backgroundColor: statusColor.withValues(alpha: 0.2),
+                          child: Icon(Icons.spa, color: statusColor),
+                        ),
+                        title: Text(
+                          record.ripenessClass,
+                          style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
+                        ),
+                        subtitle: Text(
+                          "File: $fileName\nConf: ${(record.confidence * 100).toStringAsFixed(1)}% | ${record.timestamp.toString().split('.')[0]}",
+                          style: const TextStyle(fontSize: 12),
+                        ),
+                        trailing: const Icon(Icons.chevron_right),
+                        isThreeLine: true,
+                        onTap: () => _showDetails(record),
+                      ),
+                    );
+                  },
+                ),
+    );
+  }
+
+  void _showDetails(PalmRecord record) {
+    final fileName = p.basename(record.imagePath);
+    final List<_DetectionData> detections = record.detections
+        .map((d) => _DetectionData.fromMap(d))
+        .toList();
+
+    showModalBottomSheet(
+      context: context,
+      isScrollControlled: true,
+      shape: const RoundedRectangleBorder(
+        borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
+      ),
+      builder: (context) {
+        return DraggableScrollableSheet(
+          initialChildSize: 0.7,
+          minChildSize: 0.5,
+          maxChildSize: 0.95,
+          expand: false,
+          builder: (context, scrollController) {
+            return SingleChildScrollView(
+              controller: scrollController,
+              padding: const EdgeInsets.all(24),
+              child: Column(
+                crossAxisAlignment: CrossAxisAlignment.start,
+                children: [
+                  Row(
+                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                    children: [
+                      const Text("Record Details", style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold)),
+                      IconButton(onPressed: () => Navigator.pop(context), icon: const Icon(Icons.close)),
+                    ],
+                  ),
+                  const Divider(),
+                  const SizedBox(height: 16),
+                  
+                  // Image with Bounding Boxes
+                  AspectRatio(
+                    aspectRatio: 1, // Assuming squared input 640x640
+                    child: Container(
+                      decoration: BoxDecoration(
+                        color: Colors.grey.shade200,
+                        borderRadius: BorderRadius.circular(12),
+                      ),
+                      child: record.imagePath.isNotEmpty && File(record.imagePath).existsSync()
+                          ? ClipRRect(
+                              borderRadius: BorderRadius.circular(12),
+                              child: Stack(
+                                children: [
+                                  // The Image
+                                  Positioned.fill(
+                                    child: Image.file(File(record.imagePath), fit: BoxFit.contain),
+                                  ),
+                                  // The Overlays
+                                  Positioned.fill(
+                                    child: LayoutBuilder(
+                                      builder: (context, constraints) {
+                                        return Stack(
+                                          children: detections.map<Widget>((d) {
+                                            final rect = d.normalizedBox;
+                                            final color = _getStatusColor(d.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: 2),
+                                                  borderRadius: BorderRadius.circular(4),
+                                                ),
+                                              ),
+                                            );
+                                          }).toList(),
+                                        );
+                                      },
+                                    ),
+                                  ),
+                                ],
+                              ),
+                            )
+                          : const Center(
+                              child: Icon(Icons.image, size: 64, color: Colors.grey),
+                            ),
+                    ),
+                  ),
+                  
+                  const SizedBox(height: 24),
+                  _buildDetailItem("File Name", fileName, Colors.blueGrey),
+                  _buildDetailItem("Primary Grade", record.ripenessClass, _getStatusColor(record.ripenessClass)),
+                  _buildDetailItem("Total Detections", detections.length.toString(), Colors.black),
+                  _buildDetailItem("Timestamp", record.timestamp.toString().split('.')[0], Colors.black87),
+                  
+                  const Divider(height: 32),
+                  const Text("Analytical Breakdown", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
+                  const SizedBox(height: 12),
+                  
+                  ...detections.map((d) => Padding(
+                    padding: const EdgeInsets.only(bottom: 8),
+                    child: Row(
+                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                      children: [
+                        Row(
+                          children: [
+                            Container(
+                              width: 12,
+                              height: 12,
+                              decoration: BoxDecoration(
+                                color: _getStatusColor(d.className),
+                                shape: BoxShape.circle,
+                              ),
+                            ),
+                            const SizedBox(width: 8),
+                            Text(d.className, style: const TextStyle(fontWeight: FontWeight.w500)),
+                          ],
+                        ),
+                        Text("${(d.confidence * 100).toStringAsFixed(1)}%", style: const TextStyle(color: Colors.grey)),
+                      ],
+                    ),
+                  )),
+                  
+                  const SizedBox(height: 24),
+                  const Text(
+                    "Industrial Summary: This image has been verified for harvest quality. All detected bunches are categorized according to ripeness standards.",
+                    style: TextStyle(fontStyle: FontStyle.italic, color: Colors.grey),
+                  ),
+                  const SizedBox(height: 24),
+                ],
+              ),
+            );
+          },
+        );
+      },
+    );
+  }
+
+  Widget _buildDetailItem(String label, String value, Color valueColor) {
+    return Padding(
+      padding: const EdgeInsets.only(bottom: 12),
+      child: Row(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          Text("$label: ", style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16)),
+          Expanded(
+            child: Text(value, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: valueColor)),
+          ),
+        ],
+      ),
+    );
+  }
+}

+ 123 - 0
palm_oil_mobile/lib/screens/home_screen.dart

@@ -0,0 +1,123 @@
+import 'package:flutter/material.dart';
+import 'analysis_screen.dart';
+import 'history_screen.dart';
+
+class HomeScreen extends StatelessWidget {
+  const HomeScreen({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      appBar: AppBar(
+        title: const Text('Palm Oil Ripeness AI'),
+        centerTitle: true,
+        elevation: 2,
+      ),
+      body: Container(
+        decoration: BoxDecoration(
+          gradient: LinearGradient(
+            begin: Alignment.topCenter,
+            end: Alignment.bottomCenter,
+            colors: [Colors.green.shade50, Colors.white],
+          ),
+        ),
+        child: Center(
+          child: Column(
+            mainAxisAlignment: MainAxisAlignment.center,
+            children: [
+              _buildNavCard(
+                context,
+                title: 'Analyze Gallery',
+                subtitle: 'Detect ripeness from photos',
+                icon: Icons.photo_library,
+                color: Colors.green,
+                onTap: () => Navigator.push(
+                  context,
+                  MaterialPageRoute(builder: (context) => const AnalysisScreen()),
+                ),
+              ),
+              const SizedBox(height: 24),
+              _buildNavCard(
+                context,
+                title: 'History Vault',
+                subtitle: 'View previous detections',
+                icon: Icons.history,
+                color: Colors.blue,
+                onTap: () => Navigator.push(
+                  context,
+                  MaterialPageRoute(builder: (context) => const HistoryScreen()),
+                ),
+              ),
+            ],
+          ),
+        ),
+      ),
+    );
+  }
+
+  Widget _buildNavCard(
+    BuildContext context, {
+    required String title,
+    required String subtitle,
+    required IconData icon,
+    required Color color,
+    required VoidCallback onTap,
+  }) {
+    return GestureDetector(
+      onTap: onTap,
+      child: Container(
+        width: MediaQuery.of(context).size.width * 0.85,
+        padding: const EdgeInsets.all(20),
+        decoration: BoxDecoration(
+          color: Colors.white,
+          borderRadius: BorderRadius.circular(20),
+          boxShadow: [
+            BoxShadow(
+              color: color.withValues(alpha: 0.1),
+              blurRadius: 15,
+              offset: const Offset(0, 8),
+            ),
+          ],
+          border: Border.all(color: color.withValues(alpha: 0.2), width: 1),
+        ),
+        child: Row(
+          children: [
+            Container(
+              padding: const EdgeInsets.all(12),
+              decoration: BoxDecoration(
+                color: color.withValues(alpha: 0.1),
+                borderRadius: BorderRadius.circular(15),
+              ),
+              child: Icon(icon, color: color, size: 30),
+            ),
+            const SizedBox(width: 20),
+            Expanded(
+              child: Column(
+                crossAxisAlignment: CrossAxisAlignment.start,
+                children: [
+                  Text(
+                    title,
+                    style: TextStyle(
+                      fontSize: 18,
+                      fontWeight: FontWeight.bold,
+                      color: Colors.grey.shade800,
+                    ),
+                  ),
+                  const SizedBox(height: 4),
+                  Text(
+                    subtitle,
+                    style: TextStyle(
+                      fontSize: 14,
+                      color: Colors.grey.shade600,
+                    ),
+                  ),
+                ],
+              ),
+            ),
+            Icon(Icons.arrow_forward_ios, color: Colors.grey.shade400, size: 16),
+          ],
+        ),
+      ),
+    );
+  }
+}

+ 73 - 0
palm_oil_mobile/lib/services/database_helper.dart

@@ -0,0 +1,73 @@
+import 'package:sqflite/sqflite.dart';
+import 'package:path/path.dart';
+import '../models/palm_record.dart';
+
+class DatabaseHelper {
+  static final DatabaseHelper _instance = DatabaseHelper._internal();
+  factory DatabaseHelper() => _instance;
+  DatabaseHelper._internal();
+
+  static Database? _database;
+
+  Future<Database> get database async {
+    if (_database != null) return _database!;
+    _database = await _initDatabase();
+    return _database!;
+  }
+
+  Future<Database> _initDatabase() async {
+    String path = join(await getDatabasesPath(), 'palm_oil_history.db');
+    return await openDatabase(
+      path,
+      version: 2,
+      onCreate: _onCreate,
+      onUpgrade: _onUpgrade,
+    );
+  }
+
+  Future<void> _onCreate(Database db, int version) async {
+    await db.execute('''
+      CREATE TABLE history (
+        id INTEGER PRIMARY KEY AUTOINCREMENT,
+        image_path TEXT,
+        ripeness_class TEXT,
+        confidence REAL,
+        timestamp TEXT,
+        x1 REAL,
+        y1 REAL,
+        x2 REAL,
+        y2 REAL,
+        detections TEXT
+      )
+    ''');
+  }
+
+  Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
+    if (oldVersion < 2) {
+      await db.execute('ALTER TABLE history ADD COLUMN detections TEXT');
+    }
+  }
+
+  Future<int> insertRecord(PalmRecord record) async {
+    Database db = await database;
+    return await db.insert('history', record.toMap());
+  }
+
+  Future<List<PalmRecord>> getAllRecords() async {
+    Database db = await database;
+    List<Map<String, dynamic>> maps = await db.query('history', orderBy: 'timestamp DESC');
+    return List.generate(maps.length, (i) {
+      return PalmRecord.fromMap(maps[i]);
+    });
+  }
+
+  Future<int> deleteRecord(int id) async {
+    Database db = await database;
+    return await db.delete('history', where: 'id = ?', whereArgs: [id]);
+  }
+
+  Future<int> clearAllRecords() async {
+    Database db = await database;
+    return await db.delete('history');
+  }
+}

+ 180 - 0
palm_oil_mobile/lib/services/tflite_service.dart

@@ -0,0 +1,180 @@
+import 'dart:io';
+import 'dart:math';
+import 'dart:ui';
+import 'package:flutter/services.dart';
+import 'package:flutter/foundation.dart';
+import 'package:image/image.dart' as img;
+import 'package:image_picker/image_picker.dart';
+import 'package:tflite_flutter/tflite_flutter.dart';
+
+/// A detection result parsed from the model's end-to-end output.
+class DetectionResult {
+  final String className;
+  final int classIndex;
+  final double confidence;
+  /// Normalized bounding box (0.0 - 1.0)
+  final Rect normalizedBox;
+
+  const DetectionResult({
+    required this.className,
+    required this.classIndex,
+    required this.confidence,
+    required this.normalizedBox,
+  });
+}
+
+/// Custom TFLite inference service that correctly decodes the end-to-end
+/// YOLO model output format [1, N, 6] = [batch, detections, (x1,y1,x2,y2,conf,class_id)].
+class TfliteService {
+  static const _modelAsset = 'best.tflite';
+  static const _labelsAsset = 'labels.txt';
+  static const int _inputSize = 640;
+  static const double _confidenceThreshold = 0.25;
+
+  Interpreter? _interpreter;
+  List<String> _labels = [];
+  final ImagePicker _picker = ImagePicker();
+  bool _isInitialized = false;
+
+  bool get isInitialized => _isInitialized;
+
+  Future<void> initModel() async {
+    try {
+      // Load labels
+      final labelData = await rootBundle.loadString('assets/$_labelsAsset');
+      _labels = labelData.split('\n').where((l) => l.trim().isNotEmpty).map((l) => l.trim()).toList();
+
+      // Load model
+      final interpreterOptions = InterpreterOptions()..threads = 4;
+      _interpreter = await Interpreter.fromAsset(
+        'assets/$_modelAsset',
+        options: interpreterOptions,
+      );
+
+      _isInitialized = true;
+      print('TfliteService: Model loaded. Labels: $_labels');
+      print('TfliteService: Input: ${_interpreter!.getInputTensors().map((t) => t.shape)}');
+      print('TfliteService: Output: ${_interpreter!.getOutputTensors().map((t) => t.shape)}');
+    } catch (e) {
+      print('TfliteService init error: $e');
+      rethrow;
+    }
+  }
+
+  Future<XFile?> pickImage() async {
+    return await _picker.pickImage(
+      source: ImageSource.gallery,
+      maxWidth: _inputSize.toDouble(),
+      maxHeight: _inputSize.toDouble(),
+    );
+  }
+
+  /// Run inference on the image at [imagePath].
+  /// Returns a list of [DetectionResult] sorted by confidence descending.
+  /// Offloaded to a background isolate to keep UI smooth.
+  Future<List<DetectionResult>> runInference(String imagePath) async {
+    if (!_isInitialized) await initModel();
+
+    final imageBytes = await File(imagePath).readAsBytes();
+    
+    // We pass the raw bytes and asset paths to the isolate.
+    // The isolate will handle decoding, resizing, and inference.
+    return await _runInferenceInIsolate(imageBytes);
+  }
+
+  Future<List<DetectionResult>> _runInferenceInIsolate(Uint8List imageBytes) async {
+    // We need the model and labels passed as data
+    final modelData = await rootBundle.load('assets/$_modelAsset');
+    final labelData = await rootBundle.loadString('assets/$_labelsAsset');
+    
+    // Use compute to run in a real isolate
+    return await compute(_inferenceTaskWrapper, {
+      'imageBytes': imageBytes,
+      'modelBytes': modelData.buffer.asUint8List(),
+      'labelData': labelData,
+    });
+  }
+
+  static List<DetectionResult> _inferenceTaskWrapper(Map<String, dynamic> args) {
+    return _inferenceTask(
+      args['imageBytes'] as Uint8List,
+      args['modelBytes'] as Uint8List,
+      args['labelData'] as String,
+    );
+  }
+
+  /// The static task that runs in the background isolate
+  static List<DetectionResult> _inferenceTask(Uint8List imageBytes, Uint8List modelBytes, String labelData) {
+    // 1. Initialize Interpreter inside the isolate
+    final interpreter = Interpreter.fromBuffer(modelBytes);
+    final labels = labelData.split('\n').where((l) => l.trim().isNotEmpty).map((l) => l.trim()).toList();
+
+    try {
+      // 2. Preprocess image
+      final decoded = img.decodeImage(imageBytes);
+      if (decoded == null) throw Exception('Could not decode image');
+
+      final resized = img.copyResize(decoded, width: _inputSize, height: _inputSize, interpolation: img.Interpolation.linear);
+
+      final inputTensor = List.generate(1, (_) =>
+        List.generate(_inputSize, (y) =>
+          List.generate(_inputSize, (x) {
+            final pixel = resized.getPixel(x, y);
+            return [pixel.r / 255.0, pixel.g / 255.0, pixel.b / 255.0];
+          })
+        )
+      );
+
+      // 3. Prepare output
+      final outputShape = interpreter.getOutputTensors()[0].shape;
+      final numDetections = outputShape[1];
+      final numFields = outputShape[2];
+      final outputTensor = List.generate(1, (_) =>
+        List.generate(numDetections, (_) =>
+          List<double>.filled(numFields, 0.0)
+        )
+      );
+
+      // 4. Run
+      interpreter.run(inputTensor, outputTensor);
+
+      // 5. Decode
+      final detections = <DetectionResult>[];
+      final rawDetections = outputTensor[0];
+
+      for (final det in rawDetections) {
+        if (det.length < 6) continue;
+        final conf = det[4];
+        if (conf < _confidenceThreshold) continue;
+
+        final x1 = det[0].clamp(0.0, 1.0);
+        final y1 = det[1].clamp(0.0, 1.0);
+        final x2 = det[2].clamp(0.0, 1.0);
+        final y2 = det[3].clamp(0.0, 1.0);
+        final classId = det[5].round();
+
+        if (x2 <= x1 || y2 <= y1) continue;
+
+        final label = (classId >= 0 && classId < labels.length) ? labels[classId] : 'Unknown';
+
+        detections.add(DetectionResult(
+          className: label,
+          classIndex: classId,
+          confidence: conf,
+          normalizedBox: Rect.fromLTRB(x1, y1, x2, y2),
+        ));
+      }
+
+      detections.sort((a, b) => b.confidence.compareTo(a.confidence));
+      return detections;
+    } finally {
+      interpreter.close();
+    }
+  }
+
+  void dispose() {
+    _interpreter?.close();
+    _interpreter = null;
+    _isInitialized = false;
+  }
+}

+ 56 - 0
palm_oil_mobile/lib/services/yolo_service.dart

@@ -0,0 +1,56 @@
+import 'dart:typed_data';
+import 'dart:io';
+import 'package:image_picker/image_picker.dart';
+import 'package:ultralytics_yolo/ultralytics_yolo.dart';
+
+class YoloService {
+  late YOLO _yolo;
+  final ImagePicker _picker = ImagePicker();
+  bool _isInitialized = false;
+
+  Future<void> initModel() async {
+    try {
+      _yolo = YOLO(
+        modelPath: 'best.tflite',
+        task: YOLOTask.detect,
+        useGpu: false, // Disabling GPU for better stability on budget devices
+      );
+      final success = await _yolo.loadModel();
+      if (!success) {
+        throw Exception("Model failed to load.");
+      }
+      _isInitialized = true;
+    } catch (e) {
+      print("YOLO Init Error: $e");
+      rethrow;
+    }
+  }
+
+  Future<XFile?> pickImage() async {
+    return await _picker.pickImage(
+      source: ImageSource.gallery,
+      maxWidth: 640,
+      maxHeight: 640,
+    );
+  }
+
+  Future<List<dynamic>?> runInference(String imagePath) async {
+    if (!_isInitialized) {
+      await initModel();
+    }
+    
+    try {
+      final File imageFile = File(imagePath);
+      final Uint8List bytes = await imageFile.readAsBytes();
+      final results = await _yolo.predict(bytes);
+      return results['detections'] as List<dynamic>?;
+    } catch (e) {
+      print("YOLO Inference Error: $e");
+      rethrow;
+    }
+  }
+
+  void dispose() {
+    // No explicit close on YOLO class
+  }
+}