menambahkan beberapa diagram admin dan user

This commit is contained in:
rijalhabibullah 2026-05-12 04:34:29 +07:00
parent e67063281b
commit f2967da073
13 changed files with 1008 additions and 131 deletions

View File

@ -106,6 +106,9 @@ class _HomeScreenState extends State<HomeScreen> {
locationAddress: location?.address,
locationLat: location?.lat,
locationLng: location?.lng,
kabupaten: location?.kabupaten,
kecamatan: location?.kecamatan,
kelurahan: location?.kelurahan,
);
// Validasi: cek apakah confidence cukup tinggi (artinya itu daun padi)
@ -177,21 +180,44 @@ class _HomeScreenState extends State<HomeScreen> {
);
String? address;
String? kabupaten;
String? kecamatan;
String? kelurahan;
if (placemarks.isNotEmpty) {
final place = placemarks.first;
// Extract components dengan urutan yang sesuai untuk Indonesia
kelurahan = place.subLocality?.trim(); // Kelurahan/Desa
kecamatan = place.locality?.trim(); // Kecamatan
final provinsi = place.administrativeArea?.trim(); // Provinsi
final negara = place.country?.trim(); // Negara
// Build full address: kelurahan, kecamatan, kabupaten, provinsi, negara
// Note: subAdministrativeArea (kabupaten) tidak selalu tersedia di geocoding Indonesia
final parts = [
place.street,
place.subLocality,
place.locality,
place.administrativeArea,
place.country,
].where((part) => part != null && part!.trim().isNotEmpty)
.map((part) => part!.trim())
kelurahan,
kecamatan,
provinsi,
negara,
].where((part) => part != null && part!.isNotEmpty)
.toList();
if (parts.isNotEmpty) {
address = parts.join(', ');
}
// Extract kabupaten dari location_address jika ada (Indonesia format)
// Contoh: "Sumbersari, Kecamatan Sumbersari, Kabupaten Jember, Jawa Timur, Indonesia"
if (address != null) {
// Try extract "Kabupaten [name]" atau "Kota [name]"
final kabupatenRegex = RegExp(r'(?:Kabupaten|Kota)\s+([^,]+)');
final kabupatenMatch = kabupatenRegex.firstMatch(address!);
if (kabupatenMatch != null) {
kabupaten = kabupatenMatch.group(1)?.trim();
}
}
}
if (address != null) {
@ -205,6 +231,9 @@ class _HomeScreenState extends State<HomeScreen> {
address: address,
lat: position.latitude,
lng: position.longitude,
kabupaten: kabupaten,
kecamatan: kecamatan,
kelurahan: kelurahan,
);
}
@ -414,10 +443,16 @@ class _LocationPayload {
final String? address;
final double lat;
final double lng;
final String? kabupaten;
final String? kecamatan;
final String? kelurahan;
const _LocationPayload({
required this.address,
required this.lat,
required this.lng,
this.kabupaten,
this.kecamatan,
this.kelurahan,
});
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import '../services/classification_service.dart';
class HistoryScreen extends StatefulWidget {
@ -52,57 +53,188 @@ class _HistoryScreenState extends State<HistoryScreen> {
return const Center(child: Text('Belum ada riwayat klasifikasi.'));
}
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: items.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final item = items[index];
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.06),
blurRadius: 8,
offset: const Offset(0, 3),
// Filter data bulan ini
final now = DateTime.now();
final thisMonthItems = items.where((item) {
return item.createdAt.year == now.year &&
item.createdAt.month == now.month;
}).toList();
// Hitung jumlah per klasifikasi bulan ini
final classificationCount = <String, int>{};
for (var item in thisMonthItems) {
classificationCount[item.diseaseName] =
(classificationCount[item.diseaseName] ?? 0) + 1;
}
return SingleChildScrollView(
child: Column(
children: [
// Pie Chart Section
if (classificationCount.isNotEmpty)
Container(
padding: const EdgeInsets.all(20),
margin: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.06),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.diseaseName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
child: Column(
children: [
Text(
'Klasifikasi Bulan ${_getMonthName(now.month)} ${now.year}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 20),
SizedBox(
height: 280,
child: PieChart(
PieChartData(
sections: _buildPieChartSections(classificationCount),
centerSpaceRadius: 40,
sectionsSpace: 2,
),
),
),
const SizedBox(height: 20),
// Legend
Column(
children: classificationCount.entries.map((entry) {
final color = _getColorForDisease(entry.key);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(entry.key),
),
Text(
'${entry.value}x',
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
],
),
);
}).toList(),
),
],
),
)
else
Padding(
padding: const EdgeInsets.all(20),
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.blue[50],
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.blue),
),
child: const Center(
child: Text(
'Belum ada data klasifikasi bulan ini',
style: TextStyle(color: Colors.blue),
),
),
),
const SizedBox(height: 6),
_infoRow(
icon: Icons.label,
text: 'Kelas: ${item.predictedClass}',
),
// History List Section
if (items.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Text(
'Semua Riwayat Klasifikasi',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: items.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final item = items[index];
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.06),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.diseaseName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 6),
_infoRow(
icon: Icons.label,
text: 'Kelas: ${item.predictedClass}',
),
_infoRow(
icon: Icons.bar_chart,
text: 'Confidence: ${item.confidence}',
),
_infoRow(
icon: Icons.location_on,
text:
'Lokasi: ${item.locationAddress ?? 'Alamat tidak terdeteksi'}',
),
_infoRow(
icon: Icons.calendar_today,
text: _formatTimestamp(item.createdAt),
),
],
),
);
},
),
],
),
_infoRow(
icon: Icons.bar_chart,
text: 'Confidence: ${item.confidence}',
),
_infoRow(
icon: Icons.location_on,
text:
'Lokasi: ${item.locationAddress ?? 'Alamat tidak terdeteksi'}',
),
_infoRow(
icon: Icons.calendar_today,
text: _formatTimestamp(item.createdAt),
),
],
),
);
},
),
],
),
);
},
),
@ -132,4 +264,94 @@ class _HistoryScreenState extends State<HistoryScreen> {
),
);
}
List<PieChartSectionData> _buildPieChartSections(Map<String, int> classificationCount) {
final total = classificationCount.values.fold<int>(0, (sum, val) => sum + val);
return classificationCount.entries.map((entry) {
final value = entry.value;
final percentage = (value / total * 100);
final diseaseName = _getNormalizedDiseaseName(entry.key);
return PieChartSectionData(
color: _getColorForDisease(entry.key),
value: value.toDouble(),
title: '$diseaseName\n${percentage.toStringAsFixed(1)}%',
radius: 80,
titleStyle: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: Colors.white,
height: 1.2,
),
);
}).toList();
}
Color _getColorForDisease(String diseaseName) {
final colors = {
'Bacterial Blight': const Color(0xFFEF5350), // Merah cerah
'Brown Spot': const Color(0xFFA1887F), // Coklat
'Leaf Smut': const Color(0xFF455A64), // Abu-abu gelap
'Healthy': const Color(0xFF66BB6A), // Hijau cerah
'bacterial blight': const Color(0xFFEF5350), // Merah cerah (lowercase)
'brown spot': const Color(0xFFA1887F), // Coklat (lowercase)
'leaf smut': const Color(0xFF455A64), // Abu-abu gelap (lowercase)
'healthy': const Color(0xFF66BB6A), // Hijau cerah (lowercase)
};
// Coba exact match dulu, jika tidak ada gunakan warna berdasarkan hash
if (colors.containsKey(diseaseName)) {
return colors[diseaseName]!;
}
// Jika tidak ada di map, assign warna berdasarkan hash nama untuk konsistensi
final hash = diseaseName.hashCode.abs();
final colorList = [
const Color(0xFFEF5350), // Red
const Color(0xFFAB47BC), // Purple
const Color(0xFF5C6BC0), // Indigo
const Color(0xFF42A5F5), // Blue
const Color(0xFF29B6F6), // Light Blue
const Color(0xFF26C6DA), // Cyan
const Color(0xFF26A69A), // Teal
const Color(0xFF66BB6A), // Green
const Color(0xFF9CCC65), // Light Green
const Color(0xFFD4AF37), // Gold
const Color(0xFFFF7043), // Deep Orange
const Color(0xFFA1887F), // Brown
];
return colorList[hash % colorList.length];
}
String _getMonthName(int month) {
final months = [
'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'
];
return months[month - 1];
}
String _getNormalizedDiseaseName(String diseaseName) {
final normalized = {
'Bacterial Blight': 'Bacterial Blight',
'bacterial blight': 'Bacterial Blight',
'Bacterialblight': 'Bacterial Blight',
'Bercak Bakteri (Bacterial Blight)': 'Bacterial Blight',
'Brown Spot': 'Brown Spot',
'brown spot': 'Brown Spot',
'Brownspot': 'Brown Spot',
'Bercak Coklat (Brown Spot)': 'Brown Spot',
'Leaf Smut': 'Leaf Smut',
'leaf smut': 'Leaf Smut',
'Leafsmut': 'Leaf Smut',
'Jamur Daun (Leaf Smut)': 'Leaf Smut',
'Healthy': 'Healthy',
'healthy': 'Healthy',
'Sehat (Healthy)': 'Healthy',
};
return normalized[diseaseName] ?? diseaseName;
}
}

