ngerjain backend
This commit is contained in:
parent
8af733ff12
commit
bd1112cf1a
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_windows
|
||||
geolocator_windows
|
||||
permission_handler_windows
|
||||
url_launcher_windows
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue