menambahkan beberapa diagram admin dan user
This commit is contained in:
parent
e67063281b
commit
f2967da073
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ class Classification extends Model
|
|||
'location_address',
|
||||
'location_lat',
|
||||
'location_lng',
|
||||
'kabupaten',
|
||||
'kecamatan',
|
||||
'kelurahan',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ class ClassificationHistory extends Model
|
|||
'location_address',
|
||||
'location_lat',
|
||||
'location_lng',
|
||||
'kabupaten',
|
||||
'kecamatan',
|
||||
'kelurahan',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue