pembaruan config dll

This commit is contained in:
unknown 2025-06-05 00:38:06 +07:00
parent 89d9827387
commit 27882ce9e6
18 changed files with 1993 additions and 724 deletions

7
.gitignore vendored
View File

@ -1,2 +1,7 @@
# Ignore .env file to avoid pushing sensitive data to GitHub
backend/.env
backend/.env
backend/node_modules/
**/node_modules/
**/.env

25
backend/config/config.js Normal file
View File

@ -0,0 +1,25 @@
require('dotenv').config(); // untuk baca file .env
module.exports = {
development: {
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
host: process.env.DB_HOST,
dialect: 'mysql',
},
test: {
username: 'root',
password: null,
database: 'database_test',
host: '127.0.0.1',
dialect: 'mysql',
},
production: {
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
host: process.env.DB_HOST,
dialect: 'mysql',
}
};

View File

@ -1,23 +0,0 @@
{
"development": {
"username": "root",
"password": null,
"database": "sibayam",
"host": "127.0.0.1",
"dialect": "mysql"
},
"test": {
"username": "root",
"password": null,
"database": "database_test",
"host": "127.0.0.1",
"dialect": "mysql"
},
"production": {
"username": "root",
"password": null,
"database": "database_production",
"host": "127.0.0.1",
"dialect": "mysql"
}
}

View File

@ -1,10 +1,32 @@
// const { Sequelize } = require('sequelize');
// require('dotenv').config();
// const sequelize = new Sequelize(process.env.DB_NAME, process.env.DB_USER, process.env.DB_PASSWORD, {
// host: process.env.DB_HOST,
// dialect: 'mysql',
// timezone: '+07:00',
// });
// module.exports = sequelize;
const { Sequelize } = require('sequelize');
require('dotenv').config();
const sequelize = new Sequelize(process.env.DB_NAME, process.env.DB_USER, process.env.DB_PASSWORD, {
const sequelize = new Sequelize(
process.env.DB_NAME,
process.env.DB_USER,
process.env.DB_PASSWORD,
{
host: process.env.DB_HOST,
port: process.env.DB_PORT, // tambahkan port
dialect: 'mysql',
timezone: '+07:00',
});
dialectOptions: {
ssl: {
rejectUnauthorized: false, // jika diperlukan (tergantung Clever Cloud)
}
}
}
);
module.exports = sequelize;
module.exports = sequelize;

View File

@ -112,10 +112,16 @@ const createGmailTransporter = () => {
// Generate 6 digit random code
const code = Math.floor(100000 + Math.random() * 900000).toString();
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
// Konversi ke waktu Indonesia (GMT+7)
const expiresAtWIB = new Date(expiresAt.getTime() + 7 * 60 * 60 * 1000);
// Format manual jadi ISO-like (tanpa Z karena bukan UTC)
const isoWIB = expiresAtWIB.toISOString().replace( '+07:00');
await user.update({
resetToken: code,
resetTokenExpiry: expiresAt,
resetTokenExpiry: isoWIB,
});
// Nama aplikasi yang konsisten

View File

@ -77,7 +77,7 @@ function calculateBayesProbability(rules, entityType) {
async function getTotalGejalaForEntity(entityId, entityType, inputGejala) {
try {
let totalGejala = 0;
if (entityType === 'penyakit') {
const allRules = await Rule_penyakit.findAll({
where: { id_penyakit: entityId }
@ -89,7 +89,7 @@ async function getTotalGejalaForEntity(entityId, entityType, inputGejala) {
});
totalGejala = allRules.length;
}
return totalGejala;
} catch (error) {
console.error('Error getting total gejala:', error);
@ -103,12 +103,12 @@ async function resolveAmbiguity(candidates, inputGejala) {
for (let candidate of candidates) {
const entityType = candidate.type;
const entityId = entityType === 'penyakit' ? candidate.id_penyakit : candidate.id_hama;
candidate.total_gejala_entity = await getTotalGejalaForEntity(entityId, entityType, inputGejala);
// Hitung persentase kesesuaian gejala
candidate.persentase_kesesuaian = candidate.total_gejala_entity > 0
? (candidate.jumlah_gejala_cocok / candidate.total_gejala_entity) * 100
candidate.persentase_kesesuaian = candidate.total_gejala_entity > 0
? (candidate.jumlah_gejala_cocok / candidate.total_gejala_entity) * 100
: 0;
}
@ -121,12 +121,12 @@ async function resolveAmbiguity(candidates, inputGejala) {
if (a.jumlah_gejala_cocok !== b.jumlah_gejala_cocok) {
return b.jumlah_gejala_cocok - a.jumlah_gejala_cocok;
}
// Prioritas 2: Persentase kesesuaian
if (Math.abs(a.persentase_kesesuaian - b.persentase_kesesuaian) > 0.01) {
return b.persentase_kesesuaian - a.persentase_kesesuaian;
}
// Prioritas 3: Entity dengan total gejala lebih sedikit (lebih spesifik)
return a.total_gejala_entity - b.total_gejala_entity;
});
@ -134,6 +134,24 @@ async function resolveAmbiguity(candidates, inputGejala) {
return candidates[0]; // Kembalikan yang terbaik
}
// Helper function untuk memfilter hasil dengan 100% akurasi yang hanya cocok 1 gejala
function filterSingleSymptomPerfectMatch(results) {
const filtered = results.filter(result => {
// Jika probabilitas 100% dan hanya cocok dengan 1 gejala, filter keluar
const isPerfectMatch = Math.abs(result.probabilitas_persen - 100) < 0.0001;
const isSingleSymptom = result.jumlah_gejala_cocok === 1;
if (isPerfectMatch && isSingleSymptom) {
console.log(`Memfilter ${result.nama} (${result.type}) - 100% akurasi dengan hanya 1 gejala cocok`);
return false; // Filter keluar
}
return true; // Tetap masukkan
});
return filtered;
}
exports.diagnosa = async (req, res) => {
const { gejala } = req.body;
const userId = req.user?.id;
@ -195,16 +213,41 @@ exports.diagnosa = async (req, res) => {
...sortedHama.map(h => ({ type: 'hama', ...h }))
].sort((a, b) => b.probabilitas_persen - a.probabilitas_persen);
// ========== FILTER HASIL 100% DENGAN 1 GEJALA ==========
const filteredResults = filterSingleSymptomPerfectMatch(allResults);
// Jika semua hasil terfilter, gunakan hasil berdasarkan kecocokan gejala terbanyak
let finalResults = filteredResults;
let filterInfo = {
total_sebelum_filter: allResults.length,
total_setelah_filter: filteredResults.length,
hasil_terfilter: allResults.length - filteredResults.length
};
if (filteredResults.length === 0 && allResults.length > 0) {
console.log('Semua hasil terfilter karena 100% dengan 1 gejala. Menggunakan kecocokan gejala terbanyak.');
// Urutkan berdasarkan jumlah gejala cocok, lalu probabilitas
finalResults = allResults.sort((a, b) => {
if (a.jumlah_gejala_cocok !== b.jumlah_gejala_cocok) {
return b.jumlah_gejala_cocok - a.jumlah_gejala_cocok;
}
return b.probabilitas_persen - a.probabilitas_persen;
});
filterInfo.fallback_to_symptom_count = true;
filterInfo.fallback_reason = 'Semua hasil memiliki 100% akurasi dengan hanya 1 gejala cocok';
}
// ========== PENANGANAN AMBIGUITAS ==========
let hasilTertinggi = null;
let isAmbiguous = false;
let ambiguityResolution = null;
if (allResults.length > 0) {
const nilaiTertinggi = allResults[0].probabilitas_persen;
if (finalResults.length > 0) {
const nilaiTertinggi = finalResults[0].probabilitas_persen;
// Cari semua hasil dengan nilai probabilitas yang sama dengan yang tertinggi
const kandidatTertinggi = allResults.filter(result =>
const kandidatTertinggi = finalResults.filter(result =>
Math.abs(result.probabilitas_persen - nilaiTertinggi) < 0.0001 // Toleransi untuk floating point
);
@ -212,10 +255,10 @@ exports.diagnosa = async (req, res) => {
// Ada ambiguitas - perlu resolusi
isAmbiguous = true;
console.log(`Ditemukan ${kandidatTertinggi.length} kandidat dengan nilai probabilitas sama: ${nilaiTertinggi}%`);
// Lakukan resolusi ambiguitas
hasilTertinggi = await resolveAmbiguity(kandidatTertinggi, gejala);
ambiguityResolution = {
total_kandidat: kandidatTertinggi.length,
metode_resolusi: 'jumlah_gejala_cocok',
@ -235,7 +278,38 @@ exports.diagnosa = async (req, res) => {
};
} else {
// Tidak ada ambiguitas
hasilTertinggi = allResults[0];
hasilTertinggi = finalResults[0];
// Validasi: Jika hanya cocok 1 gejala dan masih ada kandidat lain dengan kecocokan lebih baik
if (hasilTertinggi.jumlah_gejala_cocok === 1 && finalResults.length > 1) {
const alternatif = finalResults
.filter(result => result.jumlah_gejala_cocok > 1 && result.probabilitas_persen >= hasilTertinggi.probabilitas_persen - 5); // toleransi 5%
if (alternatif.length > 0) {
// Gunakan resolveAmbiguity terhadap alternatif + hasilTertinggi
const kandidatAmbigu = [hasilTertinggi, ...alternatif];
hasilTertinggi = await resolveAmbiguity(kandidatAmbigu, gejala);
isAmbiguous = true;
ambiguityResolution = {
total_kandidat: kandidatAmbigu.length,
metode_resolusi: 'gejala_minimum_filter',
kandidat: kandidatAmbigu.map(k => ({
type: k.type,
nama: k.nama,
probabilitas_persen: k.probabilitas_persen,
jumlah_gejala_cocok: k.jumlah_gejala_cocok,
total_gejala_entity: k.total_gejala_entity,
persentase_kesesuaian: k.persentase_kesesuaian
})),
terpilih: {
type: hasilTertinggi.type,
nama: hasilTertinggi.nama,
alasan: `Dipilih karena memiliki jumlah gejala cocok lebih banyak dari kandidat yang hanya cocok 1 gejala`
}
};
}
}
}
}
@ -290,7 +364,8 @@ exports.diagnosa = async (req, res) => {
gejala_input: gejala.map(id => parseInt(id)),
hasil_tertinggi: hasilTertinggi,
is_ambiguous: isAmbiguous,
ambiguity_resolution: ambiguityResolution
ambiguity_resolution: ambiguityResolution,
filter_info: filterInfo // Informasi tentang filtering yang dilakukan
}
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@ -6,7 +6,7 @@ const Sequelize = require('sequelize');
const process = require('process');
const basename = path.basename(__filename);
const env = process.env.NODE_ENV || 'development';
const config = require(__dirname + '/../config/config.json')[env];
const config = require(__dirname + '/../config/config.js')[env];
const db = {};
let sequelize;

View File

@ -177,11 +177,16 @@ class _RulePageState extends State<RulePage> {
),
).then((_) => fetchRules());
},
icon: Icon(Icons.bug_report),
label: Text("Tambah Rule Hama"),
style: ElevatedButton.styleFrom(
icon: Icon(Icons.bug_report, size: 16,),
label: Text(
"Tambah Rule Hama",
style: TextStyle(fontSize: 12)),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 6), // Padding lebih kecil
minimumSize: Size(0, 32), // Tinggi minimum lebih kecil
tapTargetSize: MaterialTapTargetSize.shrinkWrap, // Mengurangi area tap
),
),
SizedBox(width: 10),
@ -204,11 +209,16 @@ class _RulePageState extends State<RulePage> {
),
).then((_) => fetchRules());
},
icon: Icon(Icons.healing),
label: Text("Tambah Rule Penyakit"),
style: ElevatedButton.styleFrom(
icon: Icon(Icons.healing, size: 16,),
label: Text(
"Tambah Rule Penyakit",
style: TextStyle(fontSize: 12),),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 6), // Padding lebih kecil
minimumSize: Size(0, 32), // Tinggi minimum lebih kecil
tapTargetSize: MaterialTapTargetSize.shrinkWrap, // Mengurangi area tap
),
),
],

