ngerjain backend

This commit is contained in:
rijalhabibullah 2026-05-05 04:11:47 +07:00
parent 8af733ff12
commit bd1112cf1a
27 changed files with 1414 additions and 70 deletions

View File

@ -1,4 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<application
android:label="padi_app"
android:name="${applicationName}"

View File

@ -2,6 +2,9 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:flutter/foundation.dart'; // Penting untuk kIsWeb
import 'package:geolocator/geolocator.dart';
import 'package:geocoding/geocoding.dart';
import 'package:shared_preferences/shared_preferences.dart';
// Pastikan file result_screen.dart sudah kamu buat di folder lib/screen/
import 'screen/result_screen.dart';
@ -23,7 +26,11 @@ class _HomeScreenState extends State<HomeScreen> {
String? _errorMessage;
Future<void> _getImage(ImageSource source) async {
final pickedFile = await _picker.pickImage(source: source);
final pickedFile = await _picker.pickImage(
source: source,
imageQuality: 85,
maxWidth: 1280,
);
if (pickedFile != null) {
setState(() {
if (kIsWeb) {
@ -92,8 +99,23 @@ class _HomeScreenState extends State<HomeScreen> {
});
try {
final location = await _resolveLocation();
final imageFile = _image ?? File(_webImage!.path);
final result = await _classificationService.classifyAndSave(imageFile);
final result = await _classificationService.classifyAndSave(
imageFile,
locationAddress: location?.address,
locationLat: location?.lat,
locationLng: location?.lng,
);
// Validasi: cek apakah confidence cukup tinggi (artinya itu daun padi)
const double confidenceThreshold = 0.85; // 85% minimum - sangat ketat
if (result.confidenceValue < confidenceThreshold) {
if (mounted) {
_showNotRiceLeafWarning(context);
}
return;
}
if (mounted) {
Navigator.push(
@ -104,9 +126,18 @@ class _HomeScreenState extends State<HomeScreen> {
);
}
} catch (e) {
setState(() {
_errorMessage = e.toString().replaceAll('Exception: ', '');
});
final errorMsg = e.toString().replaceAll('Exception: ', '');
// Cek apakah error dari backend tentang bukan daun padi
if (errorMsg.contains('Bukan daun padi')) {
if (mounted) {
_showNotRiceLeafWarning(context);
}
} else {
setState(() {
_errorMessage = errorMsg;
});
}
} finally {
if (mounted) {
setState(() {
@ -116,6 +147,97 @@ class _HomeScreenState extends State<HomeScreen> {
}
}
Future<_LocationPayload?> _resolveLocation() async {
if (kIsWeb) {
return null;
}
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
return null;
}
var permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
return null;
}
final position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
);
final placemarks = await placemarkFromCoordinates(
position.latitude,
position.longitude,
);
String? address;
if (placemarks.isNotEmpty) {
final place = placemarks.first;
final parts = [
place.street,
place.subLocality,
place.locality,
place.administrativeArea,
place.country,
].where((part) => part != null && part!.trim().isNotEmpty)
.map((part) => part!.trim())
.toList();
if (parts.isNotEmpty) {
address = parts.join(', ');
}
}
if (address != null) {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('user_location', address);
await prefs.setDouble('user_location_lat', position.latitude);
await prefs.setDouble('user_location_lng', position.longitude);
}
return _LocationPayload(
address: address,
lat: position.latitude,
lng: position.longitude,
);
}
void _showNotRiceLeafWarning(BuildContext context) {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Row(
children: [
Icon(Icons.warning_amber_rounded, color: Colors.orange, size: 28),
SizedBox(width: 10),
Text('Bukan Daun Padi'),
],
),
content: const Text(
'Foto yang Anda upload terdeteksi bukan daun padi. '
'Silahkan upload foto daun padi yang jelas dan bagus untuk hasil deteksi penyakit yang akurat.',
style: TextStyle(fontSize: 15),
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Kembali ke Upload'),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -286,4 +408,16 @@ class _HomeScreenState extends State<HomeScreen> {
),
);
}
}
class _LocationPayload {
final String? address;
final double lat;
final double lng;
const _LocationPayload({
required this.address,
required this.lat,
required this.lng,
});
}

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:geolocator/geolocator.dart';
import 'dashboard_klasifikasi.dart';
import 'screen/history_screen.dart';
import 'screen/ecommerce_screen.dart';
@ -109,6 +111,14 @@ class _MainNavigationState extends State<MainNavigation> {
final PageController _controller = PageController(initialPage: 1);
int _currentIndex = 1; // Track halaman yang sedang aktif
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_promptLocationPermission();
});
}
@override
void dispose() {
_controller.dispose();
@ -129,6 +139,80 @@ class _MainNavigationState extends State<MainNavigation> {
);
}
Future<void> _promptLocationPermission() async {
if (kIsWeb) {
return;
}
var permission = await Geolocator.checkPermission();
if (permission != LocationPermission.always &&
permission != LocationPermission.whileInUse) {
permission = await Geolocator.requestPermission();
}
if (!mounted) return;
if (permission == LocationPermission.deniedForever) {
await showDialog<void>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Izin Lokasi Ditolak'),
content: const Text(
'Silakan aktifkan izin lokasi di Pengaturan jika ingin '
'menyimpan lokasi secara otomatis.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Tutup'),
),
ElevatedButton(
onPressed: () async {
await Geolocator.openAppSettings();
if (!context.mounted) return;
Navigator.pop(context);
},
child: const Text('Buka Pengaturan'),
),
],
);
},
);
return;
}
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
if (!mounted) return;
final openSettings = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Aktifkan Lokasi'),
content: const Text(
'Lokasi perangkat sedang mati. Aktifkan agar lokasi tersimpan '
'real time. Kamu tetap bisa klasifikasi tanpa lokasi.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Nanti'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Buka Pengaturan'),
),
],
);
},
);
if (openSettings == true) {
await Geolocator.openLocationSettings();
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(

View File

@ -1,22 +1,114 @@
import 'package:flutter/material.dart';
import '../services/classification_service.dart';
class HistoryScreen extends StatelessWidget {
class HistoryScreen extends StatefulWidget {
const HistoryScreen({super.key});
@override
State<HistoryScreen> createState() => _HistoryScreenState();
}
class _HistoryScreenState extends State<HistoryScreen> {
final ClassificationService _classificationService = ClassificationService();
late Future<List<ClassificationHistoryItem>> _historyFuture;
@override
void initState() {
super.initState();
_historyFuture = _classificationService.fetchHistory();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.grey[100],
appBar: AppBar(
title: const Text(
"Riwayat Klasifikasi",
'Riwayat Klasifikasi',
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white),
),
backgroundColor: const Color(0xFF0F703A),
centerTitle: true,
elevation: 0,
),
body: const Center(child: Text("Halaman Riwayat Siap Dihubungkan ke MySQL")),
body: FutureBuilder<List<ClassificationHistoryItem>>(
future: _historyFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Text(
'Gagal memuat riwayat: ${snapshot.error}',
textAlign: TextAlign.center,
),
);
}
final items = snapshot.data ?? [];
if (items.isEmpty) {
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),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.diseaseName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 6),
Text('Kelas: ${item.predictedClass}'),
Text('Confidence: ${item.confidence}'),
Text(
'Lokasi: ${item.locationAddress ?? 'Alamat tidak terdeteksi'}',
),
const SizedBox(height: 6),
Text(
_formatTimestamp(item.createdAt),
style: const TextStyle(color: Colors.black54),
),
],
),
);
},
);
},
),
);
}
String _formatTimestamp(DateTime value) {
final twoDigits = (int n) => n.toString().padLeft(2, '0');
final day = twoDigits(value.day);
final month = twoDigits(value.month);
final year = value.year.toString();
final hour = twoDigits(value.hour);
final minute = twoDigits(value.minute);
return '$day-$month-$year $hour:$minute';
}
}

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import '../main.dart'; // Mengarah ke MainNavigation di main.dart
import 'register_screen.dart';
@ -13,17 +15,70 @@ class LoginScreen extends StatefulWidget {
class _LoginScreenState extends State<LoginScreen> {
final TextEditingController _usernameController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
bool _isPasswordVisible = false;
// FUNGSI LOGIN TEGAS: Simpan status & Pindah ke Navigasi Utama
void _login() async {
SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setBool('isLoggedIn', true); // Simpan status login permanen
final username = _usernameController.text.trim();
final password = _passwordController.text;
if (!mounted) return;
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const MainNavigation()),
);
if (username.isEmpty || password.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Username dan password wajib diisi'),
backgroundColor: Colors.red,
),
);
return;
}
try {
final response = await http
.post(
Uri.parse('https://gobony-wedgy-cathi.ngrok-free.dev/api/mobile/login'),
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: jsonEncode({
'username': username,
'password': password,
}),
)
.timeout(const Duration(seconds: 15));
if (response.statusCode != 200) {
throw Exception('Login gagal');
}
final jsonResponse = jsonDecode(response.body);
if (jsonResponse['success'] != true) {
throw Exception(jsonResponse['message'] ?? 'Login gagal');
}
final data = jsonResponse['data'] ?? {};
SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setBool('isLoggedIn', true);
await prefs.setInt('user_id', data['user_id'] ?? 0);
await prefs.setString('user_name', data['name'] ?? username);
if (data['email'] != null) {
await prefs.setString('user_email', data['email'] ?? '');
}
if (!mounted) return;
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const MainNavigation()),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Login gagal: ${e.toString().replaceAll('Exception: ', '')}'),
backgroundColor: Colors.red,
),
);
}
}
@override
@ -39,7 +94,17 @@ class _LoginScreenState extends State<LoginScreen> {
const SizedBox(height: 40),
_buildTextField(hintText: 'Username', controller: _usernameController, obscureText: false),
const SizedBox(height: 20),
_buildTextField(hintText: 'Password', controller: _passwordController, obscureText: true),
_buildTextField(
hintText: 'Password',
controller: _passwordController,
obscureText: !_isPasswordVisible,
showToggle: true,
onToggle: () {
setState(() {
_isPasswordVisible = !_isPasswordVisible;
});
},
),
const SizedBox(height: 40),
_buildLoginButton(),
const SizedBox(height: 25),
@ -113,7 +178,13 @@ class _LoginScreenState extends State<LoginScreen> {
);
}
Widget _buildTextField({required String hintText, required TextEditingController controller, required bool obscureText}) {
Widget _buildTextField({
required String hintText,
required TextEditingController controller,
required bool obscureText,
bool showToggle = false,
VoidCallback? onToggle,
}) {
return Container(
width: 250,
decoration: BoxDecoration(
@ -129,6 +200,15 @@ class _LoginScreenState extends State<LoginScreen> {
hintText: hintText,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(25), borderSide: BorderSide.none),
contentPadding: const EdgeInsets.symmetric(vertical: 16),
suffixIcon: showToggle
? IconButton(
onPressed: onToggle,
icon: Icon(
obscureText ? Icons.visibility_off : Icons.visibility,
color: Colors.grey,
),
)
: null,
),
),
);

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:geolocator/geolocator.dart';
import 'package:geocoding/geocoding.dart';
class ProfileScreen extends StatefulWidget {
const ProfileScreen({super.key});
@ -9,10 +11,199 @@ class ProfileScreen extends StatefulWidget {
}
class _ProfileScreenState extends State<ProfileScreen> {
String _userName = "Petani Padi";
String _userEmail = "petani@agripadi.com";
String _userPhone = "0812-3456-7890";
String _userLocation = "Jawa Barat";
String _userName = "";
String _userEmail = "";
String _userPhone = "";
String _userLocation = "";
@override
void initState() {
super.initState();
_loadProfile();
_tryAutoFillLocation();
}
Future<void> _loadProfile() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
_userName = prefs.getString('user_name') ?? '';
_userEmail = prefs.getString('user_email') ?? '';
_userPhone = prefs.getString('user_phone') ?? '';
_userLocation = prefs.getString('user_location') ?? '';
});
}
Future<void> _tryAutoFillLocation() async {
if (_userLocation.isNotEmpty) {
return;
}
final address = await _fetchCurrentAddress();
if (address == null) {
return;
}
final prefs = await SharedPreferences.getInstance();
await prefs.setString('user_location', address);
if (mounted) {
setState(() {
_userLocation = address;
});
}
}
Future<String?> _fetchCurrentAddress() async {
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
return null;
}
var permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
return null;
}
final position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
);
final placemarks = await placemarkFromCoordinates(
position.latitude,
position.longitude,
);
if (placemarks.isEmpty) {
return null;
}
final place = placemarks.first;
final parts = [
place.street,
place.subLocality,
place.locality,
place.administrativeArea,
place.country,
].where((part) => part != null && part!.trim().isNotEmpty)
.map((part) => part!.trim())
.toList();
if (parts.isEmpty) {
return null;
}
return parts.join(', ');
}
void _openEditProfile() {
final phoneController = TextEditingController(text: _userPhone);
final locationController = TextEditingController(text: _userLocation);
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) {
return Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Edit Profil',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
TextField(
controller: phoneController,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
labelText: 'No. Telepon',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: locationController,
decoration: const InputDecoration(
labelText: 'Lokasi',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 10),
TextButton.icon(
onPressed: () async {
final address = await _fetchCurrentAddress();
if (address == null) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Lokasi tidak terdeteksi'),
backgroundColor: Colors.red,
),
);
return;
}
locationController.text = address;
},
icon: const Icon(Icons.my_location),
label: const Text('Gunakan lokasi saat ini'),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
'user_phone',
phoneController.text.trim(),
);
await prefs.setString(
'user_location',
locationController.text.trim(),
);
if (mounted) {
setState(() {
_userPhone = phoneController.text.trim();
_userLocation = locationController.text.trim();
});
}
if (!mounted) return;
Navigator.pop(context);
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0F703A),
),
child: const Text(
'Simpan',
style: TextStyle(color: Colors.white),
),
),
),
],
),
],
),
);
},
);
}
Future<void> _logout() async {
showDialog(
@ -29,6 +220,11 @@ class _ProfileScreenState extends State<ProfileScreen> {
onPressed: () async {
SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setBool('isLoggedIn', false);
await prefs.remove('user_id');
await prefs.remove('user_name');
await prefs.remove('user_email');
await prefs.remove('user_phone');
await prefs.remove('user_location');
if (mounted) {
Navigator.pushNamedAndRemoveUntil(context, '/', (route) => false);
}
@ -91,7 +287,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
const SizedBox(height: 20),
// Nama User
Text(
_userName,
_userName.isNotEmpty ? _userName : 'Pengguna',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
@ -100,7 +296,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
),
const SizedBox(height: 5),
Text(
"Petani Padi",
_userEmail.isNotEmpty ? _userEmail : 'Akun pengguna',
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
),
],
@ -122,18 +318,22 @@ class _ProfileScreenState extends State<ProfileScreen> {
icon: Icons.email,
label: "Email",
value: _userEmail,
placeholder: 'Tambahkan email',
),
const Divider(height: 20),
_buildProfileField(
icon: Icons.phone,
label: "No. Telepon",
value: _userPhone,
placeholder: 'Tambahkan no telepon',
),
const Divider(height: 20),
_buildProfileField(
icon: Icons.location_on,
label: "Lokasi",
value: _userLocation,
placeholder: 'Tambahkan lokasi',
maxLines: 3,
),
],
),
@ -156,12 +356,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
),
),
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Fitur edit profil akan segera tersedia'),
backgroundColor: Colors.blue,
),
);
_openEditProfile();
},
),
),
@ -222,27 +417,38 @@ class _ProfileScreenState extends State<ProfileScreen> {
required IconData icon,
required String label,
required String value,
required String placeholder,
int? maxLines,
}) {
final displayValue = value.isNotEmpty ? value : placeholder;
final isPlaceholder = value.isEmpty;
return Row(
children: [
Icon(icon, color: const Color(0xFF0F703A), size: 24),
const SizedBox(width: 15),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
),
],
const SizedBox(height: 4),
Text(
displayValue,
maxLines: maxLines,
overflow: maxLines != null ? TextOverflow.ellipsis : null,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isPlaceholder ? Colors.grey[500] : Colors.black,
fontStyle: isPlaceholder ? FontStyle.italic : FontStyle.normal,
),
),
],
),
),
],
);

