962 lines
41 KiB
Dart
962 lines
41 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:http/http.dart' as http;
|
||
import 'dart:convert';
|
||
import 'package:geolocator/geolocator.dart';
|
||
import '../config/app_config.dart';
|
||
import '../models/kontrakan.dart';
|
||
import '../models/laundry.dart';
|
||
import '../widgets/kontrakan_card.dart';
|
||
import '../widgets/laundry_card.dart';
|
||
|
||
class RecommendationScreen extends StatefulWidget {
|
||
final String category;
|
||
|
||
const RecommendationScreen({Key? key, required this.category})
|
||
: super(key: key);
|
||
|
||
@override
|
||
State<RecommendationScreen> createState() => _RecommendationScreenState();
|
||
}
|
||
|
||
class _RecommendationScreenState extends State<RecommendationScreen> {
|
||
bool _isLoading = false;
|
||
bool _hasCalculated = false;
|
||
List<dynamic> _recommendations = [];
|
||
String? _errorMessage;
|
||
|
||
// Bobot values (percentage, total must = 100)
|
||
// Default: profil mahasiswa
|
||
int _bobotHarga = 50;
|
||
int _bobotJarak = 20;
|
||
int _bobotKriteria3 = 15; // jumlah_kamar (kontrakan) or kecepatan (laundry)
|
||
int _bobotKriteria4 = 15; // fasilitas (kontrakan) or layanan (laundry)
|
||
|
||
// Jenis layanan selection for laundry
|
||
String _selectedJenisLayanan = 'reguler';
|
||
|
||
// Location values
|
||
String _referensiJarak = 'kampus';
|
||
double? _userLatitude;
|
||
double? _userLongitude;
|
||
bool _isDetectingLocation = false;
|
||
|
||
String get _kriteria3Label =>
|
||
widget.category == 'kontrakan' ? 'Jumlah Kamar' : 'Kecepatan Layanan';
|
||
String get _kriteria4Label =>
|
||
widget.category == 'kontrakan' ? 'Fasilitas' : 'Variasi Layanan';
|
||
String get _kriteria3Key =>
|
||
widget.category == 'kontrakan' ? 'jumlah_kamar' : 'kecepatan';
|
||
String get _kriteria4Key =>
|
||
widget.category == 'kontrakan' ? 'fasilitas' : 'layanan';
|
||
|
||
int get _totalBobot =>
|
||
_bobotHarga + _bobotJarak + _bobotKriteria3 + _bobotKriteria4;
|
||
|
||
Color get _categoryColor => widget.category == 'kontrakan'
|
||
? const Color(0xFF667eea)
|
||
: const Color(0xFF764ba2);
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
// Default bobot: profil mahasiswa
|
||
_bobotHarga = 50;
|
||
_bobotJarak = 20;
|
||
_bobotKriteria3 = 15;
|
||
_bobotKriteria4 = 15;
|
||
}
|
||
|
||
/// Auto-balance: when one bobot changes, redistribute the remaining
|
||
/// percentage proportionally among the other three (min 10% each).
|
||
void _updateBobot(int index, int newValue) {
|
||
setState(() {
|
||
List<int> bobots = [_bobotHarga, _bobotJarak, _bobotKriteria3, _bobotKriteria4];
|
||
int oldValue = bobots[index];
|
||
if (newValue == oldValue) return;
|
||
|
||
// Clamp newValue: max = 100 - 3*10 = 70
|
||
newValue = newValue.clamp(10, 70);
|
||
bobots[index] = newValue;
|
||
|
||
int remaining = 100 - newValue;
|
||
// Indices of the other 3 bobots
|
||
List<int> otherIdx = [0, 1, 2, 3].where((i) => i != index).toList();
|
||
int otherSum = otherIdx.fold(0, (sum, i) => sum + bobots[i]);
|
||
|
||
if (otherSum == 0) {
|
||
// Edge case: distribute equally
|
||
for (int i = 0; i < otherIdx.length; i++) {
|
||
bobots[otherIdx[i]] = (remaining / 3).round().clamp(10, 70);
|
||
}
|
||
} else {
|
||
// Proportional redistribution
|
||
int distributed = 0;
|
||
for (int i = 0; i < otherIdx.length - 1; i++) {
|
||
int idx = otherIdx[i];
|
||
int proportional = (bobots[idx] * remaining / otherSum).round();
|
||
proportional = proportional.clamp(10, 70);
|
||
bobots[idx] = proportional;
|
||
distributed += proportional;
|
||
}
|
||
// Last one gets the remainder to ensure total = 100
|
||
int lastIdx = otherIdx.last;
|
||
bobots[lastIdx] = (remaining - distributed).clamp(10, 70);
|
||
|
||
// Final adjustment pass to guarantee sum = 100
|
||
int total = bobots.reduce((a, b) => a + b);
|
||
if (total != 100) {
|
||
int diff = 100 - total;
|
||
// Adjust the largest adjustable bobot (not the one just set)
|
||
int adjustIdx = otherIdx.reduce((a, b) => bobots[a] >= bobots[b] ? a : b);
|
||
bobots[adjustIdx] = (bobots[adjustIdx] + diff).clamp(10, 70);
|
||
}
|
||
}
|
||
|
||
_bobotHarga = bobots[0];
|
||
_bobotJarak = bobots[1];
|
||
_bobotKriteria3 = bobots[2];
|
||
_bobotKriteria4 = bobots[3];
|
||
});
|
||
}
|
||
|
||
/// Get the maximum dropdown value for a given bobot index
|
||
int _getMaxBobot(int index) {
|
||
List<int> bobots = [_bobotHarga, _bobotJarak, _bobotKriteria3, _bobotKriteria4];
|
||
int othersMin = 0;
|
||
for (int i = 0; i < 4; i++) {
|
||
if (i != index) othersMin += 10; // minimum 10% each
|
||
}
|
||
return (100 - othersMin).clamp(10, 70); // max 70
|
||
}
|
||
|
||
List<int> _getBobotOptionsFor(int index) {
|
||
int max = _getMaxBobot(index);
|
||
return List.generate(((max - 10) ~/ 5) + 1, (i) => 10 + i * 5);
|
||
}
|
||
|
||
Future<void> _calculateSAW() async {
|
||
if (_totalBobot != 100) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text('Total bobot harus 100%! Saat ini: $_totalBobot%'),
|
||
backgroundColor: Colors.red,
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
|
||
setState(() {
|
||
_isLoading = true;
|
||
_errorMessage = null;
|
||
});
|
||
|
||
try {
|
||
final endpoint = widget.category == 'kontrakan'
|
||
? '/saw/calculate/kontrakan'
|
||
: '/saw/calculate/laundry';
|
||
|
||
final bodyParams = <String, dynamic>{};
|
||
bodyParams['bobot_harga'] = _bobotHarga;
|
||
bodyParams['bobot_jarak'] = _bobotJarak;
|
||
|
||
if (widget.category == 'kontrakan') {
|
||
bodyParams['bobot_jumlah_kamar'] = _bobotKriteria3;
|
||
bodyParams['bobot_fasilitas'] = _bobotKriteria4;
|
||
} else {
|
||
bodyParams['bobot_kecepatan'] = _bobotKriteria3;
|
||
bodyParams['bobot_layanan'] = _bobotKriteria4;
|
||
bodyParams['jenis_layanan'] = _selectedJenisLayanan;
|
||
}
|
||
|
||
if (widget.category == 'laundry' && _referensiJarak == 'user') {
|
||
if (_userLatitude != null && _userLongitude != null) {
|
||
bodyParams['user_lat'] = _userLatitude;
|
||
bodyParams['user_lng'] = _userLongitude;
|
||
}
|
||
}
|
||
|
||
final response = await http.post(
|
||
Uri.parse('${AppConfig.baseUrl}$endpoint'),
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'application/json',
|
||
},
|
||
body: json.encode(bodyParams),
|
||
);
|
||
|
||
if (response.statusCode == 200) {
|
||
final data = json.decode(response.body);
|
||
if (data['success'] == true) {
|
||
setState(() {
|
||
_recommendations = data['data']['hasil'] ?? [];
|
||
_hasCalculated = true;
|
||
});
|
||
} else {
|
||
setState(() {
|
||
_errorMessage = data['message'] ?? 'Terjadi kesalahan';
|
||
});
|
||
}
|
||
} else if (response.statusCode == 404) {
|
||
final data = json.decode(response.body);
|
||
setState(() {
|
||
_errorMessage = data['message'] ?? 'Tidak ada data ditemukan';
|
||
_recommendations = [];
|
||
_hasCalculated = true;
|
||
});
|
||
} else {
|
||
setState(() {
|
||
_errorMessage = 'Gagal memuat data (${response.statusCode})';
|
||
});
|
||
}
|
||
} catch (e) {
|
||
setState(() {
|
||
_errorMessage = 'Tidak dapat terhubung ke server';
|
||
});
|
||
} finally {
|
||
setState(() {
|
||
_isLoading = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _detectUserLocation() async {
|
||
setState(() => _isDetectingLocation = true);
|
||
try {
|
||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||
if (!serviceEnabled) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('Aktifkan GPS Anda terlebih dahulu'), backgroundColor: Colors.red),
|
||
);
|
||
}
|
||
setState(() { _referensiJarak = 'kampus'; _isDetectingLocation = false; });
|
||
return;
|
||
}
|
||
|
||
LocationPermission permission = await Geolocator.checkPermission();
|
||
if (permission == LocationPermission.denied) {
|
||
permission = await Geolocator.requestPermission();
|
||
if (permission == LocationPermission.denied) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('Izin lokasi ditolak'), backgroundColor: Colors.red),
|
||
);
|
||
}
|
||
setState(() { _referensiJarak = 'kampus'; _isDetectingLocation = false; });
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (permission == LocationPermission.deniedForever) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('Izin lokasi ditolak permanen. Ubah di pengaturan.'), backgroundColor: Colors.red),
|
||
);
|
||
}
|
||
setState(() { _referensiJarak = 'kampus'; _isDetectingLocation = false; });
|
||
return;
|
||
}
|
||
|
||
Position position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high);
|
||
setState(() {
|
||
_userLatitude = position.latitude;
|
||
_userLongitude = position.longitude;
|
||
_isDetectingLocation = false;
|
||
});
|
||
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('Lokasi berhasil dideteksi!'), backgroundColor: Colors.green, duration: Duration(seconds: 2)),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('Gagal mendeteksi lokasi: $e'), backgroundColor: Colors.red),
|
||
);
|
||
}
|
||
setState(() { _referensiJarak = 'kampus'; _isDetectingLocation = false; });
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final categoryTitle = widget.category == 'kontrakan'
|
||
? 'SPK Rekomendasi Kontrakan'
|
||
: 'SPK Rekomendasi Laundry';
|
||
|
||
return Scaffold(
|
||
backgroundColor: const Color(0xFFF5F5F5),
|
||
appBar: AppBar(
|
||
backgroundColor: _categoryColor,
|
||
foregroundColor: Colors.white,
|
||
elevation: 0,
|
||
title: Text(categoryTitle, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||
actions: [
|
||
if (_hasCalculated)
|
||
IconButton(
|
||
icon: const Icon(Icons.refresh),
|
||
tooltip: 'Hitung Ulang',
|
||
onPressed: () => setState(() { _hasCalculated = false; _recommendations = []; _errorMessage = null; }),
|
||
),
|
||
],
|
||
),
|
||
body: _hasCalculated ? _buildResultView() : _buildInputView(),
|
||
);
|
||
}
|
||
|
||
// ===================== INPUT VIEW =====================
|
||
Widget _buildInputView() {
|
||
return SingleChildScrollView(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_buildMethodInfoCard(),
|
||
const SizedBox(height: 16),
|
||
if (widget.category == 'laundry') ...[
|
||
_buildJenisLayananSection(),
|
||
const SizedBox(height: 16),
|
||
],
|
||
_buildBobotSection(),
|
||
const SizedBox(height: 16),
|
||
if (widget.category == 'laundry') ...[
|
||
_buildLocationSection(),
|
||
const SizedBox(height: 16),
|
||
],
|
||
_buildCalculateButton(),
|
||
const SizedBox(height: 24),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildMethodInfoCard() {
|
||
return Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
gradient: LinearGradient(colors: [_categoryColor, _categoryColor.withOpacity(0.8)]),
|
||
borderRadius: BorderRadius.circular(16),
|
||
boxShadow: [BoxShadow(color: _categoryColor.withOpacity(0.3), blurRadius: 12, offset: const Offset(0, 4))],
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Row(
|
||
children: [
|
||
Icon(Icons.analytics, color: Colors.white, size: 24),
|
||
SizedBox(width: 8),
|
||
Text('Metode SAW', style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Simple Additive Weighting (SAW) menghitung skor rekomendasi berdasarkan bobot kriteria yang Anda tentukan.',
|
||
style: TextStyle(color: Colors.white.withOpacity(0.9), fontSize: 13),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||
decoration: BoxDecoration(color: Colors.white.withOpacity(0.2), borderRadius: BorderRadius.circular(8)),
|
||
child: Text('Vi = Σ(Wj × Rij)',
|
||
style: TextStyle(color: Colors.white.withOpacity(0.95), fontSize: 14, fontWeight: FontWeight.w600, fontFamily: 'monospace')),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildJenisLayananSection() {
|
||
final jenisOptions = [
|
||
{'value': 'reguler', 'label': 'Reguler', 'icon': Icons.schedule, 'desc': 'Layanan standar dengan harga terjangkau'},
|
||
{'value': 'express', 'label': 'Express', 'icon': Icons.flash_on, 'desc': 'Layanan cepat dengan waktu lebih singkat'},
|
||
{'value': 'kilat', 'label': 'Kilat', 'icon': Icons.bolt, 'desc': 'Layanan tercepat, selesai dalam hitungan jam'},
|
||
];
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(16),
|
||
boxShadow: [BoxShadow(color: Colors.grey.withOpacity(0.1), blurRadius: 8, offset: const Offset(0, 2))],
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(children: [
|
||
Icon(Icons.local_laundry_service_outlined, color: _categoryColor, size: 20),
|
||
const SizedBox(width: 8),
|
||
Text('Jenis Layanan', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Colors.grey[800])),
|
||
]),
|
||
const SizedBox(height: 4),
|
||
Text('Pilih jenis layanan laundry yang ingin dibandingkan', style: TextStyle(fontSize: 12, color: Colors.grey[500])),
|
||
const SizedBox(height: 12),
|
||
...jenisOptions.map((opt) {
|
||
final isSelected = _selectedJenisLayanan == opt['value'];
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: InkWell(
|
||
onTap: () => setState(() => _selectedJenisLayanan = opt['value'] as String),
|
||
borderRadius: BorderRadius.circular(12),
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 14),
|
||
decoration: BoxDecoration(
|
||
color: isSelected ? _categoryColor.withOpacity(0.1) : Colors.grey[50],
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(
|
||
color: isSelected ? _categoryColor : Colors.grey[300]!,
|
||
width: isSelected ? 2 : 1,
|
||
),
|
||
),
|
||
child: Row(children: [
|
||
Icon(opt['icon'] as IconData, size: 22, color: isSelected ? _categoryColor : Colors.grey[500]),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(opt['label'] as String, style: TextStyle(fontSize: 14, fontWeight: isSelected ? FontWeight.bold : FontWeight.w500, color: isSelected ? _categoryColor : Colors.grey[800])),
|
||
const SizedBox(height: 2),
|
||
Text(opt['desc'] as String, style: TextStyle(fontSize: 11, color: Colors.grey[500])),
|
||
],
|
||
),
|
||
),
|
||
if (isSelected)
|
||
Icon(Icons.check_circle, color: _categoryColor, size: 20),
|
||
]),
|
||
),
|
||
),
|
||
);
|
||
}).toList(),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildBobotSection() {
|
||
final isValid = _totalBobot == 100;
|
||
return Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(16),
|
||
boxShadow: [BoxShadow(color: Colors.grey.withOpacity(0.1), blurRadius: 8, offset: const Offset(0, 2))],
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(children: [
|
||
Icon(Icons.tune, color: _categoryColor, size: 20),
|
||
const SizedBox(width: 8),
|
||
Text('Bobot Kriteria', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Colors.grey[800])),
|
||
const Spacer(),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: isValid ? Colors.green.withOpacity(0.1) : Colors.red.withOpacity(0.1),
|
||
borderRadius: BorderRadius.circular(20),
|
||
border: Border.all(color: isValid ? Colors.green.withOpacity(0.5) : Colors.red.withOpacity(0.5)),
|
||
),
|
||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||
Icon(isValid ? Icons.check_circle : Icons.warning, size: 14, color: isValid ? Colors.green : Colors.red),
|
||
const SizedBox(width: 4),
|
||
Text('Total: $_totalBobot%', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: isValid ? Colors.green[700] : Colors.red[700])),
|
||
]),
|
||
),
|
||
]),
|
||
const SizedBox(height: 4),
|
||
Text('Bobot otomatis disesuaikan agar total selalu 100%', style: TextStyle(fontSize: 11, color: Colors.grey[500])),
|
||
const SizedBox(height: 16),
|
||
_buildBobotDropdown(label: 'Harga', icon: Icons.payments_outlined, tipe: 'Cost', tipeDesc: 'Semakin murah semakin baik', value: _bobotHarga, options: _getBobotOptionsFor(0), onChanged: (val) => _updateBobot(0, val!)),
|
||
const SizedBox(height: 12),
|
||
_buildBobotDropdown(label: 'Jarak', icon: Icons.location_on_outlined, tipe: 'Cost', tipeDesc: 'Semakin dekat semakin baik', value: _bobotJarak, options: _getBobotOptionsFor(1), onChanged: (val) => _updateBobot(1, val!)),
|
||
const SizedBox(height: 12),
|
||
_buildBobotDropdown(
|
||
label: _kriteria3Label,
|
||
icon: widget.category == 'kontrakan' ? Icons.bed_outlined : Icons.speed_outlined,
|
||
tipe: 'Benefit',
|
||
tipeDesc: widget.category == 'kontrakan' ? 'Semakin banyak semakin baik' : 'Semakin cepat semakin baik',
|
||
value: _bobotKriteria3,
|
||
options: _getBobotOptionsFor(2),
|
||
onChanged: (val) => _updateBobot(2, val!),
|
||
),
|
||
const SizedBox(height: 12),
|
||
_buildBobotDropdown(
|
||
label: _kriteria4Label,
|
||
icon: widget.category == 'kontrakan' ? Icons.wifi_outlined : Icons.local_laundry_service_outlined,
|
||
tipe: 'Benefit',
|
||
tipeDesc: widget.category == 'kontrakan' ? 'Semakin lengkap semakin baik' : 'Semakin bervariasi semakin baik',
|
||
value: _bobotKriteria4,
|
||
options: _getBobotOptionsFor(3),
|
||
onChanged: (val) => _updateBobot(3, val!),
|
||
),
|
||
if (!isValid) ...[
|
||
const SizedBox(height: 12),
|
||
Container(
|
||
padding: const EdgeInsets.all(10),
|
||
decoration: BoxDecoration(color: Colors.red[50], borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.red[200]!)),
|
||
child: Row(children: [
|
||
const Icon(Icons.info_outline, color: Colors.red, size: 16),
|
||
const SizedBox(width: 8),
|
||
Expanded(child: Text(
|
||
_totalBobot > 100 ? 'Total bobot kelebihan ${_totalBobot - 100}%. Kurangi salah satu bobot.' : 'Total bobot kurang ${100 - _totalBobot}%. Tambah salah satu bobot.',
|
||
style: TextStyle(fontSize: 12, color: Colors.red[700]),
|
||
)),
|
||
]),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildBobotDropdown({required String label, required IconData icon, required String tipe, required String tipeDesc, required int value, required List<int> options, required ValueChanged<int?> onChanged}) {
|
||
final isCost = tipe.toLowerCase() == 'cost';
|
||
// Ensure current value is in options list
|
||
final safeValue = options.contains(value) ? value : options.reduce((a, b) => (a - value).abs() < (b - value).abs() ? a : b);
|
||
return Container(
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(color: Colors.grey[50], borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.grey[200]!)),
|
||
child: Row(children: [
|
||
Container(
|
||
padding: const EdgeInsets.all(8),
|
||
decoration: BoxDecoration(color: _categoryColor.withOpacity(0.1), borderRadius: BorderRadius.circular(8)),
|
||
child: Icon(icon, size: 20, color: _categoryColor),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Text(label, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.grey[800])),
|
||
const SizedBox(height: 2),
|
||
Row(children: [
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||
decoration: BoxDecoration(color: isCost ? Colors.orange.withOpacity(0.15) : Colors.green.withOpacity(0.15), borderRadius: BorderRadius.circular(4)),
|
||
child: Text(tipe, style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: isCost ? Colors.orange[800] : Colors.green[800])),
|
||
),
|
||
const SizedBox(width: 6),
|
||
Expanded(child: Text(tipeDesc, style: TextStyle(fontSize: 10, color: Colors.grey[500]), overflow: TextOverflow.ellipsis)),
|
||
]),
|
||
])),
|
||
const SizedBox(width: 8),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8), border: Border.all(color: _categoryColor.withOpacity(0.3))),
|
||
child: DropdownButtonHideUnderline(child: DropdownButton<int>(
|
||
value: safeValue, isDense: true,
|
||
icon: Icon(Icons.arrow_drop_down, color: _categoryColor),
|
||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: _categoryColor),
|
||
items: options.map((int val) => DropdownMenuItem<int>(value: val, child: Text('$val%'))).toList(),
|
||
onChanged: onChanged,
|
||
)),
|
||
),
|
||
]),
|
||
);
|
||
}
|
||
|
||
Widget _buildLocationSection() {
|
||
return Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(16),
|
||
boxShadow: [BoxShadow(color: Colors.grey.withOpacity(0.1), blurRadius: 8, offset: const Offset(0, 2))],
|
||
),
|
||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Row(children: [
|
||
Icon(Icons.my_location, color: _categoryColor, size: 20),
|
||
const SizedBox(width: 8),
|
||
Text('Referensi Jarak', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Colors.grey[800])),
|
||
]),
|
||
const SizedBox(height: 4),
|
||
Text('Tentukan titik referensi untuk perhitungan jarak', style: TextStyle(fontSize: 12, color: Colors.grey[500])),
|
||
const SizedBox(height: 12),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||
decoration: BoxDecoration(color: Colors.grey[50], borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.grey[200]!)),
|
||
child: DropdownButtonHideUnderline(child: DropdownButton<String>(
|
||
value: _referensiJarak, isExpanded: true,
|
||
icon: Icon(Icons.arrow_drop_down, color: _categoryColor),
|
||
items: [
|
||
DropdownMenuItem(value: 'kampus', child: Row(children: [
|
||
Icon(Icons.school, size: 18, color: _categoryColor), const SizedBox(width: 10), const Text('Dari Kampus Polije'),
|
||
])),
|
||
DropdownMenuItem(value: 'user', child: Row(children: [
|
||
Icon(Icons.location_on, size: 18, color: Colors.green[700]), const SizedBox(width: 10), const Text('Dari Lokasi Saya'),
|
||
])),
|
||
],
|
||
onChanged: (val) {
|
||
setState(() { _referensiJarak = val!; });
|
||
if (val == 'user' && _userLatitude == null) _detectUserLocation();
|
||
},
|
||
)),
|
||
),
|
||
if (_referensiJarak == 'user') ...[
|
||
const SizedBox(height: 10),
|
||
if (_isDetectingLocation)
|
||
Container(
|
||
padding: const EdgeInsets.all(10),
|
||
decoration: BoxDecoration(color: Colors.blue[50], borderRadius: BorderRadius.circular(8)),
|
||
child: const Row(children: [SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)), SizedBox(width: 10), Text('Mendeteksi lokasi...', style: TextStyle(fontSize: 12))]),
|
||
)
|
||
else if (_userLatitude != null && _userLongitude != null)
|
||
Container(
|
||
padding: const EdgeInsets.all(10),
|
||
decoration: BoxDecoration(color: Colors.green[50], borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.green[200]!)),
|
||
child: Row(children: [
|
||
const Icon(Icons.check_circle, size: 16, color: Colors.green),
|
||
const SizedBox(width: 8),
|
||
Expanded(child: Text('Lokasi: ${_userLatitude!.toStringAsFixed(6)}, ${_userLongitude!.toStringAsFixed(6)}', style: TextStyle(fontSize: 11, color: Colors.green[700]))),
|
||
InkWell(onTap: _detectUserLocation, child: Icon(Icons.refresh, size: 16, color: Colors.green[700])),
|
||
]),
|
||
)
|
||
else
|
||
Container(
|
||
padding: const EdgeInsets.all(10),
|
||
decoration: BoxDecoration(color: Colors.orange[50], borderRadius: BorderRadius.circular(8)),
|
||
child: Row(children: [
|
||
const Icon(Icons.warning, size: 16, color: Colors.orange),
|
||
const SizedBox(width: 8),
|
||
const Expanded(child: Text('Lokasi belum terdeteksi', style: TextStyle(fontSize: 12))),
|
||
TextButton(onPressed: _detectUserLocation, child: const Text('Deteksi', style: TextStyle(fontSize: 12))),
|
||
]),
|
||
),
|
||
],
|
||
]),
|
||
);
|
||
}
|
||
|
||
Widget _buildCalculateButton() {
|
||
final isValid = _totalBobot == 100;
|
||
return SizedBox(
|
||
width: double.infinity,
|
||
child: ElevatedButton(
|
||
onPressed: isValid && !_isLoading ? _calculateSAW : null,
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: _categoryColor,
|
||
disabledBackgroundColor: Colors.grey[300],
|
||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||
elevation: isValid ? 4 : 0,
|
||
shadowColor: _categoryColor.withOpacity(0.4),
|
||
),
|
||
child: _isLoading
|
||
? const Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||
SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)),
|
||
SizedBox(width: 12),
|
||
Text('Menghitung SAW...', style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
|
||
])
|
||
: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||
Icon(Icons.calculate, color: Colors.white, size: 22),
|
||
SizedBox(width: 10),
|
||
Text('Hitung Rekomendasi', style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ===================== RESULT VIEW =====================
|
||
Widget _buildResultView() {
|
||
return Column(children: [
|
||
_buildBobotSummary(),
|
||
if (widget.category == 'laundry' && _referensiJarak == 'user' && _userLatitude != null)
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||
color: Colors.green[50],
|
||
child: Row(children: [
|
||
Icon(Icons.location_on, size: 16, color: Colors.green[700]),
|
||
const SizedBox(width: 8),
|
||
Text('Jarak dihitung dari lokasi Anda', style: TextStyle(fontSize: 12, color: Colors.green[700])),
|
||
]),
|
||
),
|
||
Expanded(child: _buildResultContent()),
|
||
]);
|
||
}
|
||
|
||
Widget _buildBobotSummary() {
|
||
return Container(
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
gradient: LinearGradient(colors: [_categoryColor.withOpacity(0.05), Colors.white]),
|
||
border: Border(bottom: BorderSide(color: Colors.grey[200]!)),
|
||
),
|
||
child: Column(children: [
|
||
Row(children: [
|
||
Icon(Icons.tune, size: 14, color: _categoryColor),
|
||
const SizedBox(width: 6),
|
||
Text('Bobot yang digunakan:', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.grey[700])),
|
||
if (widget.category == 'laundry') ...[
|
||
const SizedBox(width: 8),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||
decoration: BoxDecoration(color: _categoryColor.withOpacity(0.1), borderRadius: BorderRadius.circular(10)),
|
||
child: Text(_getJenisLayananLabel(_selectedJenisLayanan), style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: _categoryColor)),
|
||
),
|
||
],
|
||
const Spacer(),
|
||
InkWell(
|
||
onTap: () => setState(() { _hasCalculated = false; _recommendations = []; _errorMessage = null; }),
|
||
child: Row(children: [
|
||
Icon(Icons.edit, size: 14, color: _categoryColor),
|
||
const SizedBox(width: 4),
|
||
Text('Ubah', style: TextStyle(fontSize: 12, color: _categoryColor, fontWeight: FontWeight.w600)),
|
||
]),
|
||
),
|
||
]),
|
||
const SizedBox(height: 8),
|
||
Row(children: [
|
||
_buildBobotMiniChip('Harga', _bobotHarga, 'Cost'),
|
||
const SizedBox(width: 6),
|
||
_buildBobotMiniChip('Jarak', _bobotJarak, 'Cost'),
|
||
const SizedBox(width: 6),
|
||
_buildBobotMiniChip(_kriteria3Label, _bobotKriteria3, 'Benefit'),
|
||
const SizedBox(width: 6),
|
||
_buildBobotMiniChip(_kriteria4Label, _bobotKriteria4, 'Benefit'),
|
||
]),
|
||
]),
|
||
);
|
||
}
|
||
|
||
Widget _buildBobotMiniChip(String label, int value, String tipe) {
|
||
final isCost = tipe == 'Cost';
|
||
return Expanded(child: Container(
|
||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4),
|
||
decoration: BoxDecoration(
|
||
color: isCost ? Colors.orange.withOpacity(0.08) : Colors.green.withOpacity(0.08),
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: isCost ? Colors.orange.withOpacity(0.2) : Colors.green.withOpacity(0.2)),
|
||
),
|
||
child: Column(children: [
|
||
Text(label, style: TextStyle(fontSize: 9, color: Colors.grey[600]), textAlign: TextAlign.center, overflow: TextOverflow.ellipsis),
|
||
const SizedBox(height: 2),
|
||
Text('$value%', style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: isCost ? Colors.orange[700] : Colors.green[700])),
|
||
]),
|
||
));
|
||
}
|
||
|
||
String _getJenisLayananLabel(String key) {
|
||
switch (key) {
|
||
case 'reguler': return 'Reguler';
|
||
case 'express': return 'Express';
|
||
case 'kilat': return 'Kilat';
|
||
default: return key;
|
||
}
|
||
}
|
||
|
||
Widget _buildResultContent() {
|
||
if (_isLoading) {
|
||
return const Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||
CircularProgressIndicator(), SizedBox(height: 16), Text('Menghitung rekomendasi SAW...'),
|
||
]));
|
||
}
|
||
|
||
if (_errorMessage != null && _recommendations.isEmpty) {
|
||
return Center(child: Padding(
|
||
padding: const EdgeInsets.all(32),
|
||
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
|
||
const SizedBox(height: 16),
|
||
Text(_errorMessage!, textAlign: TextAlign.center, style: TextStyle(color: Colors.grey[600], fontSize: 14)),
|
||
const SizedBox(height: 24),
|
||
ElevatedButton.icon(
|
||
onPressed: () => setState(() { _hasCalculated = false; _errorMessage = null; }),
|
||
icon: const Icon(Icons.arrow_back),
|
||
label: const Text('Ubah Kriteria'),
|
||
style: ElevatedButton.styleFrom(backgroundColor: _categoryColor, foregroundColor: Colors.white),
|
||
),
|
||
]),
|
||
));
|
||
}
|
||
|
||
if (_recommendations.isEmpty) {
|
||
return Center(child: Padding(
|
||
padding: const EdgeInsets.all(32),
|
||
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||
Icon(widget.category == 'kontrakan' ? Icons.home_outlined : Icons.local_laundry_service_outlined, size: 64, color: Colors.grey[400]),
|
||
const SizedBox(height: 16),
|
||
Text('Tidak ada hasil', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.grey[700])),
|
||
]),
|
||
));
|
||
}
|
||
|
||
return RefreshIndicator(
|
||
onRefresh: _calculateSAW,
|
||
child: ListView.builder(
|
||
padding: const EdgeInsets.all(16),
|
||
itemCount: _recommendations.length,
|
||
itemBuilder: (context, index) {
|
||
final item = _recommendations[index];
|
||
return _buildResultCard(item, index);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildResultCard(Map<String, dynamic> item, int index) {
|
||
final ranking = item['ranking'] ?? (index + 1);
|
||
final skor = (item['skor'] ?? item['skor_akhir'] ?? 0).toDouble();
|
||
final normalisasi = item['normalisasi'] != null ? Map<String, dynamic>.from(item['normalisasi']) : <String, dynamic>{};
|
||
final nilai = item['nilai'] != null ? Map<String, dynamic>.from(item['nilai']) : <String, dynamic>{};
|
||
final itemData = item['data'] ?? item;
|
||
|
||
return Container(
|
||
margin: const EdgeInsets.only(bottom: 16),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(16),
|
||
boxShadow: [BoxShadow(
|
||
color: ranking <= 3 ? _getRankingColor(ranking).withOpacity(0.2) : Colors.grey.withOpacity(0.1),
|
||
blurRadius: 8, offset: const Offset(0, 2),
|
||
)],
|
||
border: ranking <= 3 ? Border.all(color: _getRankingColor(ranking).withOpacity(0.3), width: 1.5) : null,
|
||
),
|
||
child: Column(children: [
|
||
// Ranking Header
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||
decoration: BoxDecoration(
|
||
gradient: LinearGradient(colors: [_getRankingColor(ranking).withOpacity(0.1), Colors.transparent]),
|
||
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
||
),
|
||
child: Row(children: [
|
||
Container(
|
||
width: 36, height: 36,
|
||
decoration: BoxDecoration(
|
||
color: _getRankingColor(ranking), shape: BoxShape.circle,
|
||
boxShadow: [BoxShadow(color: _getRankingColor(ranking).withOpacity(0.4), blurRadius: 6, offset: const Offset(0, 2))],
|
||
),
|
||
child: Center(child: ranking <= 3
|
||
? Icon(_getRankingIcon(ranking), color: Colors.white, size: 18)
|
||
: Text('#$ranking', style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold))),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Text(item['nama'] ?? 'N/A', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.grey[800])),
|
||
Text('Peringkat $ranking', style: TextStyle(fontSize: 11, color: Colors.grey[500])),
|
||
])),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||
decoration: BoxDecoration(gradient: LinearGradient(colors: [_categoryColor, _categoryColor.withOpacity(0.8)]), borderRadius: BorderRadius.circular(20)),
|
||
child: Text('Skor: ${skor.toStringAsFixed(4)}', style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)),
|
||
),
|
||
]),
|
||
),
|
||
|
||
// SAW Detail
|
||
if (normalisasi.isNotEmpty)
|
||
Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: _buildNormalizationDetail(normalisasi, nilai)),
|
||
|
||
// Score Progress Bar
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
|
||
Text('Skor Akhir (Vi)', style: TextStyle(fontSize: 11, color: Colors.grey[600], fontWeight: FontWeight.w500)),
|
||
Text('${(skor * 100).toStringAsFixed(2)}%', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: _categoryColor)),
|
||
]),
|
||
const SizedBox(height: 4),
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(4),
|
||
child: LinearProgressIndicator(value: skor.clamp(0.0, 1.0), backgroundColor: Colors.grey[200], valueColor: AlwaysStoppedAnimation<Color>(_categoryColor), minHeight: 6),
|
||
),
|
||
]),
|
||
),
|
||
|
||
const SizedBox(height: 8),
|
||
const Divider(height: 1),
|
||
|
||
// Item Card
|
||
Padding(
|
||
padding: const EdgeInsets.all(8),
|
||
child: widget.category == 'kontrakan'
|
||
? KontrakanCard(kontrakan: Kontrakan.fromJson(itemData))
|
||
: LaundryCard(laundry: Laundry.fromJson(itemData)),
|
||
),
|
||
]),
|
||
);
|
||
}
|
||
|
||
Widget _buildNormalizationDetail(Map<String, dynamic> normalisasi, Map<String, dynamic> nilai) {
|
||
return ExpansionTile(
|
||
title: Row(children: [
|
||
Icon(Icons.table_chart, size: 16, color: _categoryColor),
|
||
const SizedBox(width: 8),
|
||
Text('Detail Perhitungan SAW', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.grey[700])),
|
||
]),
|
||
tilePadding: EdgeInsets.zero,
|
||
childrenPadding: EdgeInsets.zero,
|
||
initiallyExpanded: false,
|
||
children: [
|
||
Container(
|
||
decoration: BoxDecoration(color: Colors.grey[50], borderRadius: BorderRadius.circular(8)),
|
||
child: Table(
|
||
border: TableBorder.all(color: Colors.grey[300]!, width: 0.5, borderRadius: BorderRadius.circular(8)),
|
||
columnWidths: const { 0: FlexColumnWidth(2.5), 1: FlexColumnWidth(1.5), 2: FlexColumnWidth(1.5), 3: FlexColumnWidth(1.5) },
|
||
children: [
|
||
TableRow(
|
||
decoration: BoxDecoration(color: _categoryColor.withOpacity(0.1), borderRadius: const BorderRadius.vertical(top: Radius.circular(8))),
|
||
children: [_tableHeader('Kriteria'), _tableHeader('Nilai'), _tableHeader('Normalisasi'), _tableHeader('Bobot')],
|
||
),
|
||
...normalisasi.entries.map((entry) {
|
||
final key = entry.key;
|
||
final norm = (entry.value is num) ? entry.value.toDouble() : 0.0;
|
||
final nilaiVal = nilai[key] ?? 0;
|
||
final bobot = _getBobotForKey(key);
|
||
return TableRow(children: [
|
||
_tableCell(_getKriteriaDisplayName(key)),
|
||
_tableCell(nilaiVal is double ? nilaiVal.toStringAsFixed(2) : nilaiVal.toString()),
|
||
_tableCell(norm is double ? norm.toStringAsFixed(4) : norm.toString()),
|
||
_tableCell('${(bobot * 100).toInt()}%'),
|
||
]);
|
||
}),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _tableHeader(String text) => Padding(padding: const EdgeInsets.all(6), child: Text(text, style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: _categoryColor), textAlign: TextAlign.center));
|
||
Widget _tableCell(String text) => Padding(padding: const EdgeInsets.all(6), child: Text(text, style: TextStyle(fontSize: 10, color: Colors.grey[700]), textAlign: TextAlign.center));
|
||
|
||
String _getKriteriaDisplayName(String key) {
|
||
switch (key) {
|
||
case 'harga': return 'Harga';
|
||
case 'jarak': return 'Jarak';
|
||
case 'jumlah_kamar': return 'Jml Kamar';
|
||
case 'fasilitas_count': return 'Fasilitas';
|
||
case 'kecepatan_layanan': return 'Kecepatan';
|
||
case 'layanan': return 'Layanan';
|
||
default: return key;
|
||
}
|
||
}
|
||
|
||
double _getBobotForKey(String key) {
|
||
switch (key) {
|
||
case 'harga': return _bobotHarga / 100;
|
||
case 'jarak': return _bobotJarak / 100;
|
||
case 'jumlah_kamar': case 'kecepatan_layanan': return _bobotKriteria3 / 100;
|
||
case 'fasilitas_count': case 'layanan': return _bobotKriteria4 / 100;
|
||
default: return 0;
|
||
}
|
||
}
|
||
|
||
Color _getRankingColor(int rank) {
|
||
switch (rank) {
|
||
case 1: return const Color(0xFFFFD700);
|
||
case 2: return const Color(0xFFC0C0C0);
|
||
case 3: return const Color(0xFFCD7F32);
|
||
default: return _categoryColor;
|
||
}
|
||
}
|
||
|
||
IconData _getRankingIcon(int rank) {
|
||
switch (rank) {
|
||
case 1: return Icons.emoji_events;
|
||
case 2: return Icons.workspace_premium;
|
||
case 3: return Icons.military_tech;
|
||
default: return Icons.star;
|
||
}
|
||
}
|
||
}
|