View File

@ -1000,6 +1000,29 @@ Future<List<Map<String, dynamic>>> getAllHistori() async {
}
}
Future<bool> verifyResetCode({required String email, required String code}) async {
try {
final response = await http.post(
Uri.parse('$baseUrl/reset-password'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({
'email': email,
'resetToken': code,
}),
);
if (response.statusCode == 200) {
// Jika status code 200, berarti kode valid
return true;
} else {
// Jika status code bukan 200, kode tidak valid
final error = jsonDecode(response.body);
throw error['message'] ?? 'Kode verifikasi tidak valid';
}
} catch (e) {
throw e.toString();
}
}
// Create Rule penyakit
static Future<http.Response> createRulePenyakit({

View File

@ -13,14 +13,26 @@ class DetailHamaPage extends StatefulWidget {
_DetailHamaPageState createState() => _DetailHamaPageState();
}
class _DetailHamaPageState extends State<DetailHamaPage> {
class _DetailHamaPageState extends State<DetailHamaPage> with TickerProviderStateMixin {
late Future<Map<String, dynamic>> _detailHamaFuture;
late Map<String, dynamic> _currentDetailHama;
late AnimationController _animationController;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_currentDetailHama = widget.detailHama;
// Initialize animation
_animationController = AnimationController(
duration: Duration(milliseconds: 800),
vsync: this,
);
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
);
_animationController.forward();
// Jika hamaId tersedia, fetch data terbaru dari API
if (widget.hamaId != null) {
@ -31,6 +43,12 @@ class _DetailHamaPageState extends State<DetailHamaPage> {
}
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
Future<Map<String, dynamic>> _fetchDetailHama(int id) async {
try {
final detailData = await ApiService().getHamaById(id);
@ -67,10 +85,37 @@ class _DetailHamaPageState extends State<DetailHamaPage> {
future: ApiService().getHamaImageBytesByFilename(filename),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return SizedBox(
height: 200,
return Container(
height: 280,
width: double.infinity,
child: Center(child: CircularProgressIndicator()),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
colors: [Colors.grey[300]!, Colors.grey[100]!],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF609966)),
strokeWidth: 3,
),
SizedBox(height: 12),
Text(
"Memuat gambar...",
style: TextStyle(
color: Colors.grey[600],
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
);
} else if (snapshot.hasError || snapshot.data == null) {
return _buildPlaceholderImage(
@ -78,13 +123,29 @@ class _DetailHamaPageState extends State<DetailHamaPage> {
Icons.broken_image,
);
} else {
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.memory(
snapshot.data!,
height: 200,
return Hero(
tag: 'hama_image_${widget.hamaId}',
child: Container(
height: 280,
width: double.infinity,
fit: BoxFit.contain, // untuk memastikan proporsional & penuh
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 15,
offset: Offset(0, 8),
spreadRadius: 2,
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Image.memory(
snapshot.data!,
fit: BoxFit.cover,
),
),
),
);
}
@ -95,149 +156,116 @@ class _DetailHamaPageState extends State<DetailHamaPage> {
// Widget untuk placeholder gambar
Widget _buildPlaceholderImage(String message, IconData icon) {
return Container(
height: 200,
height: 280,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
colors: [Colors.grey[300]!, Colors.grey[100]!],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: Offset(0, 5),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 64, color: Colors.grey[600]),
SizedBox(height: 8),
Container(
padding: EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.8),
shape: BoxShape.circle,
),
child: Icon(icon, size: 48, color: Colors.grey[600]),
),
SizedBox(height: 16),
Text(
message,
style: TextStyle(
color: Colors.grey[600],
fontWeight: FontWeight.bold,
color: Colors.grey[700],
fontWeight: FontWeight.w600,
fontSize: 16,
),
textAlign: TextAlign.center,
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Color(0xFF9DC08D),
appBar: AppBar(
backgroundColor: Color(0xFF9DC08D),
title: Text("Detail Hama", style: TextStyle(color: Colors.white)),
leading: IconButton(
icon: Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
Widget _buildInfoCard({
required String title,
required String content,
required IconData icon,
required Color color,
}) {
return Container(
margin: EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: color.withOpacity(0.2),
blurRadius: 15,
offset: Offset(0, 8),
spreadRadius: 2,
),
],
),
body: FutureBuilder<Map<String, dynamic>>(
future: _detailHamaFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(
child: CircularProgressIndicator(color: Colors.white),
);
}
if (snapshot.hasError) {
print('Error: ${snapshot.error}');
// Tampilkan data yang sudah ada jika terjadi error
return _buildDetailContent(_currentDetailHama);
}
print("Snapshot data runtimeType: ${snapshot.data.runtimeType}");
print("Snapshot data content: ${snapshot.data}");
// Jika berhasil fetch data baru, tampilkan data tersebut
final detailData = snapshot.data ?? _currentDetailHama;
return _buildDetailContent(detailData);
},
),
);
}
Widget _buildDetailContent(Map<String, dynamic> detailData) {
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Tampilkan foto dari database dengan penanganan error yang lebih baik
_buildImageWidget(detailData["foto"]),
SizedBox(height: 16),
// Card Nama Hama
SizedBox(
width: double.infinity,
child: Card(
elevation: 6,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Nama Hama:",
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 8),
Text(
detailData["nama"] ?? "Nama hama tidak tersedia",
style: TextStyle(fontSize: 16),
),
],
Row(
children: [
Container(
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
icon,
color: color,
size: 24,
),
),
),
SizedBox(width: 16),
Text(
title,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.grey[800],
),
),
],
),
SizedBox(height: 16),
// Card Deskripsi + Penanganan
SizedBox(
Container(
width: double.infinity,
child: Card(
elevation: 6,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Deskripsi:",
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 8),
Text(
detailData["deskripsi"] ?? "Deskripsi tidak tersedia",
style: TextStyle(fontSize: 16),
),
SizedBox(height: 16),
Text(
"Penanganan:",
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 8),
Text(
detailData["penanganan"] ?? "Penanganan tidak tersedia",
style: TextStyle(fontSize: 16),
),
],
),
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey[50],
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey[200]!),
),
child: Text(
content,
style: TextStyle(
fontSize: 16,
height: 1.6,
color: Colors.grey[700],
),
textAlign: TextAlign.justify,
),
),
],
@ -245,4 +273,119 @@ class _DetailHamaPageState extends State<DetailHamaPage> {
),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Color(0xFFF8F9FA),
appBar: AppBar(
backgroundColor: Color(0xFF9DC08D),
elevation: 0,
leading: Container(
margin: EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: IconButton(
icon: Icon(Icons.arrow_back_ios_new, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
),
title: Text(
"Detail Hama",
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 20,
),
),
centerTitle: true,
),
body: SingleChildScrollView(
child: FutureBuilder<Map<String, dynamic>>(
future: _detailHamaFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Container(
height: MediaQuery.of(context).size.height * 0.6,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF609966)),
strokeWidth: 4,
),
SizedBox(height: 20),
Text(
"Memuat data hama...",
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}
if (snapshot.hasError) {
print('Error: ${snapshot.error}');
return _buildDetailContent(_currentDetailHama);
}
final detailData = snapshot.data ?? _currentDetailHama;
return _buildDetailContent(detailData);
},
),
),
);
}
Widget _buildDetailContent(Map<String, dynamic> detailData) {
return FadeTransition(
opacity: _fadeAnimation,
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Hero Image Section
_buildImageWidget(detailData["foto"]),
SizedBox(height: 24),
// Nama Hama Card
_buildInfoCard(
title: "Nama Hama",
content: detailData["nama"] ?? "Nama hama tidak tersedia",
icon: Icons.bug_report,
color: Color(0xFF9DC08D),
),
// Deskripsi Card
_buildInfoCard(
title: "Deskripsi",
content: detailData["deskripsi"] ?? "Deskripsi tidak tersedia",
icon: Icons.description,
color: Color(0xFF9DC08D),
),
// Penanganan Card
_buildInfoCard(
title: "Penanganan",
content: detailData["penanganan"] ?? "Penanganan tidak tersedia",
icon: Icons.medical_services,
color: Color(0xFF9DC08D),
),
// Bottom spacing
SizedBox(height: 20),
],
),
),
);
}
}

