MIF_E31230069/spk_mobile/lib/screens/recommendation_screen.dart

1765 lines
56 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:async';
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;
bool _noData =
false; // true = memang tidak ada data, false = filter terlalu ketat
// 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),
/// always snapping to multiples of 5 so displayed values match actual sum.
void _updateBobot(int index, int newValue) {
setState(() {
List<int> bobots = [
_bobotHarga,
_bobotJarak,
_bobotKriteria3,
_bobotKriteria4,
];
if (newValue == bobots[index]) return;
// Clamp newValue to valid range and snap to multiple of 5
newValue = ((newValue.clamp(10, 70)) ~/ 5) * 5;
bobots[index] = newValue;
int remaining = 100 - newValue;
List<int> otherIdx = [0, 1, 2, 3].where((i) => i != index).toList();
int otherSum = otherIdx.fold(0, (sum, i) => sum + bobots[i]);
List<int> newOther = List.filled(otherIdx.length, 0);
if (otherSum == 0) {
// Edge case: distribute equally in steps of 5
int share = ((remaining / otherIdx.length / 5).round() * 5).clamp(
10,
70,
);
for (int i = 0; i < otherIdx.length; i++) {
newOther[i] = share;
}
} else {
// Proportional redistribution snapped to nearest multiple of 5
for (int i = 0; i < otherIdx.length - 1; i++) {
double proportional = bobots[otherIdx[i]] * remaining / otherSum;
newOther[i] = ((proportional / 5).round() * 5).clamp(10, 70);
}
// Last one gets exact remainder to guarantee total = 100
int distributed = newOther
.take(newOther.length - 1)
.fold(0, (a, b) => a + b);
int lastVal = remaining - distributed;
newOther[newOther.length - 1] = lastVal.clamp(10, 70);
}
// Apply computed values
for (int i = 0; i < otherIdx.length; i++) {
bobots[otherIdx[i]] = newOther[i];
}
// Final safety check: if sum != 100 due to clamping, adjust the largest
int total = bobots.reduce((a, b) => a + b);
if (total != 100) {
int diff = 100 - total;
int adjustIdx = otherIdx.reduce(
(a, b) => bobots[a] >= bobots[b] ? a : b,
);
int adjusted = (bobots[adjustIdx] + diff).clamp(10, 70);
// Snap to nearest multiple of 5
adjusted = ((adjusted / 5).round() * 5).clamp(10, 70);
bobots[adjustIdx] = adjusted;
}
_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;
_noData = false;
});
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),
)
.timeout(const Duration(seconds: 15));
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';
_hasCalculated = true;
});
}
} else if (response.statusCode == 404) {
final data = json.decode(response.body);
setState(() {
_errorMessage = data['message'] ?? 'Tidak ada data ditemukan';
_noData = data['no_data'] == true;
_recommendations = [];
_hasCalculated = true;
});
} else {
setState(() {
_errorMessage = 'Gagal memuat data (${response.statusCode})';
_hasCalculated = true;
});
}
} catch (e) {
setState(() {
_errorMessage =
'Tidak dapat terhubung ke server. Pastikan server Laravel aktif dan IP di app_config.dart benar.';
_hasCalculated = true;
});
} 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;
_noData = false;
}),
),
],
),
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;
_noData = false;
}),
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(
_noData
? (widget.category == 'kontrakan'
? Icons.home_outlined
: Icons.local_laundry_service_outlined)
: Icons.search_off,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
_noData
? (widget.category == 'kontrakan'
? 'Belum Ada Kontrakan Tersedia'
: 'Belum Ada Laundry Tersedia')
: 'Tidak Ada Hasil',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.grey[700],
),
),
const SizedBox(height: 8),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey[500], fontSize: 13),
),
const SizedBox(height: 24),
if (!_noData)
ElevatedButton.icon(
onPressed: () => setState(() {
_hasCalculated = false;
_errorMessage = null;
_noData = false;
}),
icon: const Icon(Icons.tune),
label: const Text('Ubah Bobot Kriteria'),
style: ElevatedButton.styleFrom(
backgroundColor: _categoryColor,
foregroundColor: Colors.white,
),
)
else
OutlinedButton.icon(
onPressed: () => setState(() {
_hasCalculated = false;
_errorMessage = null;
_noData = false;
}),
icon: const Icon(Icons.refresh),
label: const Text('Coba Lagi'),
style: OutlinedButton.styleFrom(
foregroundColor: _categoryColor,
side: BorderSide(color: _categoryColor),
),
),
],
),
),
);
}
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;
}
}
}