import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'dart:convert'; import 'dart:math'; import '../data/disease_data.dart'; import '../logic/fuzzy_mamdani.dart'; import '../models/disease.dart'; import '../models/symptom.dart'; import 'result_page.dart'; class DiagnosisPage extends StatefulWidget { const DiagnosisPage({super.key}); @override State createState() => _DiagnosisPageState(); } class _DiagnosisPageState extends State with TickerProviderStateMixin { final FuzzyMamdani fuzzyMamdani = FuzzyMamdani(); Map selectedSymptoms = {}; List filteredSymptoms = symptoms; final TextEditingController _searchController = TextEditingController(); final TextEditingController _historySearchController = TextEditingController(); bool isLoading = false; List> history = []; List> filteredHistory = []; late AnimationController _scaleController; late AnimationController _dotController; late AnimationController _textScaleController; late Animation _scaleAnimation; late Animation _dotAnimation; late Animation _textScaleAnimation; late AnimationController _countScaleController; late Animation _countScaleAnimation; late TabController _tabController; final List _fadeControllers = []; final List _slideControllers = []; String sortOrder = 'Terbaru'; final List loadingMessages = [ 'Menganalisis gejala...', 'Memeriksa tanaman...', 'Mendiagnosis...', 'Memproses data...', ]; final Map expandedSymptoms = {}; @override void initState() { super.initState(); _searchController.addListener(filterAndSortSymptoms); _historySearchController.addListener(filterHistory); _tabController = TabController(length: 2, vsync: this); _tabController.addListener(() { if (mounted) setState(() {}); }); _scaleController = AnimationController( duration: const Duration(milliseconds: 300), vsync: this, ); _scaleAnimation = Tween(begin: 0.5, end: 1.0).animate( CurvedAnimation(parent: _scaleController, curve: Curves.easeOutBack), ); _dotController = AnimationController( duration: const Duration(milliseconds: 1500), vsync: this, )..repeat(); _dotAnimation = IntTween(begin: 0, end: 3).animate( CurvedAnimation(parent: _dotController, curve: Curves.linear), ); _textScaleController = AnimationController( duration: const Duration(milliseconds: 600), vsync: this, )..repeat(reverse: true); _textScaleAnimation = Tween(begin: 0.9, end: 1.1).animate( CurvedAnimation(parent: _textScaleController, curve: Curves.easeInOut), ); _countScaleController = AnimationController( duration: const Duration(milliseconds: 200), vsync: this, ); _countScaleAnimation = Tween(begin: 1.0, end: 1.2).animate( CurvedAnimation(parent: _countScaleController, curve: Curves.elasticOut), ); _loadHistory(); } Future _loadHistory() async { try { final prefs = await SharedPreferences.getInstance(); final historyData = prefs.getStringList('diagnosis_history') ?? []; List> tempHistory = []; for (var item in historyData) { var entry = jsonDecode(item) as Map; var results = entry['results'] as Map; var updatedResults = Map>.from(results); for (var resultEntry in updatedResults.entries) { if (!resultEntry.value.containsKey('matchedSymptoms')) { resultEntry.value['matchedSymptoms'] = (entry['selectedSymptoms'] as Map) .keys .toList(); } resultEntry.value['confidence'] = resultEntry.value['confidence'] ?? 0.0; resultEntry.value['matchPercentage'] = resultEntry.value['matchPercentage'] ?? 0.0; resultEntry.value['recommendation'] = resultEntry.value['recommendation'] ?? ''; resultEntry.value['totalWeight'] = resultEntry.value['totalWeight'] ?? 0.0; } entry['results'] = updatedResults; tempHistory.add(entry); } if (mounted) { setState(() { history = tempHistory; filteredHistory = List.from(history); sortHistory(); _fadeControllers.clear(); _slideControllers.clear(); for (var i = 0; i < history.length; i++) { final fadeController = AnimationController( duration: const Duration(milliseconds: 500), vsync: this, )..forward(); final slideController = AnimationController( duration: const Duration(milliseconds: 500), vsync: this, )..forward(); _fadeControllers.add(fadeController); _slideControllers.add(slideController); } }); } } catch (e) { if (mounted) { setState(() { history = []; filteredHistory = []; }); } } } Future _saveHistory(Map> results) async { try { final prefs = await SharedPreferences.getInstance(); final timestamp = DateTime.now().toIso8601String(); final updatedResults = Map>.from(results); for (var entry in updatedResults.entries) { if (!entry.value.containsKey('matchedSymptoms')) { entry.value['matchedSymptoms'] = selectedSymptoms.keys.toList(); } } final historyEntry = { 'timestamp': timestamp, 'selectedSymptoms': selectedSymptoms.map((key, value) => MapEntry(key, symptoms.firstWhere((s) => s.id == key).description)), 'results': updatedResults.map( (key, value) => MapEntry(key, Map.from(value))), }; final historyData = prefs.getStringList('diagnosis_history') ?? []; historyData.add(jsonEncode(historyEntry)); if (historyData.length > 50) historyData.removeAt(0); await prefs.setStringList('diagnosis_history', historyData); await _loadHistory(); } catch (e) {} } Future _deleteHistoryEntry(int index) async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15.0)), backgroundColor: Colors.white, elevation: 10, title: Row( children: const [ Icon(Icons.warning_rounded, color: Color(0xFFAC2B36), size: 28.0), SizedBox(width: 10), Text('Konfirmasi', style: TextStyle( fontSize: 20.0, fontWeight: FontWeight.bold, color: Color(0xFFAC2B36))), ], ), content: const Text('Apakah Anda yakin ingin menghapus riwayat ini?', style: TextStyle(fontSize: 16.0, color: Colors.black87, height: 1.5)), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Batal', style: TextStyle(fontSize: 16.0, color: Colors.grey)), ), ElevatedButton( onPressed: () => Navigator.pop(context, true), style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFFAC2B36), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10.0)), ), child: const Text('Hapus', style: TextStyle(fontSize: 16.0, color: Colors.white)), ), ], ), ); if (confirmed == true) { try { final prefs = await SharedPreferences.getInstance(); final historyData = prefs.getStringList('diagnosis_history') ?? []; final reversedIndex = history.length - 1 - index; historyData.removeAt(reversedIndex); await prefs.setStringList('diagnosis_history', historyData); await _loadHistory(); } catch (e) {} } } Future _clearHistory() async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15.0)), backgroundColor: Colors.white, elevation: 10, title: Row( children: const [ Icon(Icons.warning_rounded, color: Color(0xFFAC2B36), size: 28.0), SizedBox(width: 10), Text('Konfirmasi', style: TextStyle( fontSize: 20.0, fontWeight: FontWeight.bold, color: Color(0xFFAC2B36))), ], ), content: const Text('Apakah Anda yakin ingin menghapus semua riwayat?', style: TextStyle(fontSize: 16.0, color: Colors.black87, height: 1.5)), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Batal', style: TextStyle(fontSize: 16.0, color: Colors.grey)), ), ElevatedButton( onPressed: () => Navigator.pop(context, true), style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFFAC2B36), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10.0)), ), child: const Text('Hapus', style: TextStyle(fontSize: 16.0, color: Colors.white)), ), ], ), ); if (confirmed == true) { try { final prefs = await SharedPreferences.getInstance(); await prefs.remove('diagnosis_history'); if (mounted) { setState(() { history = []; filteredHistory = []; _fadeControllers.clear(); _slideControllers.clear(); }); } } catch (e) {} } } void filterAndSortSymptoms() { final query = _searchController.text.trim().toLowerCase(); if (mounted) { setState(() { filteredSymptoms = query.isEmpty ? symptoms : symptoms .where((symptom) => symptom.description.toLowerCase().contains(query)) .toList(); }); } } void filterHistory() { final query = _historySearchController.text.trim().toLowerCase(); if (mounted) { setState(() { filteredHistory = query.isEmpty ? List.from(history) : history.where((entry) { final results = entry['results'] as Map; final topDisease = results.entries .where((e) => e.value['confidence'] >= 10) .toList() ..sort((a, b) => b.value['confidence'].compareTo(a.value['confidence'])); final diseaseName = topDisease.isNotEmpty ? diseases .firstWhere( (d) => d.id == topDisease[0].key, orElse: () => Disease( id: '', name: 'Tidak Diketahui', symptomIds: [], solutions: []), ) .name .toLowerCase() : 'tidak diketahui'; return diseaseName.contains(query); }).toList(); sortHistory(); }); } } void sortHistory() { if (mounted) { setState(() { filteredHistory.sort((a, b) { final dateA = DateTime.parse(a['timestamp']); final dateB = DateTime.parse(b['timestamp']); return sortOrder == 'Terbaru' ? dateB.compareTo(dateA) : dateA.compareTo(dateB); }); }); } } void toggleSymptom(Symptom symptom) { if (mounted) { setState(() { if (selectedSymptoms.containsKey(symptom.id)) { selectedSymptoms.remove(symptom.id); } else { selectedSymptoms[symptom.id] = symptom.weight; } _countScaleController.forward(from: 0.0); }); } } void onDiagnose() async { if (selectedSymptoms.isEmpty) { _scaleController.forward(from: 0.0); await showDialog( context: context, builder: (context) => ScaleTransition( scale: _scaleAnimation, child: AlertDialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(15.0)), backgroundColor: Colors.white, elevation: 10, title: Row( children: const [ Icon(Icons.local_florist, color: Color(0xFFAC2B36), size: 28.0), SizedBox(width: 10), Text( 'Peringatan', style: TextStyle( fontSize: 20.0, fontWeight: FontWeight.bold, color: Color(0xFFAC2B36)), ), ], ), content: const Text( 'Pilih setidaknya satu gejala untuk melanjutkan.', style: TextStyle(fontSize: 16.0, color: Colors.black87, height: 1.5), ), actions: [ Padding( padding: const EdgeInsets.only(right: 8.0, bottom: 8.0), child: ElevatedButton( onPressed: () => Navigator.pop(context), style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF4CAF50), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10.0)), ), child: const Text( 'OK', style: TextStyle( fontSize: 16.0, color: Colors.white, fontWeight: FontWeight.bold), ), ), ), ], ), ), ); return; } setState(() { isLoading = true; }); _scaleController.forward(from: 0.0); _textScaleController.forward(from: 0.0); final randomMessage = loadingMessages[Random().nextInt(loadingMessages.length)]; showDialog( context: context, barrierDismissible: false, barrierColor: Colors.black.withOpacity(0.2), builder: (context) => Dialog( backgroundColor: Colors.transparent, elevation: 0, child: FadeTransition( opacity: _scaleAnimation, child: Container( width: 180, padding: const EdgeInsets.all(12.0), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12.0), border: Border.all(color: const Color(0xFFAC2B36), width: 1.0), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.1), blurRadius: 6, offset: const Offset(0, 2), ), ], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator( color: Color(0xFFAC2B36), strokeWidth: 2.5), const SizedBox(height: 8), AnimatedBuilder( animation: _textScaleController, builder: (context, child) { String dots = '.' * (_dotAnimation.value % 4); return Transform.scale( scale: _textScaleAnimation.value, child: Text( '$randomMessage$dots', style: const TextStyle( fontSize: 13.0, fontWeight: FontWeight.w600, color: Color(0xFFAC2B36)), textAlign: TextAlign.center, ), ); }, ), ], ), ), ), ), ); final results = fuzzyMamdani.diagnose(selectedSymptoms); await _saveHistory(results); await Future.delayed(const Duration(seconds: 3)); Navigator.pop(context); setState(() { isLoading = false; }); final shouldRefresh = await Navigator.push( context, MaterialPageRoute(builder: (context) => ResultPage(results: results)), ); if (mounted) { setState(() { if (shouldRefresh == true) { selectedSymptoms.clear(); _countScaleController.forward(from: 0.0); } sortHistory(); if (_tabController.index == 1) filterHistory(); }); } } Future _showExitConfirmation() async { return await showDialog( context: context, builder: (context) => AlertDialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(15.0)), backgroundColor: Colors.white, elevation: 10, title: Row( children: const [ Icon(Icons.warning_rounded, color: Color(0xFFAC2B36), size: 28.0), SizedBox(width: 10), Text('Konfirmasi Keluar', style: TextStyle( fontSize: 20.0, fontWeight: FontWeight.bold, color: Color(0xFFAC2B36))), ], ), content: const Text( 'Apakah Anda yakin ingin keluar dari aplikasi? Perubahan yang belum disimpan akan hilang.', style: TextStyle(fontSize: 16.0, color: Colors.black87, height: 1.5), ), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Batal', style: TextStyle(fontSize: 16.0, color: Colors.grey)), ), ElevatedButton( onPressed: () => Navigator.pop(context, true), style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFFAC2B36), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10.0)), ), child: const Text('Keluar', style: TextStyle(fontSize: 16.0, color: Colors.white)), ), ], ), ) ?? false; } @override void dispose() { _searchController.dispose(); _historySearchController.dispose(); _scaleController.dispose(); _dotController.dispose(); _textScaleController.dispose(); _countScaleController.dispose(); _tabController.dispose(); for (var controller in _fadeControllers) controller.dispose(); for (var controller in _slideControllers) controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return PopScope( canPop: false, onPopInvokedWithResult: (didPop, result) async { if (didPop) return; final shouldExit = await _showExitConfirmation(); if (shouldExit && mounted) SystemNavigator.pop(); }, child: DefaultTabController( length: 2, child: Scaffold( appBar: AppBar( backgroundColor: Colors.white, elevation: 2, iconTheme: const IconThemeData(color: Colors.black), title: const Text('RawitCare', style: TextStyle( fontSize: 24.0, fontWeight: FontWeight.bold, color: Colors.black)), centerTitle: true, bottom: TabBar( controller: _tabController, labelColor: const Color(0xFFAC2B36), unselectedLabelColor: Colors.grey, indicatorColor: const Color(0xFFAC2B36), tabs: const [ Tab(text: 'Diagnosis'), Tab(text: 'Riwayat'), ], ), ), body: TabBarView( controller: _tabController, children: [ Column( children: [ Padding( padding: const EdgeInsets.symmetric( horizontal: 16.0, vertical: 8.0), child: TextField( controller: _searchController, decoration: InputDecoration( hintText: 'Cari gejala...', prefixIcon: const Icon(Icons.search, color: Colors.grey), suffixIcon: _searchController.text.isNotEmpty ? IconButton( icon: const Icon(Icons.clear, color: Colors.grey), onPressed: () => _searchController.clear(), ) : null, border: OutlineInputBorder( borderRadius: BorderRadius.circular(10.0), borderSide: const BorderSide(color: Colors.grey), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(10.0), borderSide: const BorderSide(color: Color(0xFF4CAF50)), ), contentPadding: const EdgeInsets.symmetric(vertical: 10.0), ), ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: ScaleTransition( scale: _countScaleAnimation, child: Text( 'Gejala yang dipilih: ${selectedSymptoms.length}', style: const TextStyle(fontSize: 14.0, color: Colors.grey), ), ), ), Expanded( child: ListView.builder( physics: const BouncingScrollPhysics(), padding: const EdgeInsets.all(16.0), itemCount: filteredSymptoms.length, itemBuilder: (context, index) { final symptom = filteredSymptoms[index]; final isSelected = selectedSymptoms.containsKey(symptom.id); return Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: GestureDetector( onTap: () => toggleSymptom(symptom), child: Container( padding: const EdgeInsets.symmetric( vertical: 16.0, horizontal: 20.0), decoration: BoxDecoration( color: isSelected ? const Color(0xFF4CAF50) : Colors.white, borderRadius: BorderRadius.circular(10.0), border: Border.all( color: const Color(0xFF4CAF50), width: 1.0), ), child: Row( children: [ Icon( isSelected ? Icons.check_circle : Icons.circle_outlined, color: isSelected ? Colors.white : Colors.grey, ), const SizedBox(width: 10), Expanded( child: Text( symptom.description, style: TextStyle( fontSize: 16.0, color: isSelected ? Colors.white : Colors.black), ), ), ], ), ), ), ); }, ), ), Padding( padding: const EdgeInsets.all(16.0), child: SizedBox( width: double.infinity, height: 50.0, child: ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFFAC2B36), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10.0)), ), onPressed: isLoading ? null : () { HapticFeedback.lightImpact(); onDiagnose(); }, child: const Text('Diagnosa', style: TextStyle(fontSize: 16.0, color: Colors.white)), ), ), ), ], ), Column( children: [ Padding( padding: const EdgeInsets.symmetric( horizontal: 16.0, vertical: 8.0), child: Row( children: [ Expanded( child: TextField( controller: _historySearchController, decoration: InputDecoration( hintText: 'Cari penyakit...', prefixIcon: const Icon(Icons.search, color: Colors.grey), suffixIcon: _historySearchController.text.isNotEmpty ? IconButton( icon: const Icon(Icons.clear, color: Colors.grey), onPressed: () => _historySearchController.clear(), ) : null, border: OutlineInputBorder( borderRadius: BorderRadius.circular(10.0), borderSide: const BorderSide(color: Colors.grey), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(10.0), borderSide: const BorderSide(color: Color(0xFF4CAF50)), ), contentPadding: const EdgeInsets.symmetric(vertical: 10.0), ), ), ), const SizedBox(width: 8), DropdownButton( value: sortOrder, items: ['Terbaru', 'Terlama'].map((order) { return DropdownMenuItem( value: order, child: Text( order, style: TextStyle( fontSize: 14.0, color: Colors.black87, fontWeight: sortOrder == order ? FontWeight.bold : FontWeight.normal, ), ), ); }).toList(), onChanged: (value) { if (mounted) { setState(() { sortOrder = value!; sortHistory(); }); } }, underline: Container(), icon: const Icon(Icons.sort, color: Color(0xFFAC2B36)), dropdownColor: Colors.white, borderRadius: BorderRadius.circular(10.0), ), ], ), ), if (filteredHistory.isNotEmpty) Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFFAC2B36), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10.0)), ), onPressed: _clearHistory, child: const Text('Hapus Semua Riwayat', style: TextStyle(fontSize: 16.0, color: Colors.white)), ), ), Expanded( child: filteredHistory.isEmpty ? Center( child: Text( _historySearchController.text.isEmpty ? 'Belum ada riwayat diagnosis.' : 'Tidak ada riwayat yang cocok dengan pencarian.', style: const TextStyle( fontSize: 16.0, color: Colors.grey), ), ) : ListView.builder( padding: const EdgeInsets.all(16.0), itemCount: filteredHistory.length, itemBuilder: (context, index) { final entry = filteredHistory[index]; final results = entry['results'] as Map; final topDisease = results.entries .where((e) => e.value['confidence'] >= 10) .toList() ..sort((a, b) => b.value['confidence'] .compareTo(a.value['confidence'])); final diseaseName = topDisease.isNotEmpty ? diseases .firstWhere( (d) => d.id == topDisease[0].key, orElse: () => Disease( id: '', name: 'Tidak Diketahui', symptomIds: [], solutions: []), ) .name : 'Tidak Diketahui'; final confidence = topDisease.isNotEmpty ? topDisease[0].value['confidence'] : 0.0; final confidenceStr = confidence.toStringAsFixed(1); final symptomsList = (entry['selectedSymptoms'] as Map) .values .toList(); final isExpanded = expandedSymptoms[index] ?? false; return FadeTransition( opacity: _fadeControllers[index] .drive(Tween(begin: 0.0, end: 1.0)), child: SlideTransition( position: _slideControllers[index].drive( Tween( begin: const Offset(0.1, 0), end: Offset.zero)), child: Card( elevation: 3, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10.0)), margin: const EdgeInsets.symmetric( vertical: 8.0), child: Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(10.0), ), child: Column( children: [ ListTile( contentPadding: const EdgeInsets.all(16.0), leading: Tooltip( message: confidence >= 70 ? 'Risiko Tinggi' : 'Kondisi Stabil', child: Icon( confidence >= 70 ? Icons .local_fire_department : Icons.local_florist, color: confidence >= 70 ? const Color(0xFFAC2B36) : const Color(0xFF4CAF50), size: 28.0, ), ), title: Text( '$diseaseName ($confidenceStr%)', style: const TextStyle( fontSize: 18.0, fontWeight: FontWeight.bold, color: Colors.black87), ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 8), Container( padding: const EdgeInsets .symmetric( horizontal: 8.0, vertical: 4.0), decoration: BoxDecoration( color: const Color(0xFFAC2B36) .withOpacity(0.1), borderRadius: BorderRadius.circular( 6.0), ), child: Text( 'Tanggal: ${DateTime.parse(entry['timestamp']).toLocal().toString().substring(0, 16)}', style: const TextStyle( fontSize: 14.0, color: Colors.black87, fontWeight: FontWeight.w500), ), ), const SizedBox(height: 4), Text( 'Gejala: ${symptomsList.join(', ')}', style: const TextStyle( fontSize: 14.0, color: Colors.black54), maxLines: isExpanded ? null : 2, overflow: isExpanded ? null : TextOverflow.ellipsis, ), ], ), trailing: IconButton( icon: const Icon(Icons.delete, color: Color(0xFFAC2B36)), onPressed: () => _deleteHistoryEntry(index), ), onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => ResultPage( results: Map< String, Map>.from( results)), ), ).catchError((error) { ScaffoldMessenger.of(context) .showSnackBar( SnackBar( content: Text( 'Gagal membuka detail: $error'), backgroundColor: Colors.red), ); }); }, ), if (symptomsList.join(', ').length > 50) Padding( padding: const EdgeInsets.only( bottom: 8.0), child: TextButton( onPressed: () { if (mounted) { setState(() { expandedSymptoms[index] = !isExpanded; }); } }, child: Text( isExpanded ? 'Sembunyikan' : 'Lihat Selengkapnya', style: const TextStyle( color: Color(0xFF4CAF50), fontSize: 14.0), ), ), ), ], ), ), ), ), ); }, ), ), ], ), ], ), ), ), ); } }