View File

@ -13,16 +13,28 @@ class DetailPenyakitPage extends StatefulWidget {
_DetailPenyakitPageState createState() => _DetailPenyakitPageState();
}
class _DetailPenyakitPageState extends State<DetailPenyakitPage> {
class _DetailPenyakitPageState extends State<DetailPenyakitPage> with TickerProviderStateMixin {
late Future<Map<String, dynamic>> _detailPenyakitFuture;
late Map<String, dynamic> _currentDetailPenyakit;
late AnimationController _animationController;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_currentDetailPenyakit = widget.DetailPenyakit;
// Initialize animation
_animationController = AnimationController(
duration: Duration(milliseconds: 800),
vsync: this,
);
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
);
_animationController.forward();
// Jika hamaId tersedia, fetch data terbaru dari API
// Jika penyakitId tersedia, fetch data terbaru dari API
if (widget.penyakitId != null) {
_detailPenyakitFuture = _fetchDetailPenyakit(widget.penyakitId!);
} else {
@ -31,6 +43,12 @@ class _DetailPenyakitPageState extends State<DetailPenyakitPage> {
}
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
Future<Map<String, dynamic>> _fetchDetailPenyakit(int id) async {
try {
final detailData = await ApiService().getPenyakitById(id);
@ -67,10 +85,37 @@ class _DetailPenyakitPageState extends State<DetailPenyakitPage> {
future: ApiService().getPenyakitImageBytesByFilename(filename),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return SizedBox(
height: 200,
return Container(
height: 280,
width: double.infinity,
child: Center(child: CircularProgressIndicator()),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
colors: [Colors.grey[300]!, Colors.grey[100]!],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFFE74C3C)),
strokeWidth: 3,
),
SizedBox(height: 12),
Text(
"Memuat gambar...",
style: TextStyle(
color: Colors.grey[600],
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
);
} else if (snapshot.hasError || snapshot.data == null) {
return _buildPlaceholderImage(
@ -78,13 +123,29 @@ class _DetailPenyakitPageState extends State<DetailPenyakitPage> {
Icons.broken_image,
);
} else {
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.memory(
snapshot.data!,
height: 200,
return Hero(
tag: 'penyakit_image_${widget.penyakitId}',
child: Container(
height: 280,
width: double.infinity,
fit: BoxFit.contain, // untuk memastikan proporsional & penuh
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 15,
offset: Offset(0, 8),
spreadRadius: 2,
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Image.memory(
snapshot.data!,
fit: BoxFit.cover,
),
),
),
);
}
@ -95,149 +156,116 @@ class _DetailPenyakitPageState extends State<DetailPenyakitPage> {
// Widget untuk placeholder gambar
Widget _buildPlaceholderImage(String message, IconData icon) {
return Container(
height: 200,
height: 280,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
colors: [Colors.grey[300]!, Colors.grey[100]!],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: Offset(0, 5),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 64, color: Colors.grey[600]),
SizedBox(height: 8),
Container(
padding: EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.8),
shape: BoxShape.circle,
),
child: Icon(icon, size: 48, color: Colors.grey[600]),
),
SizedBox(height: 16),
Text(
message,
style: TextStyle(
color: Colors.grey[600],
fontWeight: FontWeight.bold,
color: Colors.grey[700],
fontWeight: FontWeight.w600,
fontSize: 16,
),
textAlign: TextAlign.center,
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Color(0xFF9DC08D),
appBar: AppBar(
backgroundColor: Color(0xFF9DC08D),
title: Text("Detail Penyakit", style: TextStyle(color: Colors.white)),
leading: IconButton(
icon: Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
Widget _buildInfoCard({
required String title,
required String content,
required IconData icon,
required Color color,
}) {
return Container(
margin: EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: color.withOpacity(0.2),
blurRadius: 15,
offset: Offset(0, 8),
spreadRadius: 2,
),
],
),
body: FutureBuilder<Map<String, dynamic>>(
future: _detailPenyakitFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(
child: CircularProgressIndicator(color: Colors.white),
);
}
if (snapshot.hasError) {
print('Error: ${snapshot.error}');
// Tampilkan data yang sudah ada jika terjadi error
return _buildDetailContent(_currentDetailPenyakit);
}
print("Snapshot data runtimeType: ${snapshot.data.runtimeType}");
print("Snapshot data content: ${snapshot.data}");
// Jika berhasil fetch data baru, tampilkan data tersebut
final detailData = snapshot.data ?? _currentDetailPenyakit;
return _buildDetailContent(detailData);
},
),
);
}
Widget _buildDetailContent(Map<String, dynamic> detailData) {
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Tampilkan foto dari database dengan penanganan error yang lebih baik
_buildImageWidget(detailData["foto"]),
SizedBox(height: 16),
// Card Nama Hama
SizedBox(
width: double.infinity,
child: Card(
elevation: 6,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Nama Penyakit:",
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 8),
Text(
detailData["nama"] ?? "Nama hama tidak tersedia",
style: TextStyle(fontSize: 16),
),
],
Row(
children: [
Container(
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
icon,
color: color,
size: 24,
),
),
),
SizedBox(width: 16),
Text(
title,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.grey[800],
),
),
],
),
SizedBox(height: 16),
// Card Deskripsi + Penanganan
SizedBox(
Container(
width: double.infinity,
child: Card(
elevation: 6,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Deskripsi:",
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 8),
Text(
detailData["deskripsi"] ?? "Deskripsi tidak tersedia",
style: TextStyle(fontSize: 16),
),
SizedBox(height: 16),
Text(
"Penanganan:",
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 8),
Text(
detailData["penanganan"] ?? "Penanganan tidak tersedia",
style: TextStyle(fontSize: 16),
),
],
),
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey[50],
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey[200]!),
),
child: Text(
content,
style: TextStyle(
fontSize: 16,
height: 1.6,
color: Colors.grey[700],
),
textAlign: TextAlign.justify,
),
),
],
@ -245,4 +273,119 @@ class _DetailPenyakitPageState extends State<DetailPenyakitPage> {
),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Color(0xFFF8F9FA),
appBar: AppBar(
backgroundColor: Color(0xFF9DC08D),
elevation: 0,
leading: Container(
margin: EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: IconButton(
icon: Icon(Icons.arrow_back_ios_new, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
),
title: Text(
"Detail Penyakit",
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 20,
),
),
centerTitle: true,
),
body: SingleChildScrollView(
child: FutureBuilder<Map<String, dynamic>>(
future: _detailPenyakitFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Container(
height: MediaQuery.of(context).size.height * 0.6,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF9DC08D)),
strokeWidth: 4,
),
SizedBox(height: 20),
Text(
"Memuat data penyakit...",
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}
if (snapshot.hasError) {
print('Error: ${snapshot.error}');
return _buildDetailContent(_currentDetailPenyakit);
}
final detailData = snapshot.data ?? _currentDetailPenyakit;
return _buildDetailContent(detailData);
},
),
),
);
}
Widget _buildDetailContent(Map<String, dynamic> detailData) {
return FadeTransition(
opacity: _fadeAnimation,
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Hero Image Section
_buildImageWidget(detailData["foto"]),
SizedBox(height: 24),
// Nama Penyakit Card
_buildInfoCard(
title: "Nama Penyakit",
content: detailData["nama"] ?? "Nama penyakit tidak tersedia",
icon: Icons.coronavirus,
color: Color(0xFF9DC08D),
),
// Deskripsi Card
_buildInfoCard(
title: "Deskripsi",
content: detailData["deskripsi"] ?? "Deskripsi tidak tersedia",
icon: Icons.description,
color: Color(0xFF9DC08D),
),
// Penanganan Card
_buildInfoCard(
title: "Penanganan",
content: detailData["penanganan"] ?? "Penanganan tidak tersedia",
icon: Icons.medical_services,
color: Color(0xFF9DC08D),
),
// Bottom spacing
SizedBox(height: 20),
],
),
),
);
}
}

