MIF_E31222656/lib/screens/image_processing/plant_scanner_screen.dart

1768 lines
62 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:image_picker/image_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:tugas_akhir_supabase/core/theme/app_colors.dart';
import 'package:tugas_akhir_supabase/screens/community/models/message.dart';
import 'package:tugas_akhir_supabase/screens/community/services/message_service.dart';
import 'package:tugas_akhir_supabase/services/gemini_disease_service.dart';
import 'package:tugas_akhir_supabase/data/models/diagnosis_result_model.dart';
import 'package:tugas_akhir_supabase/utils/pdf_generator.dart';
import 'package:image/image.dart' as img;
// API key for Gemini (in a real app, should be stored securely)
const String GEMINI_API_KEY = 'AIzaSyDEH7z1IoTZ3J10EProCSTSetTMDqbBXn4';
// App colors
class PlantScannerColors {
static final primary = Color(0xFF0A8754);
static final secondary = Color(0xFF39B686);
static final accent = Color(0xFF2C7873);
static final background = Color(0xFFF5F9F6);
static final cardBackground = Colors.white;
static final error = Color(0xFFD83A3A);
static final warning = Color(0xFFFF9800);
static final success = Color(0xFF4CAF50);
static final lightGreen = Color(0xFFE8F5E9);
static final darkText = Color(0xFF2C3333);
static final lightText = Color(0xFF6B7280);
static final disabledText = Color(0xFFAEB0B6);
static final divider = Color(0xFFEAECF0);
}
// Application states
enum ScanState { empty, loading, result, error, notPlant }
class PlantScannerScreen extends StatefulWidget {
const PlantScannerScreen({super.key});
@override
State<PlantScannerScreen> createState() => _PlantScannerScreenState();
}
class _PlantScannerScreenState extends State<PlantScannerScreen>
with SingleTickerProviderStateMixin {
final _picker = ImagePicker();
File? _image;
Uint8List? _webImage;
ScanState _scanState = ScanState.empty;
String _errorMessage = '';
late AnimationController _animationController;
bool _isAnalyzing = false;
bool _isLoading = false;
// Gemini API service
late GeminiDiseaseDiagnosisService _geminiService;
DiagnosisResultModel? _diagnosisResult;
final MessageService _messageService = MessageService();
final List<Message> _messages = [];
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat();
// Initialize Gemini API service with error handling
try {
// Check if Supabase is initialized
_geminiService = GeminiDiseaseDiagnosisService(
apiKey: GEMINI_API_KEY,
supabaseClient: Supabase.instance.client,
);
// Log API key for debugging (remove in production)
debugPrint('Using Gemini API key: ${GEMINI_API_KEY.substring(0, 5)}...');
debugPrint('Gemini service initialized successfully');
} catch (e) {
debugPrint('Error initializing Gemini service: $e');
_errorMessage = 'Gagal menginisialisasi layanan. Silakan coba lagi.';
_scanState = ScanState.error;
}
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final isSmallScreen = screenWidth < 360;
return Scaffold(
appBar: AppBar(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
title: Text(
'Analisis Tanaman',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.help_outline, size: 22),
onPressed: _showHelpDialog,
),
],
),
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.primary.withOpacity(0.9),
AppColors.scaffoldBackground,
],
stops: const [0.0, 0.2],
),
),
child: SafeArea(child: _getContentForState()),
),
);
}
// Content based on state
Widget _getContentForState() {
switch (_scanState) {
case ScanState.empty:
return _buildEmptyState();
case ScanState.loading:
return _buildLoadingState();
case ScanState.result:
return _buildResultView();
case ScanState.error:
return _buildErrorState();
case ScanState.notPlant:
return _buildNotPlantState();
}
}
// UI for empty state
Widget _buildEmptyState() {
return Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Logo or illustration
Container(
width: 70,
height: 70,
decoration: BoxDecoration(
color: PlantScannerColors.lightGreen,
shape: BoxShape.circle,
),
child: Icon(
Icons.eco_rounded,
size: 44,
color: PlantScannerColors.primary,
),
),
const SizedBox(height: 20),
// Middle container with border
Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: PlantScannerColors.primary.withOpacity(0.3),
width: 1.5,
),
),
child: Column(
children: [
Row(
children: [
Icon(
Icons.eco_outlined,
color: PlantScannerColors.primary,
size: 22,
),
const SizedBox(width: 10),
Expanded(
child: Text(
'Ambil foto yang jelas dari bagian tanaman yang terlihat bermasalah',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
color: PlantScannerColors.darkText,
),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Icon(
Icons.wb_sunny_outlined,
color: PlantScannerColors.warning,
size: 22,
),
const SizedBox(width: 10),
Expanded(
child: Text(
'Pastikan pencahayaan cukup dan tidak berbayang',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
color: PlantScannerColors.darkText,
),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Icon(
Icons.zoom_in,
color: PlantScannerColors.accent,
size: 22,
),
const SizedBox(width: 10),
Expanded(
child: Text(
'Dekatkan kamera agar detail gejala terlihat jelas',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
color: PlantScannerColors.darkText,
),
),
),
],
),
],
),
),
// Image upload options with improved UI
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 15,
offset: const Offset(0, 5),
),
],
),
child: Column(
children: [
Text(
'Unggah Foto Tanaman',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: PlantScannerColors.darkText,
),
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: _buildUploadOption(
icon: Icons.camera_alt_rounded,
label: 'Kamera',
description: 'Ambil foto baru',
onTap: () => _pickImage(ImageSource.camera),
),
),
const SizedBox(width: 12),
Expanded(
child: _buildUploadOption(
icon: Icons.photo_library_rounded,
label: 'Galeri',
description: 'Pilih dari galeri',
onTap: _pickImageFromGallery,
),
),
],
),
],
),
),
],
),
),
);
}
// UI for loading state
Widget _buildLoadingState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 80,
height: 80,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 15,
offset: const Offset(0, 5),
),
],
),
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(
PlantScannerColors.primary,
),
),
),
const SizedBox(height: 32),
Text(
_isAnalyzing ? 'Menganalisis Tanaman' : 'Menghubungkan ke API',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: PlantScannerColors.darkText,
),
),
const SizedBox(height: 12),
Text(
_isAnalyzing
? 'Mencari pola penyakit dan kondisi kesehatan...'
: 'Memproses gambar tanaman...',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 15, color: PlantScannerColors.lightText),
),
const SizedBox(height: 40),
// Loading animation for indication of progress
SizedBox(
width: 240,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: LinearProgressIndicator(
backgroundColor: PlantScannerColors.lightGreen,
valueColor: AlwaysStoppedAnimation<Color>(
PlantScannerColors.secondary,
),
),
),
),
],
),
);
}
// Result view - Shows diagnosis results
Widget _buildResultView() {
if (_diagnosisResult == null) {
return Center(child: Text('Tidak ada hasil diagnosis'));
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image preview
if (_image != null)
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(
_image!,
width: double.infinity,
height: 200,
fit: BoxFit.cover,
),
),
const SizedBox(height: 16),
// Tanaman Terdeteksi section
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tanaman Terdeteksi',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.grey[700],
),
),
const SizedBox(height: 12),
Text(
_diagnosisResult!.plantSpecies,
style: TextStyle(
fontSize: 23,
fontWeight: FontWeight.bold,
color: PlantScannerColors.darkText,
),
),
const SizedBox(height: 8),
Row(
children: [
Icon(Icons.eco, color: AppColors.primary, size: 20),
const SizedBox(width: 8),
Text(
'Fase: ${_diagnosisResult!.plantData['growthStage'] ?? 'Tidak dapat ditentukan dari gambar.'}',
style: TextStyle(fontSize: 11, color: Colors.black),
),
],
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
decoration: BoxDecoration(
color:
_diagnosisResult!.isHealthy
? PlantScannerColors.success.withOpacity(0.2)
: PlantScannerColors.error.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
),
child: Text(
_diagnosisResult!.isHealthy
? 'healthy_plant'
: 'unhealthy_plant',
style: TextStyle(
color:
_diagnosisResult!.isHealthy
? PlantScannerColors.success
: PlantScannerColors.error,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
const SizedBox(height: 16),
// Tingkat Keparahan & Dampak
if (!_diagnosisResult!.isHealthy)
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tingkat Keparahan & Dampak',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.grey[700],
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Keparahan',
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: _diagnosisResult!.confidenceValue,
backgroundColor: Colors.red.withOpacity(0.2),
valueColor: AlwaysStoppedAnimation<Color>(
Colors.red,
),
minHeight: 10,
borderRadius: BorderRadius.circular(5),
),
const SizedBox(height: 4),
Text(
'${(_diagnosisResult!.confidenceValue * 100).toInt()}%',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.red,
),
),
],
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Area Terinfeksi',
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: () {
final infectedAreaRaw =
_diagnosisResult!.plantData['infectedArea'];
if (infectedAreaRaw is num) {
return infectedAreaRaw.toDouble() / 100;
} else if (infectedAreaRaw is String) {
final infectedArea = num.tryParse(
infectedAreaRaw,
);
if (infectedArea != null) {
return infectedArea.toDouble() / 100;
}
}
return 0.0;
}(),
backgroundColor: Colors.orange.withOpacity(0.2),
valueColor: AlwaysStoppedAnimation<Color>(
Colors.orange,
),
minHeight: 10,
borderRadius: BorderRadius.circular(5),
),
const SizedBox(height: 4),
Text(
'${(() {
final infectedAreaRaw = _diagnosisResult!.plantData['infectedArea'];
if (infectedAreaRaw is num) {
return infectedAreaRaw.toInt();
} else if (infectedAreaRaw is String) {
final infectedArea = num.tryParse(infectedAreaRaw);
if (infectedArea != null) {
return infectedArea.toInt();
}
}
return 0;
})()}%',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.orange,
),
),
],
),
),
],
),
const SizedBox(height: 16),
Text(
'Potensi Kerugian: ${_diagnosisResult!.economicImpact['estimatedLoss'] ?? 'Tidak dapat ditentukan dari gambar. Kerugian bergantung pada luas area yang terinfeksi.'}',
style: TextStyle(fontSize: 14, color: Colors.red[700]),
),
],
),
),
const SizedBox(height: 16),
// Informasi Penyakit
if (!_diagnosisResult!.isHealthy)
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.bug_report, color: Colors.red[700], size: 22),
const SizedBox(width: 8),
Text(
'Informasi Penyakit',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.grey[700],
),
),
],
),
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.red[50],
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_diagnosisResult!.diseaseName,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.red[700],
),
),
if (_diagnosisResult!.scientificName.isNotEmpty)
Text(
'*${_diagnosisResult!.scientificName}*',
style: TextStyle(
fontSize: 16,
fontStyle: FontStyle.italic,
color: Colors.red[700],
),
),
],
),
),
const SizedBox(height: 16),
// Gejala
Row(
children: [
Icon(
Icons.warning_amber_rounded,
color: Colors.amber,
size: 20,
),
const SizedBox(width: 8),
Text(
'Gejala',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: PlantScannerColors.darkText,
),
),
],
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Text(
_diagnosisResult!.symptoms,
style: TextStyle(
fontSize: 14,
height: 1.5,
color: PlantScannerColors.darkText,
),
),
),
const SizedBox(height: 16),
// Penyebab
Row(
children: [
Icon(
Icons.science_outlined,
color: Colors.blue,
size: 20,
),
const SizedBox(width: 8),
Text(
'Penyebab',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: PlantScannerColors.darkText,
),
),
],
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Text(
_diagnosisResult!.causes,
style: TextStyle(
fontSize: 14,
height: 1.5,
color: PlantScannerColors.darkText,
),
),
),
const SizedBox(height: 16),
// Bagian yang Terpengaruh
Row(
children: [
Icon(Icons.eco_outlined, color: Colors.green, size: 20),
const SizedBox(width: 8),
Text(
'Bagian yang Terpengaruh',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: PlantScannerColors.darkText,
),
),
],
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Text(
_diagnosisResult!.additionalInfo.affectedParts.isNotEmpty
? _diagnosisResult!.additionalInfo.affectedParts.join(
', ',
)
: 'Daun',
style: TextStyle(
fontSize: 14,
height: 1.5,
color: PlantScannerColors.darkText,
),
),
),
const SizedBox(height: 16),
// Kondisi Lingkungan
Row(
children: [
Icon(
Icons.wb_sunny_outlined,
color: Colors.orange,
size: 20,
),
const SizedBox(width: 8),
Text(
'Kondisi Lingkungan',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: PlantScannerColors.darkText,
),
),
],
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Text(
_diagnosisResult!.additionalInfo.environmentalConditions,
style: TextStyle(
fontSize: 14,
height: 1.5,
color: PlantScannerColors.darkText,
),
),
),
],
),
),
const SizedBox(height: 16),
// Pengobatan & Pencegahan
if (!_diagnosisResult!.isHealthy)
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.medical_services_outlined,
color: Colors.green,
size: 22,
),
const SizedBox(width: 8),
Text(
'Pengobatan & Pencegahan',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.grey[700],
),
),
],
),
const SizedBox(height: 16),
// Pengobatan Organik
Row(
children: [
Icon(Icons.eco_outlined, color: Colors.green, size: 20),
const SizedBox(width: 8),
Text(
'Pengobatan Organik',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: PlantScannerColors.darkText,
),
),
],
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Text(
_diagnosisResult!.organicTreatment,
style: TextStyle(
fontSize: 14,
height: 1.5,
color: PlantScannerColors.darkText,
),
),
),
const SizedBox(height: 16),
// Pengobatan Kimia
Row(
children: [
Icon(
Icons.science_outlined,
color: Colors.blue,
size: 20,
),
const SizedBox(width: 8),
Text(
'Pengobatan Kimia',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: PlantScannerColors.darkText,
),
),
],
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Text(
_diagnosisResult!.chemicalTreatment,
style: TextStyle(
fontSize: 14,
height: 1.5,
color: PlantScannerColors.darkText,
),
),
),
],
),
),
const SizedBox(height: 16),
// Langkah Pencegahan
if (!_diagnosisResult!.isHealthy)
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.shield_outlined,
color: Colors.green[700],
size: 22,
),
const SizedBox(width: 8),
Text(
'Langkah Pencegahan',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.grey[700],
),
),
],
),
const SizedBox(height: 16),
..._diagnosisResult!.preventionMeasures.map(
(measure) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.check_circle_outline,
color: Colors.green,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
measure,
style: TextStyle(
fontSize: 14,
height: 1.5,
color: PlantScannerColors.darkText,
),
),
),
],
),
),
),
],
),
),
const SizedBox(height: 16),
// Jadwal Perawatan
if (!_diagnosisResult!.isHealthy &&
_diagnosisResult!.treatmentSchedule.isNotEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Jadwal Perawatan',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.grey[700],
),
),
const SizedBox(height: 16),
// Penyiraman
if (_diagnosisResult!.treatmentSchedule['wateringSchedule'] !=
null)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.water_drop_outlined,
color: Colors.blue,
size: 20,
),
const SizedBox(width: 8),
Text(
'Penyiraman',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: PlantScannerColors.darkText,
),
),
],
),
const SizedBox(height: 4),
Text(
_diagnosisResult!
.treatmentSchedule['wateringSchedule']
.toString(),
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
const SizedBox(height: 12),
],
),
// Pemupukan
if (_diagnosisResult!
.treatmentSchedule['fertilizingSchedule'] !=
null)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.eco_outlined,
color: Colors.green,
size: 20,
),
const SizedBox(width: 8),
Text(
'Pemupukan',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: PlantScannerColors.darkText,
),
),
],
),
const SizedBox(height: 4),
Text(
_diagnosisResult!
.treatmentSchedule['fertilizingSchedule']
.toString(),
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
const SizedBox(height: 12),
],
),
// Pestisida
if (_diagnosisResult!
.treatmentSchedule['pesticideSchedule'] !=
null)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.pest_control_outlined,
color: Colors.orange,
size: 20,
),
const SizedBox(width: 8),
Text(
'Pestisida',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: PlantScannerColors.darkText,
),
),
],
),
const SizedBox(height: 4),
Text(
_diagnosisResult!
.treatmentSchedule['pesticideSchedule']
.toString(),
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
),
],
),
),
const SizedBox(height: 16),
// Varietas Alternatif
if (!_diagnosisResult!.isHealthy &&
_diagnosisResult!.alternativeVarieties.isNotEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Varietas Alternatif',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.grey[700],
),
),
const SizedBox(height: 12),
Row(
children: [
Icon(Icons.eco_outlined, color: Colors.green, size: 20),
const SizedBox(width: 8),
Text(
'Varietas padi tahan penyakit bercak daun bakteri',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: PlantScannerColors.darkText,
),
),
],
),
const SizedBox(height: 8),
Text(
'Cari informasi varietas padi yang direkomendasikan untuk daerah Anda yang memiliki tingkat ketahanan terhadap penyakit bercak daun bakteri.',
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
height: 1.5,
),
),
],
),
),
const SizedBox(height: 24),
// Action buttons
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => setState(() => _scanState = ScanState.empty),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
icon: const Icon(Icons.refresh),
label: const Text('Analisis Ulang'),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: _generatePdfReport,
style: ElevatedButton.styleFrom(
backgroundColor: PlantScannerColors.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
icon: const Icon(Icons.download_rounded),
label: const Text('Simpan Laporan'),
),
),
],
),
],
),
);
}
// Generate PDF report
Future<void> _generatePdfReport() async {
if (_diagnosisResult == null || _image == null) return;
try {
setState(() => _isLoading = true);
// Convert image to bytes
final imageBytes = await _image!.readAsBytes();
// Decode image (apapun formatnya) lalu encode ke PNG
final decoded = img.decodeImage(imageBytes);
Uint8List safeBytes;
if (decoded != null) {
safeBytes = Uint8List.fromList(img.encodePng(decoded));
} else {
safeBytes = imageBytes; // fallback, meski kemungkinan error
}
final pdfGenerator = HarvestPdfGenerator();
final pdfFile = await pdfGenerator.generateDiagnosisReportPdf(
diagnosisResult: _diagnosisResult!,
imageBytes: safeBytes,
);
setState(() => _isLoading = false);
if (!mounted) return;
showDialog(
context: context,
builder:
(context) => AlertDialog(
title: const Text('PDF Berhasil Dibuat'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Laporan PDF diagnosis tanaman telah berhasil dibuat.',
),
const SizedBox(height: 8),
Text(
'Lokasi: \\n${pdfFile.path}',
style: const TextStyle(
fontSize: 12,
fontStyle: FontStyle.italic,
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Tutup'),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
await pdfGenerator.sharePdf(pdfFile);
},
child: const Text('Bagikan'),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
await pdfGenerator.openPdf(pdfFile);
},
child: const Text('Buka'),
),
],
),
);
} catch (e) {
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Gagal membuat laporan: \\n${e.toString()}'),
backgroundColor: PlantScannerColors.error,
),
);
}
}
// UI for error state
Widget _buildErrorState() {
return Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: PlantScannerColors.error.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(
Icons.error_outline_rounded,
size: 56,
color: PlantScannerColors.error,
),
),
const SizedBox(height: 32),
Text(
'Terjadi Kesalahan',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: PlantScannerColors.darkText,
),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: PlantScannerColors.error.withOpacity(0.3),
width: 1,
),
),
child: Text(
_errorMessage.isNotEmpty
? _errorMessage
: 'Terjadi kesalahan saat menganalisis gambar. Silakan coba lagi.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 15,
color: PlantScannerColors.darkText,
),
),
),
const SizedBox(height: 32),
ElevatedButton.icon(
onPressed: () => setState(() => _scanState = ScanState.empty),
style: ElevatedButton.styleFrom(
backgroundColor: PlantScannerColors.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
),
icon: const Icon(Icons.refresh),
label: Text('Coba Lagi'),
),
],
),
),
);
}
// UI for not plant state
Widget _buildNotPlantState() {
return Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: PlantScannerColors.warning.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(
Icons.warning_amber_rounded,
size: 56,
color: PlantScannerColors.warning,
),
),
const SizedBox(height: 32),
Text(
'Bukan Tanaman Terdeteksi',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: PlantScannerColors.darkText,
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: PlantScannerColors.warning.withOpacity(0.3),
width: 1,
),
),
child: Text(
'Kami tidak dapat mendeteksi tanaman dalam gambar ini. Pastikan gambar yang Anda kirim menampilkan tanaman dengan jelas.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 15,
color: PlantScannerColors.darkText,
),
),
),
const SizedBox(height: 32),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed:
() => setState(() => _scanState = ScanState.empty),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
side: BorderSide(color: PlantScannerColors.accent),
),
icon: const Icon(Icons.arrow_back),
label: Text('Kembali'),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: _pickImageFromGallery,
style: ElevatedButton.styleFrom(
backgroundColor: PlantScannerColors.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
icon: const Icon(Icons.photo_library),
label: Text('Unggah Ulang'),
),
),
],
),
],
),
),
);
}
// Help dialog
void _showHelpDialog() {
showDialog(
context: context,
builder:
(context) => AlertDialog(
title: Text(
'Bantuan Analisis Tanaman',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
Icons.check,
color: AppColors.primary,
size: 18,
),
minLeadingWidth: 24,
title: Text(
'Ambil foto yang jelas dari bagian tanaman yang terlihat bermasalah',
style: TextStyle(
fontSize: 13,
color: PlantScannerColors.darkText,
),
),
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
Icons.check,
color: AppColors.primary,
size: 18,
),
minLeadingWidth: 24,
title: Text(
'Fokuskan pada bagian yang terinfeksi (daun, batang, buah)',
style: TextStyle(
fontSize: 13,
color: PlantScannerColors.darkText,
),
),
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
Icons.check,
color: AppColors.primary,
size: 18,
),
minLeadingWidth: 24,
title: Text(
'Pastikan pencahayaan cukup dan foto tidak buram',
style: TextStyle(
fontSize: 13,
color: PlantScannerColors.darkText,
),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
'Mengerti',
style: TextStyle(color: AppColors.primary),
),
),
],
),
);
}
// Upload option item with improved UI
Widget _buildUploadOption({
required IconData icon,
required String label,
required String description,
required VoidCallback onTap,
}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: PlantScannerColors.background,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: PlantScannerColors.divider, width: 1),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: PlantScannerColors.primary.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Icon(icon, color: PlantScannerColors.primary, size: 24),
),
const SizedBox(height: 12),
Text(
label,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: PlantScannerColors.darkText,
),
),
const SizedBox(height: 4),
Text(
description,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
color: PlantScannerColors.lightText,
),
),
],
),
),
);
}
// Camera image picking
Future<void> _pickImage(ImageSource source) async {
try {
final pickedFile = await _picker.pickImage(source: source);
if (pickedFile == null) return;
setState(() {
_scanState = ScanState.loading;
_errorMessage = '';
});
final imageFile = File(pickedFile.path);
setState(() {
_image = imageFile;
_webImage = null;
});
// Analyze the plant image
setState(() {
_isAnalyzing = true;
});
try {
// Attempt to analyze the image using Gemini service
final result = await _geminiService.diagnosePlant(_image!.path);
setState(() {
_diagnosisResult = result;
_scanState = ScanState.result;
_isAnalyzing = false;
});
} catch (e) {
debugPrint('Error analyzing image: $e');
setState(() {
_isAnalyzing = false;
_scanState = ScanState.error;
_errorMessage =
'Terjadi kesalahan saat menganalisis gambar: ${e.toString()}';
});
}
} catch (e) {
setState(() {
_scanState = ScanState.error;
_errorMessage = e.toString();
});
}
}
// Gallery picking
Future<void> _pickImageFromGallery() async {
try {
final pickedFile = await _picker.pickImage(source: ImageSource.gallery);
if (pickedFile == null) return;
setState(() {
_scanState = ScanState.loading;
_errorMessage = '';
});
final imageFile = File(pickedFile.path);
setState(() {
_image = imageFile;
_webImage = null;
});
// Analyze the plant image
setState(() {
_isAnalyzing = true;
});
try {
// Attempt to analyze the image using Gemini service
final result = await _geminiService.diagnosePlant(_image!.path);
setState(() {
_diagnosisResult = result;
_scanState = ScanState.result;
_isAnalyzing = false;
});
} catch (e) {
debugPrint('Error analyzing image: $e');
setState(() {
_isAnalyzing = false;
_scanState = ScanState.error;
_errorMessage =
'Terjadi kesalahan saat menganalisis gambar: ${e.toString()}';
});
}
} catch (e) {
setState(() {
_scanState = ScanState.error;
_errorMessage = e.toString();
});
}
}
Future<void> _deleteMessageForEveryone(Message message) async {
setState(() {
_messages.removeWhere((m) => m.id == message.id);
});
try {
await _messageService.deleteMessage(message);
// ...snackbar...
} catch (e) {
// ...error handling...
}
}
}