diff --git a/mobile_TA/padi_app/android/app/src/main/AndroidManifest.xml b/mobile_TA/padi_app/android/app/src/main/AndroidManifest.xml index 37edfea..1c214a2 100644 --- a/mobile_TA/padi_app/android/app/src/main/AndroidManifest.xml +++ b/mobile_TA/padi_app/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,6 @@ + + { String? _errorMessage; Future _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 { }); 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 { ); } } 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 { } } + 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 { ), ); } +} + +class _LocationPayload { + final String? address; + final double lat; + final double lng; + + const _LocationPayload({ + required this.address, + required this.lat, + required this.lng, + }); } \ No newline at end of file diff --git a/mobile_TA/padi_app/lib/main.dart b/mobile_TA/padi_app/lib/main.dart index d0e26c8..dd037fd 100644 --- a/mobile_TA/padi_app/lib/main.dart +++ b/mobile_TA/padi_app/lib/main.dart @@ -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 { 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 { ); } + Future _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( + 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( + 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( diff --git a/mobile_TA/padi_app/lib/screen/history_screen.dart b/mobile_TA/padi_app/lib/screen/history_screen.dart index 5ad867d..2647e8f 100644 --- a/mobile_TA/padi_app/lib/screen/history_screen.dart +++ b/mobile_TA/padi_app/lib/screen/history_screen.dart @@ -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 createState() => _HistoryScreenState(); +} + +class _HistoryScreenState extends State { + final ClassificationService _classificationService = ClassificationService(); + late Future> _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>( + 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'; + } } \ No newline at end of file diff --git a/mobile_TA/padi_app/lib/screen/login.dart b/mobile_TA/padi_app/lib/screen/login.dart index a8b1830..d276cb4 100644 --- a/mobile_TA/padi_app/lib/screen/login.dart +++ b/mobile_TA/padi_app/lib/screen/login.dart @@ -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 { 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 { 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 { ); } - 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 { 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, ), ), ); diff --git a/mobile_TA/padi_app/lib/screen/profile_screen.dart b/mobile_TA/padi_app/lib/screen/profile_screen.dart index 4468058..42ae967 100644 --- a/mobile_TA/padi_app/lib/screen/profile_screen.dart +++ b/mobile_TA/padi_app/lib/screen/profile_screen.dart @@ -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 { - 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 _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 _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 _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 _logout() async { showDialog( @@ -29,6 +220,11 @@ class _ProfileScreenState extends State { 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 { 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 { ), 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 { 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 { ), ), 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 { 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, + ), + ), + ], + ), ), ], ); diff --git a/mobile_TA/padi_app/lib/screen/register_screen.dart b/mobile_TA/padi_app/lib/screen/register_screen.dart index 62e5074..f9e4381 100644 --- a/mobile_TA/padi_app/lib/screen/register_screen.dart +++ b/mobile_TA/padi_app/lib/screen/register_screen.dart @@ -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 { 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 { _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 { _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 { 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 { 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, ), ), ); diff --git a/mobile_TA/padi_app/lib/services/classification_service.dart b/mobile_TA/padi_app/lib/services/classification_service.dart index e976698..8b70702 100644 --- a/mobile_TA/padi_app/lib/services/classification_service.dart +++ b/mobile_TA/padi_app/lib/services/classification_service.dart @@ -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 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.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 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> 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) ? data['data'] : null; + if (items is! List) { + return []; + } + + return items + .map((item) => ClassificationHistoryItem.fromJson( + Map.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 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(), + ), + ); + } +} diff --git a/mobile_TA/padi_app/macos/Flutter/GeneratedPluginRegistrant.swift b/mobile_TA/padi_app/macos/Flutter/GeneratedPluginRegistrant.swift index 21ae5af..e6cd7c1 100644 --- a/mobile_TA/padi_app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/mobile_TA/padi_app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) } diff --git a/mobile_TA/padi_app/pubspec.lock b/mobile_TA/padi_app/pubspec.lock index 6958794..f9d648f 100644 --- a/mobile_TA/padi_app/pubspec.lock +++ b/mobile_TA/padi_app/pubspec.lock @@ -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: diff --git a/mobile_TA/padi_app/pubspec.yaml b/mobile_TA/padi_app/pubspec.yaml index fb452f9..cecf1c4 100644 --- a/mobile_TA/padi_app/pubspec.yaml +++ b/mobile_TA/padi_app/pubspec.yaml @@ -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 diff --git a/mobile_TA/padi_app/windows/flutter/generated_plugin_registrant.cc b/mobile_TA/padi_app/windows/flutter/generated_plugin_registrant.cc index 4e586f9..b2cbd25 100644 --- a/mobile_TA/padi_app/windows/flutter/generated_plugin_registrant.cc +++ b/mobile_TA/padi_app/windows/flutter/generated_plugin_registrant.cc @@ -7,12 +7,15 @@ #include "generated_plugin_registrant.h" #include +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + GeolocatorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GeolocatorWindows")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/mobile_TA/padi_app/windows/flutter/generated_plugins.cmake b/mobile_TA/padi_app/windows/flutter/generated_plugins.cmake index 1119879..92c9a0d 100644 --- a/mobile_TA/padi_app/windows/flutter/generated_plugins.cmake +++ b/mobile_TA/padi_app/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows + geolocator_windows permission_handler_windows url_launcher_windows ) diff --git a/web_TA/app/Http/Controllers/AuthController.php b/web_TA/app/Http/Controllers/AuthController.php index 9ceb227..5b2209f 100644 --- a/web_TA/app/Http/Controllers/AuthController.php +++ b/web_TA/app/Http/Controllers/AuthController.php @@ -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); + } } \ No newline at end of file diff --git a/web_TA/app/Http/Controllers/ClassificationController.php b/web_TA/app/Http/Controllers/ClassificationController.php index 8cda12e..9504ae4 100644 --- a/web_TA/app/Http/Controllers/ClassificationController.php +++ b/web_TA/app/Http/Controllers/ClassificationController.php @@ -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); diff --git a/web_TA/app/Http/Controllers/ClassificationHistoryAdminController.php b/web_TA/app/Http/Controllers/ClassificationHistoryAdminController.php index 17d26c1..d22bdc3 100644 --- a/web_TA/app/Http/Controllers/ClassificationHistoryAdminController.php +++ b/web_TA/app/Http/Controllers/ClassificationHistoryAdminController.php @@ -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(); diff --git a/web_TA/app/Http/Controllers/ClassificationHistoryController.php b/web_TA/app/Http/Controllers/ClassificationHistoryController.php index 0f95634..f3d4ff4 100644 --- a/web_TA/app/Http/Controllers/ClassificationHistoryController.php +++ b/web_TA/app/Http/Controllers/ClassificationHistoryController.php @@ -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, diff --git a/web_TA/app/Http/Controllers/DashboardController.php b/web_TA/app/Http/Controllers/DashboardController.php index a11eabc..fd3e08e 100644 --- a/web_TA/app/Http/Controllers/DashboardController.php +++ b/web_TA/app/Http/Controllers/DashboardController.php @@ -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' )); } } diff --git a/web_TA/app/Models/Classification.php b/web_TA/app/Models/Classification.php index 5ee42a4..ed44bd6 100644 --- a/web_TA/app/Models/Classification.php +++ b/web_TA/app/Models/Classification.php @@ -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', diff --git a/web_TA/app/Models/ClassificationHistory.php b/web_TA/app/Models/ClassificationHistory.php index 31a3c68..deb4654 100644 --- a/web_TA/app/Models/ClassificationHistory.php +++ b/web_TA/app/Models/ClassificationHistory.php @@ -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 diff --git a/web_TA/database/migrations/2026_05_05_000000_add_user_id_to_classifications_table.php b/web_TA/database/migrations/2026_05_05_000000_add_user_id_to_classifications_table.php new file mode 100644 index 0000000..5f75b5e --- /dev/null +++ b/web_TA/database/migrations/2026_05_05_000000_add_user_id_to_classifications_table.php @@ -0,0 +1,33 @@ +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'); + }); + } +}; diff --git a/web_TA/database/migrations/2026_05_05_000001_add_location_to_classifications_table.php b/web_TA/database/migrations/2026_05_05_000001_add_location_to_classifications_table.php new file mode 100644 index 0000000..fb20fca --- /dev/null +++ b/web_TA/database/migrations/2026_05_05_000001_add_location_to_classifications_table.php @@ -0,0 +1,30 @@ +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']); + }); + } +}; diff --git a/web_TA/database/migrations/2026_05_05_000002_add_location_to_classification_histories_table.php b/web_TA/database/migrations/2026_05_05_000002_add_location_to_classification_histories_table.php new file mode 100644 index 0000000..191867d --- /dev/null +++ b/web_TA/database/migrations/2026_05_05_000002_add_location_to_classification_histories_table.php @@ -0,0 +1,30 @@ +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']); + }); + } +}; diff --git a/web_TA/resources/views/admin/classification_histories/index.blade.php b/web_TA/resources/views/admin/classification_histories/index.blade.php index 3898fde..4202aaf 100644 --- a/web_TA/resources/views/admin/classification_histories/index.blade.php +++ b/web_TA/resources/views/admin/classification_histories/index.blade.php @@ -25,7 +25,7 @@

Data History Classification

- +
@@ -37,6 +37,7 @@ ID User Jenis Penyakit + Lokasi Waktu Aksi @@ -47,6 +48,9 @@ {{ $history->id }} {{ $history->user->name ?? '-' }} {{ $history->jenis_penyakit }} + + {{ $history->location_address ?? 'Alamat tidak terdeteksi' }} + {{ $history->created_at?->format('d M Y H:i') }} @@ -58,7 +62,7 @@ - +
@csrf @method('PUT') @@ -80,7 +84,7 @@ @empty - Belum ada data history classification. + Belum ada data history classification. @endforelse diff --git a/web_TA/resources/views/dashboard.blade.php b/web_TA/resources/views/dashboard.blade.php index 0134001..d2878fe 100644 --- a/web_TA/resources/views/dashboard.blade.php +++ b/web_TA/resources/views/dashboard.blade.php @@ -5,7 +5,7 @@ @section('content') -
+
@@ -35,7 +35,21 @@
- + + + +

⚠️ Penyakit Terbanyak

@@ -52,6 +66,45 @@
+ + +
@@ -90,6 +143,13 @@