View File

@ -8,132 +8,316 @@ class ForgotPasswordPage extends StatefulWidget {
class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
final TextEditingController emailController = TextEditingController();
final TextEditingController codeController = TextEditingController();
final TextEditingController passwordController = TextEditingController();
final ApiService apiService = ApiService();
bool isLoading = false;
bool isCodeSent = false;
void _showErrorDialog(String message) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Icon(Icons.error_outline, color: Colors.red),
SizedBox(width: 10),
Text('Error'),
],
),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
'OK',
style: TextStyle(color: Color(0xFF9DC08D)),
),
),
],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
),
);
}
void _showSuccessDialog(String title, String message) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Icon(Icons.check_circle, color: Color(0xFF9DC08D)),
SizedBox(width: 10),
Text(title),
],
),
content: Text(message),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
if (title == 'Berhasil') {
Navigator.of(context).pop(); // kembali ke halaman login
}
},
child: Text(
'OK',
style: TextStyle(color: Color(0xFF9DC08D)),
),
),
],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
),
);
}
// Fungsi untuk mengirim kode verifikasi
void handleSendCode() async {
if (emailController.text.trim().isEmpty) {
_showErrorDialog('Email harus diisi');
return;
}
setState(() => isLoading = true);
try {
await apiService.sendResetCode(email: emailController.text.trim());
// Tampilkan dialog input kode verifikasi
showVerificationDialog();
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
} finally {
setState(() => isLoading = false);
_showSuccessDialog(
'Kode Terkirim',
'Kode verifikasi telah dikirim ke email Anda.',
);
showResetPasswordDialog();
} catch (e) {
setState(() => isLoading = false);
_showErrorDialog(e.toString());
}
}
// Dialog untuk input kode verifikasi
void showVerificationDialog() {
// Dialog untuk input kode verifikasi dan reset password
void showResetPasswordDialog() {
final codeController = TextEditingController();
final passwordController = TextEditingController();
final confirmPasswordController = TextEditingController();
bool isDialogLoading = false;
bool obscurePassword = true;
bool obscureConfirmPassword = true;
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: Text('Masukkan Kode Verifikasi'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Kode verifikasi telah dikirim ke email Anda.'),
SizedBox(height: 16),
TextField(
controller: codeController,
decoration: InputDecoration(
labelText: 'Kode Verifikasi',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: Text('Reset Password'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Masukkan kode verifikasi dan password baru Anda.'),
SizedBox(height: 16),
TextField(
controller: codeController,
decoration: InputDecoration(
labelText: 'Kode Verifikasi',
labelStyle: TextStyle(color: Colors.black.withOpacity(0.7)),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: Colors.black.withOpacity(0.6),
width: 2,
),
),
),
keyboardType: TextInputType.number,
),
SizedBox(height: 16),
TextField(
controller: passwordController,
obscureText: obscurePassword,
decoration: InputDecoration(
labelText: 'Password Baru',
labelStyle: TextStyle(color: Colors.black.withOpacity(0.7)),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: Colors.black.withOpacity(0.6),
width: 2,
),
),
suffixIcon: IconButton(
icon: Icon(
obscurePassword ? Icons.visibility : Icons.visibility_off,
color: Colors.black.withOpacity(0.6),
),
onPressed: () {
setDialogState(() {
obscurePassword = !obscurePassword;
});
},
),
),
),
SizedBox(height: 16),
TextField(
controller: confirmPasswordController,
obscureText: obscureConfirmPassword,
decoration: InputDecoration(
labelText: 'Konfirmasi Password',
labelStyle: TextStyle(color: Colors.black.withOpacity(0.7)),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: Colors.black.withOpacity(0.6),
width: 2,
),
),
suffixIcon: IconButton(
icon: Icon(
obscureConfirmPassword ? Icons.visibility : Icons.visibility_off,
color: Colors.black.withOpacity(0.6),
),
onPressed: () {
setDialogState(() {
obscureConfirmPassword = !obscureConfirmPassword;
});
},
),
),
),
],
),
],
),
actions: [
TextButton(
child: Text('Verifikasi'),
onPressed: () {
Navigator.pop(context);
showNewPasswordDialog();
},
),
],
),
);
}
// Dialog untuk input password baru
void showNewPasswordDialog() {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: Text('Reset Password'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: passwordController,
decoration: InputDecoration(
labelText: 'Password Baru',
border: OutlineInputBorder(),
),
obscureText: true,
),
],
),
actions: [
TextButton(
child: Text('Reset'),
onPressed: () => handleResetPassword(),
),
],
),
);
}
// Fungsi untuk reset password
void handleResetPassword() async {
setState(() => isLoading = true);
try {
await apiService.resetPasswordWithCode(
code: codeController.text.trim(),
password: passwordController.text.trim(),
);
Navigator.pop(context); // Tutup dialog password
// Tampilkan pesan sukses
showDialog(
context: context,
builder: (_) => AlertDialog(
title: Text('Berhasil'),
content: Text('Password berhasil direset.'),
actions: [
TextButton(
child: Text('OK'),
child: Text(
'Batal',
style: TextStyle(color: Colors.grey),
),
onPressed: () {
Navigator.of(context).pop(); // tutup dialog
Navigator.of(context).pop(); // kembali ke halaman login
Navigator.pop(context);
},
),
TextButton(
child: isDialogLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF9DC08D)),
),
)
: Text(
'Reset Password',
style: TextStyle(
color: Color(0xFF9DC08D),
fontWeight: FontWeight.bold,
),
),
onPressed: isDialogLoading ? null : () async {
// Validasi input
if (codeController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Silakan masukkan kode verifikasi.'),
backgroundColor: Colors.red,
),
);
return;
}
if (passwordController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Silakan masukkan password baru.'),
backgroundColor: Colors.red,
),
);
return;
}
if (passwordController.text != confirmPasswordController.text) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Password tidak cocok.'),
backgroundColor: Colors.red,
),
);
return;
}
if (passwordController.text.length < 6) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Password minimal 6 karakter.'),
backgroundColor: Colors.red,
),
);
return;
}
setDialogState(() {
isDialogLoading = true;
});
try {
// Panggil API untuk reset password
await apiService.resetPasswordWithCode(
code: codeController.text.trim(),
password: passwordController.text,
);
setDialogState(() {
isDialogLoading = false;
});
// Tutup dialog dan tampilkan pesan sukses
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Password berhasil direset!'),
backgroundColor: Colors.green,
),
);
// Redirect ke login page
// Navigator.pushReplacementNamed(context, '/login');
} catch (e) {
setDialogState(() {
isDialogLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Terjadi kesalahan: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
},
),
],
),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
} finally {
setState(() => isLoading = false);
}
),
);
}
@override
void dispose() {
emailController.dispose();
super.dispose();
}
@override
@ -143,6 +327,7 @@ class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
appBar: AppBar(
backgroundColor: Color(0xFF9DC08D),
title: Text('Lupa Password'),
elevation: 0,
),
body: Center(
child: SingleChildScrollView(
@ -168,9 +353,19 @@ class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
controller: emailController,
decoration: InputDecoration(
labelText: 'Email',
labelStyle: TextStyle(
color: Colors.black.withOpacity(0.7),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: Colors.black.withOpacity(0.6),
width: 2,
),
),
),
keyboardType: TextInputType.emailAddress,
),

View File