View File

@ -15,6 +15,9 @@ class _ProfileScreenState extends State<ProfileScreen> {
String _userEmail = "";
String _userPhone = "";
String _userLocation = "";
String _userKabupaten = "";
String _userKecamatan = "";
String _userKelurahan = "";
@override
void initState() {
@ -30,6 +33,9 @@ class _ProfileScreenState extends State<ProfileScreen> {
_userEmail = prefs.getString('user_email') ?? '';
_userPhone = prefs.getString('user_phone') ?? '';
_userLocation = prefs.getString('user_location') ?? '';
_userKabupaten = prefs.getString('user_kabupaten') ?? '';
_userKecamatan = prefs.getString('user_kecamatan') ?? '';
_userKelurahan = prefs.getString('user_kelurahan') ?? '';
});
}
@ -82,26 +88,53 @@ class _ProfileScreenState extends State<ProfileScreen> {
}
final place = placemarks.first;
// Extract components dengan urutan yang sesuai untuk Indonesia
final kelurahan = place.subLocality?.trim(); // Kelurahan/Desa
final kecamatan = place.locality?.trim(); // Kecamatan
final provinsi = place.administrativeArea?.trim(); // Provinsi
final negara = place.country?.trim(); // Negara
// Build full address: kelurahan, kecamatan, kabupaten, provinsi, negara
final parts = [
place.street,
place.subLocality,
place.locality,
place.administrativeArea,
place.country,
].where((part) => part != null && part!.trim().isNotEmpty)
.map((part) => part!.trim())
kelurahan,
kecamatan,
provinsi,
negara,
].where((part) => part != null && part!.isNotEmpty)
.toList();
if (parts.isEmpty) {
return null;
}
return parts.join(', ');
final address = parts.join(', ');
// Extract kabupaten dari address jika ada (Indonesia format)
// Contoh: "Sumbersari, Kecamatan Sumbersari, Kabupaten Jember, Jawa Timur, Indonesia"
String? kabupaten;
final kabupatenRegex = RegExp(r'(?:Kabupaten|Kota)\s+([^,]+)');
final kabupatenMatch = kabupatenRegex.firstMatch(address);
if (kabupatenMatch != null) {
kabupaten = kabupatenMatch.group(1)?.trim();
}
// Save to shared preferences
final prefs = await SharedPreferences.getInstance();
await prefs.setString('user_location', address);
if (kelurahan != null) await prefs.setString('user_kelurahan', kelurahan);
if (kecamatan != null) await prefs.setString('user_kecamatan', kecamatan);
if (kabupaten != null) await prefs.setString('user_kabupaten', kabupaten);
return address;
}
void _openEditProfile() {
final phoneController = TextEditingController(text: _userPhone);
final locationController = TextEditingController(text: _userLocation);
final kabupatenController = TextEditingController(text: _userKabupaten);
final kecamatanController = TextEditingController(text: _userKecamatan);
final kelurahanController = TextEditingController(text: _userKelurahan);
showModalBottomSheet(
context: context,
@ -142,6 +175,30 @@ class _ProfileScreenState extends State<ProfileScreen> {
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: kabupatenController,
decoration: const InputDecoration(
labelText: 'Kabupaten',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: kecamatanController,
decoration: const InputDecoration(
labelText: 'Kecamatan',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: kelurahanController,
decoration: const InputDecoration(
labelText: 'Kelurahan',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 10),
TextButton.icon(
onPressed: () async {
@ -157,6 +214,12 @@ class _ProfileScreenState extends State<ProfileScreen> {
return;
}
locationController.text = address;
// Load saved values from prefs
final prefs = await SharedPreferences.getInstance();
kabupatenController.text = prefs.getString('user_kabupaten') ?? '';
kecamatanController.text = prefs.getString('user_kecamatan') ?? '';
kelurahanController.text = prefs.getString('user_kelurahan') ?? '';
},
icon: const Icon(Icons.my_location),
label: const Text('Gunakan lokasi saat ini'),
@ -176,11 +239,26 @@ class _ProfileScreenState extends State<ProfileScreen> {
'user_location',
locationController.text.trim(),
);
await prefs.setString(
'user_kabupaten',
kabupatenController.text.trim(),
);
await prefs.setString(
'user_kecamatan',
kecamatanController.text.trim(),
);
await prefs.setString(
'user_kelurahan',
kelurahanController.text.trim(),
);
if (mounted) {
setState(() {
_userPhone = phoneController.text.trim();
_userLocation = locationController.text.trim();
_userKabupaten = kabupatenController.text.trim();
_userKecamatan = kecamatanController.text.trim();
_userKelurahan = kelurahanController.text.trim();
});
}
@ -225,6 +303,9 @@ class _ProfileScreenState extends State<ProfileScreen> {
await prefs.remove('user_email');
await prefs.remove('user_phone');
await prefs.remove('user_location');
await prefs.remove('user_kabupaten');
await prefs.remove('user_kecamatan');
await prefs.remove('user_kelurahan');
if (mounted) {
Navigator.pushNamedAndRemoveUntil(context, '/', (route) => false);
}
@ -330,9 +411,9 @@ class _ProfileScreenState extends State<ProfileScreen> {
const Divider(height: 20),
_buildProfileField(
icon: Icons.location_on,
label: "Lokasi",
label: "Lokasi Lengkap",
value: _userLocation,
placeholder: 'Tambahkan lokasi',
placeholder: 'Lokasi lengkap',
maxLines: 3,
),
],

View File

@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../main.dart';
import '../services/classification_service.dart';
import '../services/product_service.dart';
class ResultScreen extends StatelessWidget {
class ResultScreen extends StatefulWidget {
final ClassificationResult result;
const ResultScreen({
@ -10,6 +12,19 @@ class ResultScreen extends StatelessWidget {
required this.result,
});
@override
State<ResultScreen> createState() => _ResultScreenState();
}
class _ResultScreenState extends State<ResultScreen> {
late Future<List<ProductItem>> _productsFuture;
@override
void initState() {
super.initState();
_productsFuture = ProductService().fetchProducts();
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -35,7 +50,7 @@ class ResultScreen extends StatelessWidget {
children: [
const Text("Penyakit Terdeteksi:", style: TextStyle(fontSize: 16)),
Text(
result.diseaseInfo.name,
widget.result.diseaseInfo.name,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.red),
),
const SizedBox(height: 12),
@ -50,7 +65,7 @@ class ResultScreen extends StatelessWidget {
child: Column(
children: [
Text(
"Confidence: ${result.confidence}",
"Confidence: ${widget.result.confidence}",
style: TextStyle(
color: Colors.green[700],
fontWeight: FontWeight.bold,
@ -61,11 +76,11 @@ class ResultScreen extends StatelessWidget {
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: result.confidenceValue,
value: widget.result.confidenceValue,
minHeight: 8,
backgroundColor: Colors.grey[300],
valueColor: AlwaysStoppedAnimation<Color>(
result.confidenceValue > 0.8 ? Colors.green : Colors.orange,
widget.result.confidenceValue > 0.8 ? Colors.green : Colors.orange,
),
),
),
@ -79,11 +94,11 @@ class ResultScreen extends StatelessWidget {
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: _getSeverityColor(result.diseaseInfo.severity),
color: _getSeverityColor(widget.result.diseaseInfo.severity),
borderRadius: BorderRadius.circular(20),
),
child: Text(
"Severity: ${result.diseaseInfo.severity}",
"Severity: ${widget.result.diseaseInfo.severity}",
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
@ -101,6 +116,103 @@ class ResultScreen extends StatelessWidget {
),
),
// Location Info Card
if (widget.result.kabupaten != null || widget.result.locationAddress != null)
Padding(
padding: const EdgeInsets.only(top: 20),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue[50],
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.blue[200]!),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.location_on, color: Colors.blue[700], size: 20),
const SizedBox(width: 8),
const Text(
"Informasi Lokasi",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
],
),
const SizedBox(height: 12),
if (widget.result.kabupaten != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(
width: 100,
child: Text("Kabupaten:", style: TextStyle(fontWeight: FontWeight.w500, fontSize: 13)),
),
Expanded(
child: Text(widget.result.kabupaten!, style: const TextStyle(fontSize: 13)),
),
],
),
),
if (widget.result.kecamatan != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(
width: 100,
child: Text("Kecamatan:", style: TextStyle(fontWeight: FontWeight.w500, fontSize: 13)),
),
Expanded(
child: Text(widget.result.kecamatan!, style: const TextStyle(fontSize: 13)),
),
],
),
),
if (widget.result.kelurahan != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(
width: 100,
child: Text("Kelurahan:", style: TextStyle(fontWeight: FontWeight.w500, fontSize: 13)),
),
Expanded(
child: Text(widget.result.kelurahan!, style: const TextStyle(fontSize: 13)),
),
],
),
),
if (widget.result.locationAddress != null)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(
width: 100,
child: Text("Alamat Lengkap:", style: TextStyle(fontWeight: FontWeight.w500, fontSize: 13)),
),
Expanded(
child: Text(
widget.result.locationAddress!,
style: const TextStyle(fontSize: 12),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
),
),
const SizedBox(height: 30),
// Semua Prediksi
@ -120,7 +232,7 @@ class ResultScreen extends StatelessWidget {
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
...result.allPredictions.entries.map((entry) {
...widget.result.allPredictions.entries.map((entry) {
final score = entry.value as double;
final percentage = (score * 100).toStringAsFixed(2);
return Padding(
@ -143,14 +255,14 @@ class ResultScreen extends StatelessWidget {
minHeight: 6,
backgroundColor: Colors.grey[300],
valueColor: AlwaysStoppedAnimation<Color>(
entry.key == result.predictedClass ? Colors.green : Colors.blue,
entry.key == widget.result.predictedClass ? Colors.green : Colors.blue,
),
),
),
],
),
);
}).toList(),
}),
],
),
),
@ -162,19 +274,72 @@ class ResultScreen extends StatelessWidget {
),
const SizedBox(height: 15),
// Grid Produk
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 15,
mainAxisSpacing: 15,
childAspectRatio: 0.8,
),
itemCount: 4,
itemBuilder: (context, index) {
return _buildProductCard();
// Grid Produk dengan FutureBuilder
FutureBuilder<List<ProductItem>>(
future: _productsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return SizedBox(
height: 300,
child: Center(
child: CircularProgressIndicator(
color: const Color(0xFF0F703A),
),
),
);
}
if (snapshot.hasError) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.red[50],
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.red),
),
child: Column(
children: [
Icon(Icons.error, color: Colors.red, size: 40),
const SizedBox(height: 10),
Text(
"Gagal memuat produk: ${snapshot.error}",
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center,
),
],
),
);
}
final products = snapshot.data ?? [];
if (products.isEmpty) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(12),
),
child: const Center(
child: Text("Tidak ada produk tersedia"),
),
);
}
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 15,
mainAxisSpacing: 15,
childAspectRatio: 0.75,
),
itemCount: products.length,
itemBuilder: (context, index) {
return _buildProductCard(products[index]);
},
);
},
),
const SizedBox(height: 40),
@ -217,56 +382,156 @@ class ResultScreen extends StatelessWidget {
}
}
Widget _buildProductCard() {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: Column(
children: [
Expanded(
child: Container(
color: Colors.grey[300],
child: Center(
child: Icon(Icons.inventory_2, size: 50, color: Colors.grey),
Widget _buildProductCard(ProductItem product) {
return GestureDetector(
onTap: () {
if (product.marketplaceLink != null && product.marketplaceLink!.isNotEmpty) {
_launchUrl(product.marketplaceLink!);
}
},
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 8)],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Gambar Produk
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
),
child: product.imageUrl != null && product.imageUrl!.isNotEmpty
? Image.network(
product.imageUrl!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Center(
child: Icon(Icons.image_not_supported,
size: 40,
color: Colors.grey[400]
),
);
},
)
: Center(
child: Icon(
Icons.inventory_2,
size: 40,
color: Colors.grey[400],
),
),
),
),
),
Container(
padding: const EdgeInsets.all(8),
width: double.infinity,
color: Colors.black,
child: const Text(
"Deskripsi Produk",
style: TextStyle(color: Colors.white, fontSize: 12),
textAlign: TextAlign.center,
// Info Produk
Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Nama Produk
Text(
product.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
// Harga
Text(
'Rp ${product.price.toStringAsFixed(0)}',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
const SizedBox(height: 6),
// Tombol Beli
if (product.marketplaceLink != null && product.marketplaceLink!.isNotEmpty)
SizedBox(
width: double.infinity,
height: 28,
child: ElevatedButton(
onPressed: () => _launchUrl(product.marketplaceLink!),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0F703A),
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
),
child: const Text(
'Beli',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
],
),
),
)
],
],
),
),
);
}
Future<void> _launchUrl(String urlString) async {
final Uri url = Uri.parse(urlString);
try {
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Tidak dapat membuka link')),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
}
void _showDetailPopup(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(result.diseaseInfo.name),
title: Text(widget.result.diseaseInfo.name),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Deskripsi
if (result.diseaseInfo.description.isNotEmpty)
if (widget.result.diseaseInfo.description.isNotEmpty)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("Deskripsi:", style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text(result.diseaseInfo.description),
Text(widget.result.diseaseInfo.description),
const SizedBox(height: 16),
],
),
@ -274,7 +539,7 @@ class ResultScreen extends StatelessWidget {
// Gejala
const Text("Gejala:", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
const SizedBox(height: 8),
...result.diseaseInfo.symptoms.map((symptom) {
...widget.result.diseaseInfo.symptoms.map((symptom) {
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
@ -285,14 +550,14 @@ class ResultScreen extends StatelessWidget {
],
),
);
}).toList(),
}),
const SizedBox(height: 16),
// Penanganan
const Text("Penanganan:", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
const SizedBox(height: 8),
...result.diseaseInfo.treatment.map((treatment) {
...widget.result.diseaseInfo.treatment.map((treatment) {
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
@ -303,7 +568,7 @@ class ResultScreen extends StatelessWidget {
],
),
);
}).toList(),
}),
],
),
),