View File

@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
// import 'login_screen.dart'; // Nanti ini dipakai untuk kembali ke halaman login
class RegisterScreen extends StatefulWidget {
@ -12,18 +14,72 @@ class _RegisterScreenState extends State<RegisterScreen> {
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
bool _isPasswordVisible = false;
bool _isConfirmPasswordVisible = false;
void _register() {
// TODO: Tambahkan logika pendaftaran ke database di sini
// Contoh jika register berhasil, munculkan pesan dan kembali ke Login
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Registrasi Berhasil! Silakan Login.'),
backgroundColor: Color(0xFF0F703A), // Hijau sesuai tema
),
);
Navigator.pop(context); // Kembali ke halaman Login
void _register() async {
final username = _usernameController.text.trim();
final password = _passwordController.text;
final confirmPassword = _confirmPasswordController.text;
if (username.isEmpty || password.isEmpty || confirmPassword.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Semua field wajib diisi'),
backgroundColor: Colors.red,
),
);
return;
}
if (password != confirmPassword) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Konfirmasi password tidak cocok'),
backgroundColor: Colors.red,
),
);
return;
}
try {
final response = await http
.post(
Uri.parse('https://gobony-wedgy-cathi.ngrok-free.dev/api/mobile/register'),
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: jsonEncode({
'username': username,
'password': password,
'password_confirmation': confirmPassword,
}),
)
.timeout(const Duration(seconds: 15));
if (response.statusCode != 201) {
final jsonResponse = jsonDecode(response.body);
throw Exception(jsonResponse['message'] ?? 'Registrasi gagal');
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Registrasi berhasil! Silakan login.'),
backgroundColor: Color(0xFF0F703A),
),
);
Navigator.pop(context);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Registrasi gagal: ${e.toString().replaceAll('Exception: ', '')}'),
backgroundColor: Colors.red,
),
);
}
}
@override
@ -63,7 +119,13 @@ class _RegisterScreenState extends State<RegisterScreen> {
_buildTextField(
hintText: 'Password',
controller: _passwordController,
obscureText: true, // Teks disembunyikan
obscureText: !_isPasswordVisible,
showToggle: true,
onToggle: () {
setState(() {
_isPasswordVisible = !_isPasswordVisible;
});
},
),
const SizedBox(height: 20),
@ -71,7 +133,13 @@ class _RegisterScreenState extends State<RegisterScreen> {
_buildTextField(
hintText: 'Konfirmasi Password',
controller: _confirmPasswordController,
obscureText: true, // Teks disembunyikan
obscureText: !_isConfirmPasswordVisible,
showToggle: true,
onToggle: () {
setState(() {
_isConfirmPasswordVisible = !_isConfirmPasswordVisible;
});
},
),
const SizedBox(height: 50),
@ -131,6 +199,8 @@ class _RegisterScreenState extends State<RegisterScreen> {
required String hintText,
required TextEditingController controller,
required bool obscureText,
bool showToggle = false,
VoidCallback? onToggle,
}) {
return Container(
decoration: BoxDecoration(
@ -155,6 +225,15 @@ class _RegisterScreenState extends State<RegisterScreen> {
borderSide: BorderSide.none, // Menghilangkan garis pinggir bawaan
),
contentPadding: const EdgeInsets.symmetric(vertical: 16),
suffixIcon: showToggle
? IconButton(
onPressed: onToggle,
icon: Icon(
obscureText ? Icons.visibility_off : Icons.visibility,
color: Colors.grey,
),
)
: null,
),
),
);

View File

@ -2,6 +2,7 @@ import 'package:http/http.dart' as http;
import 'dart:convert';
import 'dart:io';
import 'dart:async';
import 'package:shared_preferences/shared_preferences.dart';
class ClassificationService {
// ⚠️ UBAH URL INI SESUAI ENVIRONMENT
@ -9,7 +10,9 @@ class ClassificationService {
// Device Fisik (WiFi): http://192.168.x.x:8000/api/classification (ganti dengan IP komputer Anda)
// Testing Lokal: http://127.0.0.1:8000/api/classification
// Production (ngrok): https://gobony-wedgy-cathi.ngrok-free.dev/api/classification
static const String _baseUrl = 'https://gobony-wedgy-cathi.ngrok-free.dev/api/classification';
static const String _apiRoot = 'https://gobony-wedgy-cathi.ngrok-free.dev/api';
static const String _baseUrl = '$_apiRoot/classification';
static const String _historyUrl = '$_apiRoot/classifications';
final http.Client _httpClient;
@ -20,6 +23,17 @@ class ClassificationService {
Future<ClassificationResult> classifyImage(File imageFile) async {
try {
final request = http.MultipartRequest('POST', Uri.parse('$_baseUrl/classify'));
request.headers['Accept'] = 'application/json';
final prefs = await SharedPreferences.getInstance();
final userId = prefs.getInt('user_id');
final userName = prefs.getString('user_name');
if (userId != null && userId > 0) {
request.fields['user_id'] = userId.toString();
}
if (userName != null && userName.trim().isNotEmpty) {
request.fields['user_name'] = userName.trim();
}
request.files.add(
await http.MultipartFile.fromPath('image', imageFile.path),
@ -37,6 +51,23 @@ class ClassificationService {
if (response.statusCode == 200 && jsonResponse['success'] == true) {
return ClassificationResult.fromJson(jsonResponse['data']);
} else if (response.statusCode == 422 && jsonResponse['errors'] is Map) {
final errors = Map<String, dynamic>.from(jsonResponse['errors']);
final errorMessage = errors.entries
.expand((entry) {
final value = entry.value;
if (value is List) {
return value.map((item) => '$item');
}
return ['${entry.key}: $value'];
})
.join('\n');
throw Exception(
jsonResponse['message'] != null
? '${jsonResponse['message']}\n$errorMessage'
: errorMessage,
);
} else {
throw Exception(jsonResponse['message'] ?? 'Klasifikasi gagal');
}
@ -53,12 +84,22 @@ class ClassificationService {
Future<ClassificationResult> classifyAndSave(
File imageFile, {
String? notes,
String? locationAddress,
double? locationLat,
double? locationLng,
}) async {
try {
final request = http.MultipartRequest(
'POST',
Uri.parse('$_baseUrl/classify-and-save'),
);
request.headers['Accept'] = 'application/json';
final prefs = await SharedPreferences.getInstance();
final userId = prefs.getInt('user_id');
if (userId != null && userId > 0) {
request.fields['user_id'] = userId.toString();
}
request.files.add(
await http.MultipartFile.fromPath('image', imageFile.path),
@ -67,6 +108,16 @@ class ClassificationService {
if (notes != null && notes.isNotEmpty) {
request.fields['notes'] = notes;
}
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();
}
final response = await request.send().timeout(
const Duration(seconds: 30),
@ -108,6 +159,51 @@ class ClassificationService {
return false;
}
}
/// Ambil riwayat klasifikasi
Future<List<ClassificationHistoryItem>> fetchHistory({int page = 1}) async {
try {
final prefs = await SharedPreferences.getInstance();
final userId = prefs.getInt('user_id');
final userParam = (userId != null && userId > 0)
? '&user_id=${userId.toString()}'
: '';
final response = await _httpClient
.get(
Uri.parse('$_historyUrl?page=$page$userParam'),
headers: {'Accept': 'application/json'},
)
.timeout(const Duration(seconds: 15));
if (response.statusCode != 200) {
throw Exception('Gagal mengambil riwayat');
}
final jsonResponse = jsonDecode(response.body);
if (jsonResponse['success'] != true) {
throw Exception(jsonResponse['message'] ?? 'Gagal mengambil riwayat');
}
final data = jsonResponse['data'];
final items = (data is Map<String, dynamic>) ? data['data'] : null;
if (items is! List) {
return [];
}
return items
.map((item) => ClassificationHistoryItem.fromJson(
Map<String, dynamic>.from(item),
))
.toList();
} on TimeoutException catch (e) {
throw Exception(e.message);
} on SocketException {
throw Exception('Network error');
} catch (e) {
throw Exception('Error: $e');
}
}
}
class ClassificationResult {
@ -176,3 +272,57 @@ class DiseaseInfo {
);
}
}
class ClassificationHistoryItem {
final int id;
final String diseaseName;
final String predictedClass;
final double confidenceValue;
final String confidence;
final String? imagePath;
final String? locationAddress;
final double? locationLat;
final double? locationLng;
final DateTime createdAt;
ClassificationHistoryItem({
required this.id,
required this.diseaseName,
required this.predictedClass,
required this.confidenceValue,
required this.confidence,
required this.createdAt,
this.imagePath,
this.locationAddress,
this.locationLat,
this.locationLng,
});
factory ClassificationHistoryItem.fromJson(Map<String, dynamic> json) {
final confidenceValue = (json['confidence'] is num)
? (json['confidence'] as num).toDouble()
: 0.0;
final locationLat = (json['location_lat'] is num)
? (json['location_lat'] as num).toDouble()
: null;
final locationLng = (json['location_lng'] is num)
? (json['location_lng'] as num).toDouble()
: null;
return ClassificationHistoryItem(
id: json['id'] ?? 0,
diseaseName: json['disease_name'] ?? 'Unknown',
predictedClass: json['predicted_class'] ?? '',
confidenceValue: confidenceValue,
confidence: '${(confidenceValue * 100).toStringAsFixed(2)}%',
imagePath: json['image_path'],
locationAddress: json['location_address'],
locationLat: locationLat,
locationLng: locationLng,
createdAt: DateTime.parse(
json['created_at'] ?? DateTime.now().toIso8601String(),
),
);
}
}

View File

@ -6,11 +6,13 @@ import FlutterMacOS
import Foundation
import file_selector_macos
import geolocator_apple
import shared_preferences_foundation
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}

View File

@ -49,6 +49,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.3.4+2"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
@ -113,6 +121,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.9.3+4"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
@ -144,6 +160,86 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
geocoding:
dependency: "direct main"
description:
name: geocoding
sha256: d580c801cba9386b4fac5047c4c785a4e19554f46be42f4f5e5b7deacd088a66
url: "https://pub.dev"
source: hosted
version: "3.0.0"
geocoding_android:
dependency: transitive
description:
name: geocoding_android
sha256: "1b13eca79b11c497c434678fed109c2be020b158cec7512c848c102bc7232603"
url: "https://pub.dev"
source: hosted
version: "3.3.1"
geocoding_ios:
dependency: transitive
description:
name: geocoding_ios
sha256: "18ab1c8369e2b0dcb3a8ccc907319334f35ee8cf4cfef4d9c8e23b13c65cb825"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
geocoding_platform_interface:
dependency: transitive
description:
name: geocoding_platform_interface
sha256: "8c2c8226e5c276594c2e18bfe88b19110ed770aeb7c1ab50ede570be8b92229b"
url: "https://pub.dev"
source: hosted
version: "3.2.0"
geolocator:
dependency: "direct main"
description:
name: geolocator
sha256: f4efb8d3c4cdcad2e226af9661eb1a0dd38c71a9494b22526f9da80ab79520e5
url: "https://pub.dev"
source: hosted
version: "10.1.1"
geolocator_android:
dependency: transitive
description:
name: geolocator_android
sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d
url: "https://pub.dev"
source: hosted
version: "4.6.2"
geolocator_apple:
dependency: transitive
description:
name: geolocator_apple
sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22
url: "https://pub.dev"
source: hosted
version: "2.3.13"
geolocator_platform_interface:
dependency: transitive
description:
name: geolocator_platform_interface
sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67"
url: "https://pub.dev"
source: hosted
version: "4.2.6"
geolocator_web:
dependency: transitive
description:
name: geolocator_web
sha256: "102e7da05b48ca6bf0a5bda0010f886b171d1a08059f01bfe02addd0175ebece"
url: "https://pub.dev"
source: hosted
version: "2.2.1"
geolocator_windows:
dependency: transitive
description:
name: geolocator_windows
sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6"
url: "https://pub.dev"
source: hosted
version: "0.2.5"
http:
dependency: "direct main"
description:
@ -573,6 +669,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.4"
uuid:
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_math:
dependency: transitive
description:

View File

@ -35,6 +35,8 @@ dependencies:
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
image_picker: ^1.2.0
geolocator: ^10.1.0
geocoding: ^3.0.0
http: ^1.6.0
permission_handler: ^11.4.0
smooth_page_indicator: 1.1.0

View File

@ -7,12 +7,15 @@
#include "generated_plugin_registrant.h"
#include <file_selector_windows/file_selector_windows.h>
#include <geolocator_windows/geolocator_windows.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
GeolocatorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("GeolocatorWindows"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(

View File

@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
geolocator_windows
permission_handler_windows
url_launcher_windows
)

View File

@ -2,8 +2,11 @@
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class AuthController extends Controller
{
@ -42,4 +45,82 @@ public function logout(Request $request)
$request->session()->regenerateToken();
return redirect('/');
}
// 4. Mobile Login
public function mobileLogin(Request $request)
{
$validated = $request->validate([
'username' => ['required', 'string', 'max:255'],
'password' => ['required', 'string', 'min:4'],
]);
$username = trim($validated['username']);
$password = $validated['password'];
$user = User::where('name', $username)->first();
if (!$user || !Hash::check($password, $user->password)) {
return response()->json([
'success' => false,
'message' => 'Username atau password salah',
], 401);
}
return response()->json([
'success' => true,
'message' => 'Login mobile berhasil',
'data' => [
'user_id' => $user->id,
'name' => $user->name,
'email' => $user->email,
],
], 200);
}
// 5. Mobile Register
public function mobileRegister(Request $request)
{
$validated = $request->validate([
'username' => ['required', 'string', 'max:255'],
'password' => ['required', 'string', 'min:4'],
'password_confirmation' => ['required', 'same:password'],
]);
$username = trim($validated['username']);
$password = $validated['password'];
if (User::where('name', $username)->exists()) {
return response()->json([
'success' => false,
'message' => 'Username sudah digunakan',
], 409);
}
$baseSlug = Str::slug($username, '.');
if ($baseSlug === '') {
$baseSlug = 'user';
}
$email = $baseSlug . '@agripadi.local';
$counter = 1;
while (User::where('email', $email)->exists()) {
$email = $baseSlug . $counter . '@agripadi.local';
$counter++;
}
$user = User::create([
'name' => $username,
'email' => $email,
'password' => Hash::make($password),
'role' => 'user',
]);
return response()->json([
'success' => true,
'message' => 'Registrasi berhasil',
'data' => [
'user_id' => $user->id,
'name' => $user->name,
'email' => $user->email,
],
], 201);
}
}

View File

@ -5,7 +5,11 @@
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use App\Models\Classification;
use App\Models\ClassificationHistory;
use App\Models\User;
use App\Services\PythonClassificationService;
class ClassificationController extends Controller
@ -25,6 +29,9 @@ public function classify(Request $request)
// Validasi input
$request->validate([
'image' => 'required|image|mimes:jpeg,png,jpg,gif|max:5120', // Max 5MB
'location_address' => 'nullable|string|max:255',
'location_lat' => 'nullable|numeric',
'location_lng' => 'nullable|numeric',
]);
// Ambil file gambar
@ -48,6 +55,15 @@ public function classify(Request $request)
], 500);
}
$minLeafiness = 0.12;
if (isset($result['leafiness']) && $result['leafiness'] < $minLeafiness) {
return response()->json([
'success' => false,
'message' => 'Bukan daun padi - Foto yang Anda upload terdeteksi bukan daun padi. Silahkan upload foto daun padi yang sesuai.',
'is_not_rice_leaf' => true,
], 422);
}
// Tambahkan informasi detail tentang penyakit
$diseaseInfo = $this->getDiseaseInfo($result['predicted_class']);
@ -56,6 +72,7 @@ public function classify(Request $request)
try {
Classification::create([
'user_id' => $request->input('user_id'),
'filename' => $file->getClientOriginalName(),
'predicted_class' => $result['predicted_class'],
'confidence' => $result['confidence'],
@ -63,6 +80,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'),
]);
} catch (\Throwable $dbException) {
$savedToDatabase = false;
@ -112,7 +135,12 @@ public function classifyAndSave(Request $request)
// Validasi input
$request->validate([
'image' => 'required|image|mimes:jpeg,png,jpg,gif|max:5120',
'notes' => 'nullable|string|max:500'
'notes' => 'nullable|string|max:500',
'user_id' => 'nullable|exists:users,id',
'user_name' => 'nullable|string|max:255',
'location_address' => 'nullable|string|max:255',
'location_lat' => 'nullable|numeric',
'location_lng' => 'nullable|numeric',
]);
// Simpan gambar
@ -139,6 +167,31 @@ public function classifyAndSave(Request $request)
'error' => $result['message'] ?? 'Unknown error',
], 500);
}
$minLeafiness = 0.12;
if (isset($result['leafiness']) && $result['leafiness'] < $minLeafiness) {
Storage::disk('public')->delete($storagePath);
return response()->json([
'success' => false,
'message' => 'Bukan daun padi - Foto yang Anda upload terdeteksi bukan daun padi. Silahkan upload foto daun padi yang sesuai.',
'is_not_rice_leaf' => true,
], 422);
}
// Validasi confidence - jika rendah berarti bukan daun padi
$minConfidence = 0.80; // 80% minimum confidence
if ($result['confidence'] < $minConfidence) {
// Hapus file yang sudah disimpan jika confidence terlalu rendah
Storage::disk('public')->delete($storagePath);
return response()->json([
'success' => false,
'message' => 'Bukan daun padi - Foto yang Anda upload terdeteksi bukan daun padi. Silahkan upload foto daun padi yang jelas dan bagus.',
'is_not_rice_leaf' => true,
], 422);
}
$diseaseInfo = $this->getDiseaseInfo($result['predicted_class']);
$savedToDatabase = true;
@ -146,6 +199,7 @@ public function classifyAndSave(Request $request)
try {
Classification::create([
'user_id' => $request->input('user_id'),
'image_path' => $storagePath,
'filename' => $file->getClientOriginalName(),
'predicted_class' => $result['predicted_class'],
@ -154,6 +208,9 @@ 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'),
]);
} catch (\Throwable $dbException) {
$savedToDatabase = false;
@ -165,6 +222,53 @@ public function classifyAndSave(Request $request)
]);
}
$historyUserId = null;
if ($request->filled('user_id')) {
$historyUserId = (int) $request->input('user_id');
} elseif ($request->filled('user_name')) {
$userName = trim((string) $request->input('user_name'));
if ($userName !== '') {
$baseSlug = Str::slug($userName, '.');
if ($baseSlug === '') {
$baseSlug = 'user';
}
$email = $baseSlug . '@agripadi.local';
$counter = 1;
while (User::where('email', $email)->exists()) {
$email = $baseSlug . $counter . '@agripadi.local';
$counter++;
}
$user = User::firstOrCreate(
['name' => $userName],
[
'email' => $email,
'password' => Hash::make(Str::random(12)),
'role' => 'user',
]
);
$historyUserId = $user->id;
}
}
if ($historyUserId) {
try {
ClassificationHistory::create([
'user_id' => $historyUserId,
'jenis_penyakit' => $diseaseInfo['name'] ?? $result['predicted_class'],
'location_address' => $request->input('location_address'),
'location_lat' => $request->input('location_lat'),
'location_lng' => $request->input('location_lng'),
]);
} catch (\Throwable $historyException) {
Log::warning('Classification history not persisted', [
'user_id' => $historyUserId,
'error' => $historyException->getMessage(),
]);
}
}
return response()->json([
'success' => true,
'message' => 'Gambar berhasil diklasifikasi dan disimpan',
@ -178,6 +282,9 @@ public function classifyAndSave(Request $request)
'saved_to_database' => $savedToDatabase,
'persistence_warning' => $persistenceWarning,
'notes' => $request->input('notes'),
'location_address' => $request->input('location_address'),
'location_lat' => $request->input('location_lat'),
'location_lng' => $request->input('location_lng'),
'timestamp' => now(),
]
], 200);

View File

@ -12,8 +12,15 @@ public function index(Request $request)
{
$query = ClassificationHistory::with('user')->latest();
if ($request->filled('jenis_penyakit')) {
$query->where('jenis_penyakit', 'like', '%' . $request->jenis_penyakit . '%');
if ($request->filled('keyword')) {
$keyword = $request->keyword;
$query->where(function ($builder) use ($keyword) {
$builder->where('jenis_penyakit', 'like', '%' . $keyword . '%')
->orWhere('location_address', 'like', '%' . $keyword . '%')
->orWhereHas('user', function ($userQuery) use ($keyword) {
$userQuery->where('name', 'like', '%' . $keyword . '%');
});
});
}
$histories = $query->paginate(10)->withQueryString();

View File

@ -11,10 +11,15 @@ class ClassificationHistoryController extends Controller
* Get semua classification history
* GET /api/classifications
*/
public function index()
public function index(Request $request)
{
$classifications = Classification::orderBy('created_at', 'desc')
->paginate(15);
$query = Classification::orderBy('created_at', 'desc');
if ($request->filled('user_id')) {
$query->where('user_id', (int) $request->input('user_id'));
}
$classifications = $query->paginate(15);
return response()->json([
'success' => true,

View File

@ -4,6 +4,7 @@
use App\Models\Dataset;
use App\Models\Product;
use App\Models\User;
use Illuminate\Http\Request;
class DashboardController extends Controller
@ -24,12 +25,18 @@ public function index()
// Total produk
$totalProducts = Product::count();
// Total user dan user terbaru
$totalUsers = User::count();
$recentUsers = User::orderByDesc('created_at')->take(10)->get();
return view('dashboard', compact(
'totalDataset',
'diseaseStats',
'mostCommonDisease',
'totalProducts'
'totalProducts',
'totalUsers',
'recentUsers'
));
}
}

View File

@ -7,6 +7,7 @@
class Classification extends Model
{
protected $fillable = [
'user_id',
'image_path',
'filename',
'predicted_class',
@ -15,9 +16,15 @@ class Classification extends Model
'disease_name',
'severity',
'notes',
'location_address',
'location_lat',
'location_lng',
];
protected $casts = [
'user_id' => 'integer',
'location_lat' => 'float',
'location_lng' => 'float',
'all_predictions' => 'array',
'confidence' => 'float',
'created_at' => 'datetime',

View File

@ -13,6 +13,14 @@ class ClassificationHistory extends Model
protected $fillable = [
'user_id',
'jenis_penyakit',
'location_address',
'location_lat',
'location_lng',
];
protected $casts = [
'location_lat' => 'float',
'location_lng' => 'float',
];
public function user(): BelongsTo

View File

@ -0,0 +1,33 @@
<?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->foreignId('user_id')
->nullable()
->after('id')
->constrained()
->nullOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('classifications', function (Blueprint $table) {
$table->dropForeign(['user_id']);
$table->dropColumn('user_id');
});
}
};

View File

@ -0,0 +1,30 @@
<?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('location_address')->nullable()->after('notes');
$table->decimal('location_lat', 10, 7)->nullable()->after('location_address');
$table->decimal('location_lng', 10, 7)->nullable()->after('location_lat');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('classifications', function (Blueprint $table) {
$table->dropColumn(['location_address', 'location_lat', 'location_lng']);
});
}
};

View File

@ -0,0 +1,30 @@
<?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('classification_histories', function (Blueprint $table) {
$table->string('location_address')->nullable()->after('jenis_penyakit');
$table->decimal('location_lat', 10, 7)->nullable()->after('location_address');
$table->decimal('location_lng', 10, 7)->nullable()->after('location_lat');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('classification_histories', function (Blueprint $table) {
$table->dropColumn(['location_address', 'location_lat', 'location_lng']);
});
}
};

View File

@ -25,7 +25,7 @@
<div class="p-4 border-b bg-gray-50 flex flex-col sm:flex-row gap-3 sm:items-center sm:justify-between">
<h3 class="text-lg font-bold text-gray-800">Data History Classification</h3>
<form method="GET" class="flex gap-2">
<input type="text" name="jenis_penyakit" value="{{ request('jenis_penyakit') }}" placeholder="Filter penyakit..." class="border border-gray-300 rounded-lg p-2 text-sm">
<input type="text" name="keyword" value="{{ request('keyword') }}" placeholder="Cari penyakit, user, atau lokasi..." class="border border-gray-300 rounded-lg p-2 text-sm">
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded-lg text-sm">Filter</button>
</form>
</div>
@ -37,6 +37,7 @@
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">User</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Jenis Penyakit</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Lokasi</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Waktu</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Aksi</th>
</tr>
@ -47,6 +48,9 @@
<td class="px-4 py-3 text-sm text-gray-700">{{ $history->id }}</td>
<td class="px-4 py-3 text-sm text-gray-700">{{ $history->user->name ?? '-' }}</td>
<td class="px-4 py-3 text-sm text-gray-700">{{ $history->jenis_penyakit }}</td>
<td class="px-4 py-3 text-sm text-gray-700">
{{ $history->location_address ?? 'Alamat tidak terdeteksi' }}
</td>
<td class="px-4 py-3 text-sm text-gray-500">{{ $history->created_at?->format('d M Y H:i') }}</td>
<td class="px-4 py-3 text-right text-sm">
<button type="button" onclick="toggleEdit({{ $history->id }})" class="text-blue-600 hover:text-blue-800 mr-3">Edit</button>
@ -58,7 +62,7 @@
</td>
</tr>
<tr id="edit-row-{{ $history->id }}" class="hidden bg-gray-50">
<td colspan="5" class="px-4 py-3">
<td colspan="6" class="px-4 py-3">
<form action="{{ route('history-klasifikasi.update', $history->id) }}" method="POST" class="grid grid-cols-1 md:grid-cols-3 gap-3">
@csrf
@method('PUT')
@ -80,7 +84,7 @@
</tr>
@empty
<tr>
<td colspan="5" class="px-4 py-8 text-center text-sm text-gray-500">Belum ada data history classification.</td>
<td colspan="6" class="px-4 py-8 text-center text-sm text-gray-500">Belum ada data history classification.</td>
</tr>
@endforelse
</tbody>

View File

@ -5,7 +5,7 @@
@section('content')
<!-- Kartu Statistik Utama -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<!-- Kartu 1: Total Dataset (Citra Penyakit) -->
<div class="bg-white p-6 rounded-2xl shadow-sm border border-gray-100 flex items-start justify-between">
@ -35,7 +35,21 @@
</div>
</div>
<!-- Kartu 3: Penyakit Terbanyak di Database -->
<!-- Kartu 3: Total User -->
<button type="button" onclick="toggleUserList()" class="text-left bg-white p-6 rounded-2xl shadow-sm border border-gray-100 flex items-start justify-between hover:border-emerald-300 hover:shadow-md transition">
<div>
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">👤 Total User</p>
<h3 class="text-3xl font-bold text-gray-800 mt-2">{{ number_format($totalUsers) }}</h3>
<span class="inline-block mt-2 text-xs text-emerald-600">Klik untuk lihat user terbaru</span>
</div>
<div class="p-3 bg-emerald-50 text-emerald-600 rounded-xl">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a4 4 0 00-4-4h-1M9 20H4v-2a4 4 0 014-4h1m6-3a4 4 0 11-8 0 4 4 0 018 0zm6 0a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
</button>
<!-- Kartu 4: Penyakit Terbanyak di Database -->
<div class="bg-white p-6 rounded-2xl shadow-sm border border-gray-100 flex items-start justify-between">
<div>
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">⚠️ Penyakit Terbanyak</p>
@ -52,6 +66,45 @@
</div>
</div>
<!-- Modal User Terbaru -->
<div id="user-list-modal" class="fixed inset-0 z-50 hidden">
<div class="absolute inset-0 bg-black/40" onclick="toggleUserList()"></div>
<div class="relative mx-auto mt-20 w-full max-w-3xl px-4">
<div class="bg-white rounded-2xl shadow-xl border border-gray-100">
<div class="flex items-center justify-between px-6 py-4 border-b">
<h3 class="text-lg font-bold text-gray-800">User Terbaru</h3>
<button type="button" onclick="toggleUserList()" class="text-sm text-gray-500 hover:text-gray-700">Tutup</button>
</div>
<div class="max-h-[70vh] overflow-y-auto">
<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-medium text-gray-500 uppercase">Nama</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Tanggal Daftar</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@forelse($recentUsers as $user)
<tr>
<td class="px-4 py-3 text-sm text-gray-700">{{ $user->name }}</td>
<td class="px-4 py-3 text-sm text-gray-700">{{ $user->email }}</td>
<td class="px-4 py-3 text-sm text-gray-500">{{ $user->created_at?->format('d M Y H:i') }}</td>
</tr>
@empty
<tr>
<td colspan="3" class="px-4 py-6 text-center text-sm text-gray-500">Belum ada user.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Grafik dan Detail Penyakit -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
@ -90,6 +143,13 @@
</div>
<script>
function toggleUserList() {
const modal = document.getElementById('user-list-modal');
if (!modal) return;
modal.classList.toggle('hidden');
document.body.classList.toggle('overflow-hidden');
}
const ctx = document.getElementById('diseaseChart').getContext('2d');
const myChart = new Chart(ctx, {
type: 'bar',

View File

@ -1,6 +1,7 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\AuthController;
use App\Http\Controllers\ClassificationController;
use App\Http\Controllers\ClassificationHistoryController;
@ -30,6 +31,10 @@
Route::post('/classify-and-save', [ClassificationController::class, 'classifyAndSave']);
});
// Mobile auth endpoint
Route::post('/mobile/login', [AuthController::class, 'mobileLogin']);
Route::post('/mobile/register', [AuthController::class, 'mobileRegister']);
// History endpoints
Route::prefix('classifications')->group(function () {

View File

@ -119,6 +119,24 @@ def _preprocess_image(image_bytes: bytes):
return img_array
def _estimate_leafiness(image_bytes: bytes) -> float:
image = Image.open(BytesIO(image_bytes))
if image.mode != "RGB":
image = image.convert("RGB")
image = image.resize((128, 128))
hsv = image.convert("HSV")
hsv_array = np.array(hsv)
hue = hsv_array[:, :, 0]
sat = hsv_array[:, :, 1]
val = hsv_array[:, :, 2]
# Heuristic: green-ish pixels with enough saturation and brightness.
green_mask = (hue >= 50) & (hue <= 140) & (sat >= 40) & (val >= 40)
return float(np.mean(green_mask))
def _predict(image_bytes: bytes, model_dir: str) -> dict:
model = _load_model(model_dir)
@ -134,11 +152,14 @@ def _predict(image_bytes: bytes, model_dir: str) -> dict:
for idx, class_name in enumerate(CLASS_NAMES)
}
leafiness = _estimate_leafiness(image_bytes)
return {
"success": True,
"predicted_class": predicted_class,
"confidence": confidence,
"all_predictions": all_predictions,
"leafiness": leafiness,
"model_path": MODEL_PATH,
}