@ -43,10 +43,14 @@ class _HasilDiagnosaPageState extends State<HasilDiagnosaPage> {
final List<dynamic> penyakitList = data['penyakit'] ?? [];
final List<dynamic> hamaList = data['hama'] ?? [];
final Map<String, dynamic>? hasilTertinggi = data['hasil_tertinggi'];
// Ambiguity information from backend
final bool isAmbiguous = data['is_ambiguous'] ?? false;
final Map<String, dynamic>? ambiguityResolution = data['ambiguity_resolution'];
final Map<String, dynamic>? ambiguityResolution =
data['ambiguity_resolution'];
// Filter information from backend
final Map<String, dynamic>? filterInfo = data['filter_info'];
// Get the first penyakit and hama (if any)
Map<String, dynamic>? firstPenyakit =
@ -88,81 +92,197 @@ class _HasilDiagnosaPageState extends State<HasilDiagnosaPage> {
),
body: Container(
color: Color(0xFFEDF1D6),
child: isLoading
? Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Ambiguity notification (if applicable)
if (isAmbiguous && ambiguityResolution != null)
_buildAmbiguityNotification(ambiguityResolution),
child:
isLoading
? Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Filter notification (if applicable)
if (filterInfo != null)
_buildFilterNotification(filterInfo),
// Main result display - use hasil_tertinggi from backend
_buildDetailedResultFromBackend(context, hasilTertinggi),
// Ambiguity notification (if applicable)
if (isAmbiguous && ambiguityResolution != null)
_buildAmbiguityNotification(ambiguityResolution),
SizedBox(height: 24),
// Main result display - use hasil_tertinggi from backend
_buildDetailedResultFromBackend(context, hasilTertinggi),
// Selected symptoms section
_buildSection(
context,
'Gejala yang Dipilih',
widget.gejalaTerpilih.isEmpty
? _buildEmptyResult('Tidak ada gejala yang dipilih')
: Card(
SizedBox(height: 24),
// Selected symptoms section
_buildSection(
context,
'Gejala yang Dipilih',
widget.gejalaTerpilih.isEmpty
? _buildEmptyResult('Tidak ada gejala yang dipilih')
: Card(
elevation: 2,
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: widget.gejalaTerpilih
.map(
(gejala) => Padding(
padding: EdgeInsets.symmetric(
vertical: 4,
),
child: Row(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Icon(
Icons.check_circle,
color: Colors.green,
size: 18,
children:
widget.gejalaTerpilih
.map(
(gejala) => Padding(
padding: EdgeInsets.symmetric(
vertical: 4,
),
SizedBox(width: 8),
Expanded(child: Text(gejala)),
],
),
),
)
.toList(),
child: Row(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Icon(
Icons.check_circle,
color: Colors.green,
size: 18,
),
SizedBox(width: 8),
Expanded(child: Text(gejala)),
],
),
),
)
.toList(),
),
),
),
),
SizedBox(height: 24),
// Other possible diseases section
_buildSection(
context,
'Kemungkinan Penyakit Lainnya',
_buildOtherPossibilities(
penyakitList,
hasilTertinggi,
'penyakit',
),
),
SizedBox(height: 24),
// Other possible pests section
_buildSection(
context,
'Kemungkinan Hama Lainnya',
_buildOtherPossibilities(
hamaList,
hasilTertinggi,
'hama',
),
),
],
),
),
),
);
}
Widget _buildFilterNotification(Map<String, dynamic> filterInfo) {
final int totalSebelum = filterInfo['total_sebelum_filter'] ?? 0;
final int totalSetelah = filterInfo['total_setelah_filter'] ?? 0;
final int hasilTerfilter = filterInfo['hasil_terfilter'] ?? 0;
final bool fallbackToSymptomCount =
filterInfo['fallback_to_symptom_count'] ?? false;
final String? fallbackReason = filterInfo['fallback_reason'];
// Only show notification if there's filtering activity
if (hasilTerfilter == 0 && !fallbackToSymptomCount) {
return SizedBox.shrink();
}
Color cardColor;
Color iconColor;
IconData iconData;
String title;
String description;
if (fallbackToSymptomCount) {
// Fallback scenario
cardColor = Colors.orange.shade50;
iconColor = Colors.orange.shade700;
iconData = Icons.warning_amber_outlined;
title = 'Penyesuaian Hasil Diagnosa';
description =
'Sistem menggunakan kecocokan gejala terbanyak karena hasil dengan akurasi 100% hanya cocok dengan 1 gejala.';
} else {
// Normal filtering
cardColor = Colors.green.shade50;
iconColor = Colors.green.shade700;
iconData = Icons.filter_alt_outlined;
title = 'Filter Hasil Diagnosa';
description =
'Sistem memfilter $hasilTerfilter hasil dengan akurasi 100% yang hanya cocok dengan 1 gejala untuk memberikan diagnosis yang lebih akurat.';
}
return Card(
color: cardColor,
elevation: 2,
margin: EdgeInsets.only(bottom: 16),
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(iconData, color: iconColor, size: 24),
SizedBox(width: 8),
Expanded(
child: Text(
title,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: iconColor,
),
SizedBox(height: 24),
// Other possible diseases section
_buildSection(
context,
'Kemungkinan Penyakit Lainnya',
_buildOtherPossibilities(penyakitList, hasilTertinggi, 'penyakit'),
),
SizedBox(height: 24),
// Other possible pests section
_buildSection(
context,
'Kemungkinan Hama Lainnya',
_buildOtherPossibilities(hamaList, hasilTertinggi, 'hama'),
),
],
),
),
],
),
SizedBox(height: 12),
Text(description, style: TextStyle(fontSize: 14)),
if (fallbackReason != null) ...[
SizedBox(height: 8),
Container(
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Alasan: $fallbackReason',
style: TextStyle(
fontSize: 12,
fontStyle: FontStyle.italic,
color: Colors.orange.shade800,
),
),
),
] else if (!fallbackToSymptomCount) ...[
SizedBox(height: 8),
Row(
children: [
Text(
'Total hasil: $totalSebelum$totalSetelah',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.green.shade700,
),
),
],
),
],
],
),
),
);
}
@ -183,11 +303,7 @@ class _HasilDiagnosaPageState extends State<HasilDiagnosaPage> {
children: [
Row(
children: [
Icon(
Icons.info_outline,
color: Colors.blue.shade700,
size: 24,
),
Icon(Icons.info_outline, color: Colors.blue.shade700, size: 24),
SizedBox(width: 8),
Expanded(
child: Text(
@ -249,11 +365,13 @@ class _HasilDiagnosaPageState extends State<HasilDiagnosaPage> {
// Determine type based on the presence of id fields
String type = '';
bool isPenyakit = false;
if (hasilTertinggi.containsKey('id_penyakit') && hasilTertinggi['id_penyakit'] != null) {
if (hasilTertinggi.containsKey('id_penyakit') &&
hasilTertinggi['id_penyakit'] != null) {
type = 'penyakit';
isPenyakit = true;
} else if (hasilTertinggi.containsKey('id_hama') && hasilTertinggi['id_hama'] != null) {
} else if (hasilTertinggi.containsKey('id_hama') &&
hasilTertinggi['id_hama'] != null) {
type = 'hama';
isPenyakit = false;
} else {
@ -266,22 +384,28 @@ class _HasilDiagnosaPageState extends State<HasilDiagnosaPage> {
final completeData = _getCompleteItemData(hasilTertinggi, type);
// Extract the data we need with safe access
final nama = completeData['nama'] ?? hasilTertinggi['nama'] ?? 'Tidak diketahui';
final nama =
completeData['nama'] ?? hasilTertinggi['nama'] ?? 'Tidak diketahui';
final deskripsi = completeData['deskripsi'] ?? 'Tidak tersedia';
final penanganan = completeData['penanganan'] ?? 'Tidak tersedia';
final foto = completeData['foto'];
final probabilitas = _getProbabilitas(hasilTertinggi);
// Get additional ambiguity info if available
final jumlahGejalacocok = hasilTertinggi['jumlah_gejala_cocok'];
final totalGejalaEntity = hasilTertinggi['total_gejala_entity'];
final persentaseKesesuaian = hasilTertinggi['persentase_kesesuaian'];
// Check if this is a perfect match result that would normally be filtered
final isPerfectSingleMatch =
(probabilitas * 100).round() == 100 && jumlahGejalacocok == 1;
// Debug log
print('DEBUG - Building detailed result for: $nama');
print('DEBUG - Type: $type, isPenyakit: $isPenyakit');
print('DEBUG - Probabilitas: $probabilitas');
print('DEBUG - Is perfect single match: $isPerfectSingleMatch');
return Card(
elevation: 6,
shape: RoundedRectangleBorder(
@ -300,7 +424,8 @@ class _HasilDiagnosaPageState extends State<HasilDiagnosaPage> {
children: [
Icon(
isPenyakit ? Icons.coronavirus_outlined : Icons.bug_report,
color: isPenyakit ? Colors.red.shade700 : Colors.orange.shade700,
color:
isPenyakit ? Colors.red.shade700 : Colors.orange.shade700,
size: 28,
),
SizedBox(width: 8),
@ -310,14 +435,49 @@ class _HasilDiagnosaPageState extends State<HasilDiagnosaPage> {
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: isPenyakit ? Colors.red.shade700 : Colors.orange.shade700,
color:
isPenyakit
? Colors.red.shade700
: Colors.orange.shade700,
),
),
),
_buildProbabilityIndicator(probabilitas),
],
),
// Show warning if this is a perfect single match result
if (isPerfectSingleMatch)
Container(
margin: EdgeInsets.only(top: 8),
padding: EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.yellow.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.yellow.shade300),
),
child: Row(
children: [
Icon(
Icons.info_outline,
size: 16,
color: Colors.orange.shade700,
),
SizedBox(width: 6),
Expanded(
child: Text(
'Hasil ini dipilih berdasarkan kecocokan gejala terbanyak',
style: TextStyle(
fontSize: 12,
color: Colors.orange.shade700,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
// Additional info if ambiguity resolution occurred
if (jumlahGejalacocok != null && totalGejalaEntity != null)
Container(
@ -329,7 +489,11 @@ class _HasilDiagnosaPageState extends State<HasilDiagnosaPage> {
),
child: Row(
children: [
Icon(Icons.analytics_outlined, size: 16, color: Colors.grey.shade600),
Icon(
Icons.analytics_outlined,
size: 16,
color: Colors.grey.shade600,
),
SizedBox(width: 6),
Text(
'Kesesuaian: $jumlahGejalacocok/$totalGejalaEntity gejala',
@ -350,7 +514,7 @@ class _HasilDiagnosaPageState extends State<HasilDiagnosaPage> {
],
),
),
Divider(thickness: 1, height: 24),
// Image section
@ -431,53 +595,76 @@ class _HasilDiagnosaPageState extends State<HasilDiagnosaPage> {
);
}
Widget _buildOtherPossibilities(
Widget _buildOtherPossibilities(
List<dynamic> itemList,
Map<String, dynamic>? hasilTertinggi,
String type,
) {
if (itemList.isEmpty) {
return _buildEmptyResult('Tidak ada kemungkinan ${type} lainnya');
) {
// Check if there's a 100% match in hasilTertinggi
if (hasilTertinggi != null) {
double probabilitas = _getProbabilitas(hasilTertinggi);
if ((probabilitas * 100).round() == 100) {
return _buildEmptyResult(
'Ditemukan kecocokan 100% pada diagnosa utama',
);
}
// Filter out the top result that's already shown
List<dynamic> otherItems = [];
if (hasilTertinggi != null) {
// Get the ID of the top result
String? topResultId;
if (type == 'penyakit' && hasilTertinggi.containsKey('id_penyakit')) {
topResultId = hasilTertinggi['id_penyakit']?.toString();
} else if (type == 'hama' && hasilTertinggi.containsKey('id_hama')) {
topResultId = hasilTertinggi['id_hama']?.toString();
}
// Filter out the top result
otherItems = itemList.where((item) {
String? itemId;
if (type == 'penyakit') {
itemId = item['id_penyakit']?.toString();
} else {
itemId = item['id_hama']?.toString();
}
return topResultId == null || itemId != topResultId;
}).toList();
} else {
// If no top result, skip the first item
otherItems = itemList.skip(1).toList();
}
if (otherItems.isEmpty) {
return _buildEmptyResult('Tidak ada kemungkinan ${type} lainnya');
}
return Column(
children: otherItems
.map((item) => _buildItemCard(item, type))
.toList(),
);
}
if (itemList.isEmpty) {
return _buildEmptyResult('Tidak ada kemungkinan ${type} lainnya');
}
// Filter out items with 100% probability and the top result
List otherItems = [];
if (hasilTertinggi != null) {
// Get the ID of the top result
String? topResultId;
if (type == 'penyakit' && hasilTertinggi.containsKey('id_penyakit')) {
topResultId = hasilTertinggi['id_penyakit']?.toString();
} else if (type == 'hama' && hasilTertinggi.containsKey('id_hama')) {
topResultId = hasilTertinggi['id_hama']?.toString();
}
// Filter out the top result AND items with 100% probability
otherItems = itemList.where((item) {
String? itemId;
if (type == 'penyakit') {
itemId = item['id_penyakit']?.toString();
} else {
itemId = item['id_hama']?.toString();
}
// Skip if this is the top result
if (topResultId != null && itemId == topResultId) {
return false;
}
// Skip if this item has 100% probability
double itemProbabilitas = _getProbabilitas(item);
if ((itemProbabilitas * 100).round() == 100) {
return false;
}
return true;
}).toList();
} else {
// If no hasilTertinggi, filter out 100% probability items from all except first
otherItems = itemList.skip(1).where((item) {
double itemProbabilitas = _getProbabilitas(item);
return (itemProbabilitas * 100).round() != 100;
}).toList();
}
if (otherItems.isEmpty) {
return _buildEmptyResult('Tidak ada kemungkinan ${type} lainnya');
}
return Column(
children: otherItems.map((item) => _buildItemCard(item, type)).toList(),
);
}
Future<void> _fetchAdditionalData() async {
setState(() {
isLoading = true;
@ -518,7 +705,8 @@ class _HasilDiagnosaPageState extends State<HasilDiagnosaPage> {
if (detail.isNotEmpty) {
double probability = 0.0;
if (penyakit.containsKey('probabilitas_persen')) {
probability = (penyakit['probabilitas_persen'] as num).toDouble() / 100;
probability =
(penyakit['probabilitas_persen'] as num).toDouble() / 100;
} else if (penyakit.containsKey('nilai_bayes')) {
probability = (penyakit['nilai_bayes'] as num).toDouble();
}
@ -529,7 +717,8 @@ class _HasilDiagnosaPageState extends State<HasilDiagnosaPage> {
'id_penyakit': penyakitIdStr,
};
final nama = penyakitDetails[penyakitIdStr]?['nama'] ?? 'Nama tidak ditemukan';
final nama =
penyakitDetails[penyakitIdStr]?['nama'] ?? 'Nama tidak ditemukan';
print('DEBUG - Found details for penyakit ID $penyakitIdStr: $nama');
}
}
@ -561,7 +750,8 @@ class _HasilDiagnosaPageState extends State<HasilDiagnosaPage> {
'id_hama': hamaIdStr,
};
final nama = hamaDetails[hamaIdStr]?['nama'] ?? 'Nama tidak ditemukan';
final nama =
hamaDetails[hamaIdStr]?['nama'] ?? 'Nama tidak ditemukan';
print('DEBUG - Found details for hama ID $hamaIdStr: $nama');
}
}
@ -597,7 +787,9 @@ class _HasilDiagnosaPageState extends State<HasilDiagnosaPage> {
details = penyakitDetails[idStr];
if (details == null || details.isEmpty) {
print('DEBUG - No cached details for penyakit ID: $idStr, searching API data...');
print(
'DEBUG - No cached details for penyakit ID: $idStr, searching API data...',
);
details = semuaPenyakit.firstWhere(
(p) => p['id'].toString() == idStr,
orElse: () => <String, dynamic>{},
@ -611,7 +803,9 @@ class _HasilDiagnosaPageState extends State<HasilDiagnosaPage> {
details = hamaDetails[idStr];
if (details == null || details.isEmpty) {
print('DEBUG - No cached details for hama ID: $idStr, searching API data...');
print(
'DEBUG - No cached details for hama ID: $idStr, searching API data...',
);
details = semuaHama.firstWhere(
(h) => h['id'].toString() == idStr,
orElse: () => <String, dynamic>{},
@ -644,7 +838,9 @@ class _HasilDiagnosaPageState extends State<HasilDiagnosaPage> {
type == 'penyakit' ? 'id_penyakit' : 'id_hama': idStr,
};
print('DEBUG - Final data for $type ID $idStr (${result['nama']}): probabilitas=${result['probabilitas']}');
print(
'DEBUG - Final data for $type ID $idStr (${result['nama']}): probabilitas=${result['probabilitas']}',
);
} else {
print('DEBUG - No details found for $type ID $idStr');
}
@ -656,32 +852,63 @@ class _HasilDiagnosaPageState extends State<HasilDiagnosaPage> {
final completeData = _getCompleteItemData(item, type);
final nama = completeData['nama'] ?? 'Tidak diketahui';
final probabilitas = _getProbabilitas(completeData);
// Get additional info for display
final jumlahGejalacocok = item['jumlah_gejala_cocok'];
final totalGejalaEntity = item['total_gejala_entity'];
final persentaseKesesuaian = item['persentase_kesesuaian'];
return Card(
margin: EdgeInsets.only(bottom: 8),
elevation: 2,
child: ListTile(
leading: Icon(
type == 'penyakit' ? Icons.coronavirus_outlined : Icons.bug_report,
color: type == 'penyakit' ? Colors.red.shade700 : Colors.orange.shade700,
color: type == 'penyakit' ? Color(0xFF9DC08D) : Color(0xFF7A9A6D),
size: 24,
),
title: Text(nama),
subtitle: jumlahGejalacocok != null && totalGejalaEntity != null
? Text(
'Kesesuaian: $jumlahGejalacocok/$totalGejalaEntity gejala',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
)
: null,
trailing: Row(
mainAxisSize: MainAxisSize.min,
title: Text(
nama,
style: TextStyle(
fontWeight: FontWeight.w500,
color: Color(0xFF40513B),
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildProbabilityIndicator(probabilitas),
SizedBox(width: 8),
if (jumlahGejalacocok != null && totalGejalaEntity != null) ...[
SizedBox(height: 4),
Text(
'Kesesuaian: $jumlahGejalacocok/$totalGejalaEntity gejala',
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
if (persentaseKesesuaian != null)
Text(
'(${persentaseKesesuaian.toStringAsFixed(1)}%)',
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
],
],
),
trailing: Container(
width: 60,
height: 30,
decoration: BoxDecoration(
color: type == 'penyakit' ? Color(0xFF9DC08D) : Color(0xFF7A9A6D),
borderRadius: BorderRadius.circular(15),
),
child: Center(
child: Text(
'${(probabilitas * 100).toStringAsFixed(0)}%',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
),
),
);
}
@ -732,9 +959,10 @@ class _HasilDiagnosaPageState extends State<HasilDiagnosaPage> {
}
Widget _buildProbabilityIndicator(double value) {
final Color indicatorColor = value > 0.7
? Colors.red
: value > 0.4
final Color indicatorColor =
value > 0.7
? Colors.red
: value > 0.4
? Colors.orange
: Colors.green;
@ -832,4 +1060,4 @@ Widget _buildProbabilityIndicator(double value) {
),
),
);
}
}

View File

@ -16,7 +16,34 @@ class _LoginPageState extends State<LoginPage> {
final TextEditingController _emailController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
bool _isLoading = false;
bool _isPasswordVisible = false;
// Method to show error dialog
void _showErrorDialog(String message) {
showDialog(
context: context,
builder:
(context) => AlertDialog(
title: Row(
children: [
Icon(Icons.error_outline, color: Colors.red),
SizedBox(width: 10),
Text('Error'),
],
),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('OK', style: TextStyle(color: Color(0xFF9DC08D))),
),
],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
),
);
}
Future<void> _login() async {
setState(() {
@ -27,12 +54,10 @@ class _LoginPageState extends State<LoginPage> {
String password = _passwordController.text.trim();
if (email.isEmpty || password.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Email dan Password harus diisi")),
);
setState(() {
_isLoading = false;
});
_showErrorDialog('Email dan Password harus diisi');
return;
}
@ -41,10 +66,7 @@ class _LoginPageState extends State<LoginPage> {
var response = await http.post(
url,
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"email": email,
"password": password,
}),
body: jsonEncode({"email": email, "password": password}),
);
var responseData = jsonDecode(response.body);
@ -56,7 +78,7 @@ class _LoginPageState extends State<LoginPage> {
await prefs.setString('token', responseData['token']);
await prefs.setString('role', responseData['role']);
await prefs.setString('email', email);
await prefs.setString('userId', responseData['userId'].toString());
await prefs.setString('userId', responseData['userId'].toString());
// Redirect berdasarkan role
if (responseData['role'] == 'admin') {
@ -71,19 +93,19 @@ class _LoginPageState extends State<LoginPage> {
);
}
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(responseData['message'] ?? "Login gagal")),
setState(() {
_isLoading = false;
});
_showErrorDialog(
responseData['message'] ?? 'Email atau Password salah',
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Terjadi kesalahan: $e")),
);
setState(() {
_isLoading = false;
});
_showErrorDialog('Terjadi kesalahan koneksi. Silakan coba lagi.');
}
setState(() {
_isLoading = false;
});
}
@override
@ -109,10 +131,7 @@ class _LoginPageState extends State<LoginPage> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'assets/images/bayam.png',
height: 150,
),
Image.asset('assets/images/bayam.png', height: 150),
SizedBox(height: 30),
Card(
shape: RoundedRectangleBorder(
@ -128,20 +147,54 @@ class _LoginPageState extends State<LoginPage> {
controller: _emailController,
decoration: InputDecoration(
labelText: 'email',
labelStyle: TextStyle(
color: Colors.black.withOpacity(0.7),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: Colors.black.withOpacity(0.6),
width: 2,
),
),
),
),
SizedBox(height: 20),
TextField(
controller: _passwordController,
obscureText: true,
obscureText:
!_isPasswordVisible, // Toggle visibility based on state
decoration: InputDecoration(
labelText: 'Password',
labelStyle: TextStyle(
color: Colors.black.withOpacity(0.7),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: Colors.black.withOpacity(0.6),
width: 2,
),
),
suffixIcon: IconButton(
icon: Icon(
_isPasswordVisible
? Icons.visibility
: Icons.visibility_off,
color: Color(0xFF9DC08D),
),
onPressed: () {
setState(() {
_isPasswordVisible = !_isPasswordVisible;
});
},
),
),
),
SizedBox(height: 20),
@ -156,18 +209,19 @@ class _LoginPageState extends State<LoginPage> {
),
),
onPressed: _isLoading ? null : _login,
child: _isLoading
? CircularProgressIndicator(
color: Colors.white,
)
: Text(
'Login',
style: TextStyle(
child:
_isLoading
? CircularProgressIndicator(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
)
: Text(
'Login',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
),
],
@ -175,11 +229,11 @@ class _LoginPageState extends State<LoginPage> {
),
),
SizedBox(height: 20),
Row(
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
GestureDetector(
onTap: () {
TextButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
@ -187,17 +241,39 @@ class _LoginPageState extends State<LoginPage> {
),
);
},
child: Text(
'Belum punya akun? Daftar',
style: TextStyle(
color: Colors.white,
decoration: TextDecoration.underline,
style: TextButton.styleFrom(
backgroundColor: Colors.white,
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.person_add_outlined,
color: Color(0xFF9DC08D),
size: 16,
),
SizedBox(width: 8),
Text(
'Belum punya akun? Daftar',
style: TextStyle(
color: Color(0xFF9DC08D),
fontWeight: FontWeight.w500,
fontSize: 13,
),
),
],
),
),
SizedBox(width: 10),
GestureDetector(
onTap: () {
SizedBox(height: 10),
TextButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
@ -205,12 +281,34 @@ class _LoginPageState extends State<LoginPage> {
),
);
},
child: Text(
'Lupa Password?',
style: TextStyle(
color: Colors.white,
decoration: TextDecoration.underline,
style: TextButton.styleFrom(
backgroundColor: Colors.white,
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.lock_reset_outlined,
color: Color(0xFF9DC08D),
size: 16,
),
SizedBox(width: 8),
Text(
'Lupa Password?',
style: TextStyle(
color: Color(0xFF9DC08D),
fontWeight: FontWeight.w500,
fontSize: 13,
),
),
],
),
),
],

View File

@ -142,7 +142,13 @@ class _ProfilPageState extends State<ProfilPage> {
context: context,
builder:
(context) => AlertDialog(
title: Text('Update Profil'),
title: Row(
children: [
Icon(Icons.edit, color: Color(0xFF9DC08D)),
SizedBox(width: 8),
Text('Update Profil'),
],
),
content: SingleChildScrollView(
child: Form(
key: _formKey,
@ -151,16 +157,40 @@ class _ProfilPageState extends State<ProfilPage> {
children: [
TextFormField(
controller: _nameController,
decoration: InputDecoration(labelText: 'Nama'),
decoration: InputDecoration(
labelText: 'Nama',
prefixIcon: Icon(
Icons.person,
color: Color(0xFF9DC08D),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Color(0xFF9DC08D)),
),
),
validator:
(value) =>
value?.isEmpty ?? true
? 'Nama tidak boleh kosong'
: null,
),
SizedBox(height: 16),
TextFormField(
controller: _emailController,
decoration: InputDecoration(labelText: 'Email'),
decoration: InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email, color: Color(0xFF9DC08D)),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Color(0xFF9DC08D)),
),
),
validator: (value) {
if (value?.isEmpty ?? true)
return 'Email tidak boleh kosong';
@ -168,12 +198,21 @@ class _ProfilPageState extends State<ProfilPage> {
return null;
},
),
SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: InputDecoration(
labelText: 'Password Baru',
helperText:
'Kosongkan jika tidak ingin mengubah password',
prefixIcon: Icon(Icons.lock, color: Color(0xFF9DC08D)),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Color(0xFF9DC08D)),
),
),
obscureText: true,
validator: (value) {
@ -184,18 +223,43 @@ class _ProfilPageState extends State<ProfilPage> {
return null;
},
),
SizedBox(height: 16),
TextFormField(
controller: _alamatController,
decoration: InputDecoration(labelText: 'Alamat'),
decoration: InputDecoration(
labelText: 'Alamat',
prefixIcon: Icon(
Icons.location_on,
color: Color(0xFF9DC08D),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Color(0xFF9DC08D)),
),
),
validator:
(value) =>
value?.isEmpty ?? true
? 'Alamat tidak boleh kosong'
: null,
),
SizedBox(height: 16),
TextFormField(
controller: _nomorTeleponController,
decoration: InputDecoration(labelText: 'Nomor Telepon'),
decoration: InputDecoration(
labelText: 'Nomor Telepon',
prefixIcon: Icon(Icons.phone, color: Color(0xFF9DC08D)),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Color(0xFF9DC08D)),
),
),
keyboardType: TextInputType.phone,
validator:
(value) =>
@ -208,11 +272,12 @@ class _ProfilPageState extends State<ProfilPage> {
),
),
actions: [
TextButton(
TextButton.icon(
onPressed: () => Navigator.pop(context),
child: Text('Batal'),
icon: Icon(Icons.cancel, color: Colors.grey),
label: Text('Batal', style: TextStyle(color: Colors.grey)),
),
ElevatedButton(
ElevatedButton.icon(
onPressed: () async {
if (_formKey.currentState!.validate()) {
try {
@ -234,15 +299,29 @@ class _ProfilPageState extends State<ProfilPage> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Profil berhasil diperbarui'),
content: Row(
children: [
Icon(Icons.check_circle, color: Colors.white),
SizedBox(width: 8),
Text('Profil berhasil diperbarui'),
],
),
backgroundColor: Colors.green,
),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Gagal memperbarui profil: ${e.toString()}',
content: Row(
children: [
Icon(Icons.error, color: Colors.white),
SizedBox(width: 8),
Expanded(
child: Text(
'Gagal memperbarui profil: ${e.toString()}',
),
),
],
),
backgroundColor: Colors.red,
),
@ -250,10 +329,11 @@ class _ProfilPageState extends State<ProfilPage> {
}
}
},
icon: Icon(Icons.save, color: Colors.white),
label: Text('Update', style: TextStyle(color: Colors.white)),
style: ElevatedButton.styleFrom(
backgroundColor: Color(0xFF9DC08D),
),
child: Text('Update'),
),
],
),
@ -266,21 +346,38 @@ class _ProfilPageState extends State<ProfilPage> {
backgroundColor: Color(0xFF9DC08D),
body: Stack(
children: [
// Judul halaman tetap di luar border (di bagian atas dan center)
// Background decoration
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF9DC08D), Color(0xFF8BB37A)],
),
),
),
// Judul halaman dengan icon
Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.only(top: 40.0),
child: Column(
children: [
Text(
"Profil Pengguna",
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
textAlign: TextAlign.center,
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.account_circle, color: Colors.white, size: 32),
SizedBox(width: 12),
Text(
"Profil Pengguna",
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
],
),
SizedBox(height: 80),
],
@ -292,32 +389,38 @@ class _ProfilPageState extends State<ProfilPage> {
Positioned(
top: 40.0,
left: 16.0,
child: IconButton(
icon: Icon(Icons.arrow_back, color: Colors.white, size: 28),
onPressed: () {
Navigator.pop(context);
},
child: Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(25),
),
child: IconButton(
icon: Icon(Icons.arrow_back, color: Colors.white, size: 28),
onPressed: () {
Navigator.pop(context);
},
),
),
),
// Isi halaman
Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 36.0),
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Card box untuk data pengguna
Container(
height: 200,
width: 450,
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(16),
),
elevation: 4,
elevation: 8,
shadowColor: Colors.black.withOpacity(0.2),
child: Padding(
padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.all(20.0),
child:
isLoading
? _buildLoadingState()
@ -329,51 +432,72 @@ class _ProfilPageState extends State<ProfilPage> {
),
SizedBox(height: 30),
// Button untuk update data profil
ElevatedButton(
onPressed: _showUpdateProfileDialog,
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
// Buttons row
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Button untuk update data profil
Expanded(
child: ElevatedButton.icon(
onPressed: _showUpdateProfileDialog,
icon: Icon(
Icons.edit,
color: Color(0xFF9DC08D),
size: 20,
),
label: Text(
"Update Profil",
style: TextStyle(
fontSize: 14,
color: Color(0xFF9DC08D),
fontWeight: FontWeight.bold,
),
),
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
backgroundColor: Colors.white,
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
elevation: 2,
),
),
),
backgroundColor: Colors.white,
padding: EdgeInsets.symmetric(
horizontal: 32,
vertical: 12,
SizedBox(width: 12),
// Button untuk logout
Expanded(
child: ElevatedButton.icon(
onPressed: () => _logout(context),
icon: Icon(
Icons.logout,
color: Colors.red[700],
size: 20,
),
label: Text(
"Logout",
style: TextStyle(
fontSize: 14,
color: Colors.red[700],
fontWeight: FontWeight.bold,
),
),
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
backgroundColor: Colors.white,
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
elevation: 2,
),
),
),
),
child: Text(
"Update Data Profil",
style: TextStyle(
fontSize: 18,
color: Color(0xFF9DC08D),
fontWeight: FontWeight.bold,
),
),
),
SizedBox(height: 30),
// Button untuk logout
ElevatedButton(
onPressed: () => _logout(context),
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
backgroundColor: Colors.white,
padding: EdgeInsets.symmetric(
horizontal: 32,
vertical: 12,
),
),
child: Text(
"Logout",
style: TextStyle(
fontSize: 18,
color: Color(0xFF9DC08D),
fontWeight: FontWeight.bold,
),
),
],
),
],
),
@ -392,7 +516,14 @@ class _ProfilPageState extends State<ProfilPage> {
children: [
CircularProgressIndicator(color: Color(0xFF9DC08D)),
SizedBox(height: 16),
Text("Memuat data profil..."),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.hourglass_empty, color: Color(0xFF9DC08D)),
SizedBox(width: 8),
Text("Memuat data profil..."),
],
),
],
),
);
@ -412,7 +543,17 @@ class _ProfilPageState extends State<ProfilPage> {
style: TextStyle(color: Colors.red),
),
SizedBox(height: 16),
TextButton(onPressed: _loadUserData, child: Text("Coba Lagi")),
ElevatedButton.icon(
onPressed: _loadUserData,
icon: Icon(Icons.refresh, color: Colors.white),
label: Text("Coba Lagi", style: TextStyle(color: Colors.white)),
style: ElevatedButton.styleFrom(
backgroundColor: Color(0xFF9DC08D),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
),
);
@ -427,20 +568,91 @@ class _ProfilPageState extends State<ProfilPage> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildProfileItem("Nama: ${userData?['name'] ?? '-'}"),
Divider(color: Colors.black),
_buildProfileItem("Email: ${userData?['email'] ?? '-'}"),
Divider(color: Colors.black),
_buildProfileItem("Alamat: ${userData?['alamat'] ?? '-'}"),
// Header card
Row(
children: [
Icon(Icons.info_outline, color: Color(0xFF9DC08D), size: 24),
SizedBox(width: 8),
Text(
"Informasi Profil",
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF9DC08D),
),
),
],
),
SizedBox(height: 16),
_buildProfileItem(Icons.person, "Nama", userData?['name'] ?? '-'),
SizedBox(height: 12),
_buildProfileItem(Icons.email, "Email", userData?['email'] ?? '-'),
SizedBox(height: 12),
_buildProfileItem(
Icons.location_on,
"Alamat",
userData?['alamat'] ?? '-',
),
SizedBox(height: 12),
_buildProfileItem(
Icons.admin_panel_settings,
"Role",
userData?['role'] ?? '-',
),
],
);
}
// Fungsi untuk membuat item dalam Card box
Widget _buildProfileItem(String text) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Text(text, style: TextStyle(fontSize: 18)),
// Fungsi untuk membuat item dalam Card box dengan icon
Widget _buildProfileItem(IconData icon, String label, String value) {
return Container(
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[50],
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey[200]!),
),
child: Row(
children: [
Container(
padding: EdgeInsets.all(8),
decoration: BoxDecoration(
color: Color(0xFF9DC08D).withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: Icon(icon, color: Color(0xFF9DC08D), size: 20),
),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 2),
Text(
value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
],
),
),
],
),
);
}
}
}