View File

@ -20,7 +20,15 @@ class ClassificationService {
: _httpClient = httpClient ?? http.Client();
/// Klasifikasi gambar dari file
Future<ClassificationResult> classifyImage(File imageFile) async {
Future<ClassificationResult> classifyImage(
File imageFile, {
String? locationAddress,
double? locationLat,
double? locationLng,
String? kabupaten,
String? kecamatan,
String? kelurahan,
}) async {
try {
final request = http.MultipartRequest('POST', Uri.parse('$_baseUrl/classify'));
request.headers['Accept'] = 'application/json';
@ -39,6 +47,25 @@ class ClassificationService {
await http.MultipartFile.fromPath('image', imageFile.path),
);
if (locationAddress != null && locationAddress.isNotEmpty) {
request.fields['location_address'] = locationAddress;
}
if (locationLat != null) {
request.fields['location_lat'] = locationLat.toString();
}
if (locationLng != null) {
request.fields['location_lng'] = locationLng.toString();
}
if (kabupaten != null && kabupaten.isNotEmpty) {
request.fields['kabupaten'] = kabupaten;
}
if (kecamatan != null && kecamatan.isNotEmpty) {
request.fields['kecamatan'] = kecamatan;
}
if (kelurahan != null && kelurahan.isNotEmpty) {
request.fields['kelurahan'] = kelurahan;
}
final response = await request.send().timeout(
const Duration(seconds: 30),
onTimeout: () {
@ -87,6 +114,9 @@ class ClassificationService {
String? locationAddress,
double? locationLat,
double? locationLng,
String? kabupaten,
String? kecamatan,
String? kelurahan,
}) async {
try {
final request = http.MultipartRequest(
@ -118,6 +148,15 @@ class ClassificationService {
if (locationLng != null) {
request.fields['location_lng'] = locationLng.toString();
}
if (kabupaten != null && kabupaten.isNotEmpty) {
request.fields['kabupaten'] = kabupaten;
}
if (kecamatan != null && kecamatan.isNotEmpty) {
request.fields['kecamatan'] = kecamatan;
}
if (kelurahan != null && kelurahan.isNotEmpty) {
request.fields['kelurahan'] = kelurahan;
}
final response = await request.send().timeout(
const Duration(seconds: 30),
@ -214,6 +253,10 @@ class ClassificationResult {
final DiseaseInfo diseaseInfo;
final String? imagePath;
final String? notes;
final String? locationAddress;
final String? kabupaten;
final String? kecamatan;
final String? kelurahan;
final DateTime timestamp;
ClassificationResult({
@ -225,6 +268,10 @@ class ClassificationResult {
required this.timestamp,
this.imagePath,
this.notes,
this.locationAddress,
this.kabupaten,
this.kecamatan,
this.kelurahan,
});
factory ClassificationResult.fromJson(Map<String, dynamic> json) {
@ -240,6 +287,10 @@ class ClassificationResult {
),
imagePath: json['image_path'],
notes: json['notes'],
locationAddress: json['location_address'],
kabupaten: json['kabupaten'],
kecamatan: json['kecamatan'],
kelurahan: json['kelurahan'],
timestamp: DateTime.parse(
json['timestamp'] ?? DateTime.now().toIso8601String(),
),
@ -283,6 +334,9 @@ class ClassificationHistoryItem {
final String? locationAddress;
final double? locationLat;
final double? locationLng;
final String? kabupaten;
final String? kecamatan;
final String? kelurahan;
final DateTime createdAt;
ClassificationHistoryItem({
@ -296,6 +350,9 @@ class ClassificationHistoryItem {
this.locationAddress,
this.locationLat,
this.locationLng,
this.kabupaten,
this.kecamatan,
this.kelurahan,
});
factory ClassificationHistoryItem.fromJson(Map<String, dynamic> json) {
@ -320,6 +377,9 @@ class ClassificationHistoryItem {
locationAddress: json['location_address'],
locationLat: locationLat,
locationLng: locationLng,
kabupaten: json['kabupaten'],
kecamatan: json['kecamatan'],
kelurahan: json['kelurahan'],
createdAt: DateTime.parse(
json['created_at'] ?? DateTime.now().toIso8601String(),
),

View File

@ -65,6 +65,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.8"
equatable:
dependency: transitive
description:
name: equatable
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
url: "https://pub.dev"
source: hosted
version: "2.0.8"
fake_async:
dependency: transitive
description:
@ -129,6 +137,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
fl_chart:
dependency: "direct main"
description:
name: fl_chart
sha256: "5a74434cc83bf64346efb562f1a06eefaf1bcb530dc3d96a104f631a1eff8d79"
url: "https://pub.dev"
source: hosted
version: "0.65.0"
flutter:
dependency: "direct main"
description: flutter

View File

@ -42,6 +42,7 @@ dependencies:
smooth_page_indicator: 1.1.0
shared_preferences: ^2.5.3
url_launcher: ^6.3.2
fl_chart: ^0.65.0
dev_dependencies:
flutter_test:

View File

@ -32,6 +32,9 @@ public function classify(Request $request)
'location_address' => 'nullable|string|max:255',
'location_lat' => 'nullable|numeric',
'location_lng' => 'nullable|numeric',
'kabupaten' => 'nullable|string|max:255',
'kecamatan' => 'nullable|string|max:255',
'kelurahan' => 'nullable|string|max:255',
]);
// Ambil file gambar
@ -67,6 +70,15 @@ public function classify(Request $request)
// Tambahkan informasi detail tentang penyakit
$diseaseInfo = $this->getDiseaseInfo($result['predicted_class']);
// Extract location components from address if not provided
$locationAddress = $request->input('location_address');
$locationLat = $request->input('location_lat');
$locationLng = $request->input('location_lng');
$locationComponents = $this->extractLocationComponents($locationAddress, $locationLat, $locationLng);
$kabupaten = $request->input('kabupaten') ?? $locationComponents['kabupaten'];
$kecamatan = $request->input('kecamatan') ?? $locationComponents['kecamatan'];
$kelurahan = $request->input('kelurahan') ?? $locationComponents['kelurahan'];
$savedToDatabase = true;
$persistenceWarning = null;
@ -80,12 +92,12 @@ public function classify(Request $request)
'disease_name' => $diseaseInfo['name'],
'severity' => $diseaseInfo['severity'],
'notes' => 'Classification without storage',
'location_address' => $request->input('location_address'),
'location_lat' => $request->input('location_lat'),
'location_lng' => $request->input('location_lng'),
'location_address' => $request->input('location_address'),
'location_lat' => $request->input('location_lat'),
'location_lng' => $request->input('location_lng'),
'location_address' => $locationAddress,
'location_lat' => $locationLat,
'location_lng' => $locationLng,
'kabupaten' => $kabupaten,
'kecamatan' => $kecamatan,
'kelurahan' => $kelurahan,
]);
} catch (\Throwable $dbException) {
$savedToDatabase = false;
@ -141,6 +153,9 @@ public function classifyAndSave(Request $request)
'location_address' => 'nullable|string|max:255',
'location_lat' => 'nullable|numeric',
'location_lng' => 'nullable|numeric',
'kabupaten' => 'nullable|string|max:255',
'kecamatan' => 'nullable|string|max:255',
'kelurahan' => 'nullable|string|max:255',
]);
// Simpan gambar
@ -194,6 +209,15 @@ public function classifyAndSave(Request $request)
$diseaseInfo = $this->getDiseaseInfo($result['predicted_class']);
// Extract location components from address if not provided
$locationAddress = $request->input('location_address');
$locationLat = $request->input('location_lat');
$locationLng = $request->input('location_lng');
$locationComponents = $this->extractLocationComponents($locationAddress, $locationLat, $locationLng);
$kabupaten = $request->input('kabupaten') ?? $locationComponents['kabupaten'];
$kecamatan = $request->input('kecamatan') ?? $locationComponents['kecamatan'];
$kelurahan = $request->input('kelurahan') ?? $locationComponents['kelurahan'];
$savedToDatabase = true;
$persistenceWarning = null;
@ -208,9 +232,12 @@ public function classifyAndSave(Request $request)
'disease_name' => $diseaseInfo['name'],
'severity' => $diseaseInfo['severity'],
'notes' => $request->input('notes'),
'location_address' => $request->input('location_address'),
'location_lat' => $request->input('location_lat'),
'location_lng' => $request->input('location_lng'),
'location_address' => $locationAddress,
'location_lat' => $locationLat,
'location_lng' => $locationLng,
'kabupaten' => $kabupaten,
'kecamatan' => $kecamatan,
'kelurahan' => $kelurahan,
]);
} catch (\Throwable $dbException) {
$savedToDatabase = false;
@ -257,9 +284,12 @@ public function classifyAndSave(Request $request)
ClassificationHistory::create([
'user_id' => $historyUserId,
'jenis_penyakit' => $diseaseInfo['name'] ?? $result['predicted_class'],
'location_address' => $request->input('location_address'),
'location_address' => $locationAddress,
'location_lat' => $request->input('location_lat'),
'location_lng' => $request->input('location_lng'),
'kabupaten' => $kabupaten,
'kecamatan' => $kecamatan,
'kelurahan' => $kelurahan,
]);
} catch (\Throwable $historyException) {
Log::warning('Classification history not persisted', [
@ -443,6 +473,57 @@ public function testConnection()
return $this->health();
}
/**
* Extract kabupaten, kecamatan, kelurahan dari location_address string
* Format Indonesia: "Kelurahan, Kecamatan ..., Kabupaten ..., Provinsi, Negara"
* Contoh: "Sumbersari, Kecamatan Sumbersari, Kabupaten Jember, Jawa Timur, Indonesia"
*/
private function extractLocationComponents($locationAddress, $lat = null, $lng = null)
{
$components = [
'kabupaten' => null,
'kecamatan' => null,
'kelurahan' => null,
];
if (!$locationAddress) {
return $components;
}
// Extract "Kabupaten [name]" atau "Kota [name]"
if (preg_match('/(?:Kabupaten|Kota)\s+([^,]+)/', $locationAddress, $matches)) {
$components['kabupaten'] = trim($matches[1]);
}
// Extract "Kecamatan [name]"
if (preg_match('/Kecamatan\s+([^,]+)/', $locationAddress, $matches)) {
$components['kecamatan'] = trim($matches[1]);
}
// Extract first part (before first comma) as kelurahan
$parts = explode(',', $locationAddress);
if (!empty($parts)) {
$kelurahan = trim($parts[0]);
if ($kelurahan && !preg_match('/^(Kecamatan|Kabupaten|Kota|Provinsi)/', $kelurahan)) {
$components['kelurahan'] = $kelurahan;
}
}
// Fallback: If kabupaten not found in address, use lat/lng to determine it
// For Jember area (coordinates around -8.1656155, 113.7212891)
if (!$components['kabupaten'] && $lat && $lng) {
$lat = (float) $lat;
$lng = (float) $lng;
// Jember region boundaries (approximate)
if ($lat >= -8.35 && $lat <= -7.85 && $lng >= 113.50 && $lng <= 114.00) {
$components['kabupaten'] = 'Jember';
}
}
return $components;
}
/**
* Mendapatkan informasi detail tentang penyakit
*/

View File

@ -30,13 +30,31 @@ public function index()
$totalUsers = User::count();
$recentUsers = User::orderByDesc('created_at')->take(10)->get();
// Statistik penyakit per kabupaten
$diseaseByKabupaten = \App\Models\Classification::selectRaw('kabupaten, predicted_class, count(*) as total')
->whereNotNull('kabupaten')
->groupBy('kabupaten', 'predicted_class')
->orderBy('kabupaten')
->orderByDesc('total')
->get();
// Format data untuk chart
$kabupatenDiseaseData = [];
foreach ($diseaseByKabupaten as $record) {
if (!isset($kabupatenDiseaseData[$record->kabupaten])) {
$kabupatenDiseaseData[$record->kabupaten] = [];
}
$kabupatenDiseaseData[$record->kabupaten][$record->predicted_class] = $record->total;
}
return view('dashboard', compact(
'totalDataset',
'diseaseStats',
'mostCommonDisease',
'totalProducts',
'totalUsers',
'recentUsers'
'recentUsers',
'kabupatenDiseaseData'
));
}
}

View File

@ -19,6 +19,9 @@ class Classification extends Model
'location_address',
'location_lat',
'location_lng',
'kabupaten',
'kecamatan',
'kelurahan',
];
protected $casts = [

View File

@ -16,6 +16,9 @@ class ClassificationHistory extends Model
'location_address',
'location_lat',
'location_lng',
'kabupaten',
'kecamatan',
'kelurahan',
];
protected $casts = [

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('classifications', function (Blueprint $table) {
$table->string('kabupaten')->nullable()->after('location_address');
$table->string('kecamatan')->nullable()->after('kabupaten');
$table->string('kelurahan')->nullable()->after('kecamatan');
});
Schema::table('classification_histories', function (Blueprint $table) {
$table->string('kabupaten')->nullable()->after('location_address');
$table->string('kecamatan')->nullable()->after('kabupaten');
$table->string('kelurahan')->nullable()->after('kecamatan');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('classifications', function (Blueprint $table) {
$table->dropColumn(['kabupaten', 'kecamatan', 'kelurahan']);
});
Schema::table('classification_histories', function (Blueprint $table) {
$table->dropColumn(['kabupaten', 'kecamatan', 'kelurahan']);
});
}
};

View File

@ -222,4 +222,56 @@ function toggleUserList() {
});
</script>
<!-- Statistik Penyakit Per Kabupaten -->
<div class="mt-8 bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
<h3 class="text-lg font-bold text-gray-800 mb-4">🗺️ Distribusi Penyakit Per Kabupaten</h3>
@if(count($kabupatenDiseaseData) > 0)
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-bold text-gray-700 uppercase">Kabupaten</th>
<th class="px-4 py-3 text-center text-xs font-bold text-gray-700 uppercase">Bacterial Blight</th>
<th class="px-4 py-3 text-center text-xs font-bold text-gray-700 uppercase">Brown Spot</th>
<th class="px-4 py-3 text-center text-xs font-bold text-gray-700 uppercase">Leaf Smut</th>
<th class="px-4 py-3 text-center text-xs font-bold text-gray-700 uppercase">Total</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@foreach($kabupatenDiseaseData as $kabupaten => $diseases)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm font-medium text-gray-800">{{ $kabupaten ?? 'Tidak Diketahui' }}</td>
<td class="px-4 py-3 text-center">
<span class="inline-flex items-center justify-center px-3 py-1 text-sm font-bold text-blue-700 bg-blue-100 rounded-lg">
{{ $diseases['Bacterialblight'] ?? 0 }}
</span>
</td>
<td class="px-4 py-3 text-center">
<span class="inline-flex items-center justify-center px-3 py-1 text-sm font-bold text-orange-700 bg-orange-100 rounded-lg">
{{ $diseases['Brownspot'] ?? 0 }}
</span>
</td>
<td class="px-4 py-3 text-center">
<span class="inline-flex items-center justify-center px-3 py-1 text-sm font-bold text-yellow-700 bg-yellow-100 rounded-lg">
{{ $diseases['Leafsmut'] ?? 0 }}
</span>
</td>
<td class="px-4 py-3 text-center">
<span class="inline-flex items-center justify-center px-3 py-1 text-sm font-bold text-gray-700 bg-gray-200 rounded-lg">
{{ array_sum($diseases) }}
</span>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center py-8">
<p class="text-gray-500 text-sm">Belum ada data klasifikasi dengan informasi kabupaten</p>
</div>
@endif
</div>
@endsection