View File

@ -2,15 +2,113 @@ import 'package:flutter/material.dart';
import 'package:frontend/api_services/api_services.dart';
// Halaman Pendaftaran
class RegisterPage extends StatelessWidget {
class RegisterPage extends StatefulWidget {
@override
_RegisterPageState createState() => _RegisterPageState();
}
class _RegisterPageState extends State<RegisterPage> {
final TextEditingController nameController = TextEditingController();
final TextEditingController emailController = TextEditingController();
final TextEditingController passwordController = TextEditingController();
final TextEditingController alamatController = TextEditingController();
final TextEditingController nomorHpController = TextEditingController();
bool _isLoading = false;
final ApiService apiService = ApiService();
void _showErrorDialog(String message) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Icon(Icons.error_outline, color: Colors.red),
SizedBox(width: 10),
Text('Registrasi Gagal'),
],
),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
'OK',
style: TextStyle(color: Color(0xFF9DC08D)),
),
),
],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
),
);
}
void _showSuccessDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Icon(Icons.check_circle, color: Color(0xFF9DC08D)),
SizedBox(width: 10),
Text('Berhasil'),
],
),
content: Text('Registrasi berhasil! Silahkan login dengan akun Anda.'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(); // Close dialog
Navigator.of(context).pop(); // Back to login page
},
child: Text(
'OK',
style: TextStyle(color: Color(0xFF9DC08D)),
),
),
],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
),
);
}
Future<void> _handleRegister() async {
// Validasi input
if (nameController.text.isEmpty ||
emailController.text.isEmpty ||
passwordController.text.isEmpty ||
alamatController.text.isEmpty ||
nomorHpController.text.isEmpty) {
_showErrorDialog('Semua field harus diisi');
return;
}
setState(() {
_isLoading = true;
});
try {
await apiService.registerUser(
name: nameController.text,
email: emailController.text,
password: passwordController.text,
alamat: alamatController.text,
nomorTelepon: nomorHpController.text,
);
_showSuccessDialog();
} catch (e) {
_showErrorDialog(e.toString());
} finally {
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -35,9 +133,14 @@ class RegisterPage extends StatelessWidget {
TextField(
decoration: InputDecoration(
labelText: 'Nama',
labelStyle: TextStyle(color: Colors.black.withOpacity(0.7)),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.black.withOpacity(0.6), width: 2),
),
),
controller: nameController,
),
@ -46,9 +149,14 @@ class RegisterPage extends StatelessWidget {
obscureText: true,
decoration: InputDecoration(
labelText: 'Password',
labelStyle: TextStyle(color: Colors.black.withOpacity(0.7)),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.black.withOpacity(0.6), width: 2),
),
),
controller: passwordController,
),
@ -56,9 +164,14 @@ class RegisterPage extends StatelessWidget {
TextField(
decoration: InputDecoration(
labelText: 'Email',
labelStyle: TextStyle(color: Colors.black.withOpacity(0.7)),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.black.withOpacity(0.6), width: 2),
),
),
controller: emailController,
),
@ -66,22 +179,32 @@ class RegisterPage extends StatelessWidget {
TextField(
decoration: InputDecoration(
labelText: 'Alamat',
labelStyle: TextStyle(color: Colors.black.withOpacity(0.7)),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.black.withOpacity(0.6), width: 2),
),
),
controller: alamatController,
),
SizedBox(height: 20),
TextField(
decoration: InputDecoration(
labelText: 'Nomor HP',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
),
controller: nomorHpController,
),
// TextField(
// decoration: InputDecoration(
// labelText: 'Nomor HP',
// labelStyle: TextStyle(color: Colors.black.withOpacity(0.7)),
// border: OutlineInputBorder(
// borderRadius: BorderRadius.circular(10),
// ),
// focusedBorder: OutlineInputBorder(
// borderRadius: BorderRadius.circular(10),
// borderSide: BorderSide(color: Colors.black.withOpacity(0.6), width: 2),
// ),
// ),
// controller: nomorHpController,
// ),
SizedBox(height: 20),
SizedBox(
width: double.infinity,
@ -93,33 +216,17 @@ class RegisterPage extends StatelessWidget {
borderRadius: BorderRadius.circular(10),
),
),
onPressed: () async {
try {
await apiService.registerUser(
name: nameController.text,
email: emailController.text,
password: passwordController.text,
alamat: alamatController.text,
nomorTelepon: nomorHpController.text,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Registrasi berhasil!')),
);
Navigator.pop(context); // kembali ke login page
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Registrasi gagal: $e')),
);
}
},
child: Text(
'Daftar',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
onPressed: _isLoading ? null : _handleRegister,
child: _isLoading
? CircularProgressIndicator(color: Colors.white)
: Text(
'Daftar',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
],