MIF_E31230069/spk_mobile/lib/screens/recommendation_screen.dart

1897 lines
60 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 '../models/user.dart';
import '../services/auth_service.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
// User info
final _authService = AuthService();
User? _currentUser;
// 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 untuk referensi jarak (deteksi lokasi user)
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';
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;
// Load user info
_loadUser();
// Auto-detect lokasi untuk laundry
if (widget.category == 'laundry') {
WidgetsBinding.instance.addPostFrameCallback((_) {
_detectUserLocation();
});
}
}
Future<void> _loadUser() async {
try {
// Use cached user first (no API call needed)
if (_authService.currentUser != null) {
if (!mounted) return;
setState(() => _currentUser = _authService.currentUser);
return;
}
// Fallback: load from SharedPreferences via API
await _authService.loadToken();
if (!mounted) return;
setState(() => _currentUser = _authService.currentUser);
} catch (e) {
debugPrint('Load user error: $e');
}
}
/// 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: force total == 100 by adjusting the largest other bobot
// without snapping to 5 (exact correction takes priority over pretty numbers)
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,
);
bobots[adjustIdx] = (bobots[adjustIdx] + diff).clamp(5, 75);
}
_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) {
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;
}
// Validate location for laundry
if (widget.category == 'laundry' && _userLatitude == null) {
// Auto-detect location first, then re-calculate
await _detectUserLocation();
if (!mounted) return;
// If still no location after detection attempt, warn and stop
if (_userLatitude == null || _userLongitude == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Lokasi belum terdeteksi. Aktifkan GPS dan coba lagi.',
),
backgroundColor: Colors.orange,
),
);
return;
}
}
setState(() {
_isLoading = true;
_errorMessage = null;
_noData = false;
});
final endpoint = widget.category == 'kontrakan'
? '/saw/calculate/kontrakan'
: '/saw/calculate/laundry';
try {
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' &&
_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 (!mounted) return;
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) {
debugPrint('SAW API Error: $e');
debugPrint('URL: ${AppConfig.baseUrl}$endpoint');
if (!mounted) return;
setState(() {
_errorMessage =
'Tidak dapat terhubung ke server (${AppConfig.baseUrl}). Periksa koneksi internet Anda dan coba lagi.\n\nDetail: $e';
_hasCalculated = true;
});
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
Future<void> _detectUserLocation() async {
// Guard: prevent re-entrant / duplicate calls
if (_isDetectingLocation) return;
setState(() => _isDetectingLocation = true);
try {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Aktifkan GPS Anda terlebih dahulu'),
backgroundColor: Colors.red,
),
);
setState(() => _isDetectingLocation = false);
return;
}
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Izin lokasi ditolak'),
backgroundColor: Colors.red,
),
);
setState(() => _isDetectingLocation = false);
return;
}
}
if (permission == LocationPermission.deniedForever) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Izin lokasi ditolak permanen. Ubah di pengaturan.',
),
backgroundColor: Colors.red,
),
);
setState(() => _isDetectingLocation = false);
return;
}
Position position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
).timeout(
const Duration(seconds: 15),
onTimeout: () => throw TimeoutException('Deteksi lokasi timeout'),
);
if (!mounted) return;
setState(() {
_userLatitude = position.latitude;
_userLongitude = position.longitude;
_isDetectingLocation = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Lokasi berhasil dideteksi!'),
backgroundColor: Colors.green,
duration: Duration(seconds: 2),
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Gagal mendeteksi lokasi: ${e.toString().replaceAll('Exception: ', '')}'),
backgroundColor: Colors.red,
),
);
setState(() => _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: [
_buildUserInfoCard(),
const SizedBox(height: 16),
_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 _buildUserInfoCard() {
final name = _currentUser?.name ?? '';
final email = _currentUser?.email ?? '';
final initials = name.isEmpty
? 'U'
: name.trim().split(' ').length >= 2
? '${name.trim().split(' ')[0][0]}${name.trim().split(' ')[1][0]}'.toUpperCase()
: name.trim()[0].toUpperCase();
final hour = DateTime.now().hour;
final greeting = hour < 11
? 'Selamat Pagi'
: hour < 15
? 'Selamat Siang'
: hour < 18
? 'Selamat Sore'
: 'Selamat Malam';
final categoryLabel = widget.category == 'kontrakan' ? 'kontrakan' : 'laundry';
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: Row(
children: [
// Avatar
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [_categoryColor, _categoryColor.withValues(alpha: 0.7)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: _categoryColor.withValues(alpha: 0.3),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
child: Center(
child: Text(
initials,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w800,
color: Colors.white,
),
),
),
),
const SizedBox(width: 14),
// Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$greeting,',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade500,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
name.isEmpty ? 'Pengguna' : name,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w800,
color: Color(0xFF1A1A2E),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (email.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
email,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade400,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
// Category badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: _categoryColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
widget.category == 'kontrakan'
? Icons.home_work_rounded
: Icons.local_laundry_service_rounded,
size: 14,
color: _categoryColor,
),
const SizedBox(width: 5),
Text(
categoryLabel,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: _categoryColor,
),
),
],
),
),
],
),
);
}
Widget _buildMethodInfoCard() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [_categoryColor, _categoryColor.withValues(alpha: 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),
Expanded(
child: Text(
'Lokasi Saya',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Colors.grey[800],
),
),
),
if (_userLatitude != null && !_isDetectingLocation)
InkWell(
onTap: _detectUserLocation,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
Icons.refresh,
size: 18,
color: _categoryColor,
),
),
),
],
),
const SizedBox(height: 4),
Text(
'Jarak dihitung dari lokasi Anda saat ini',
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
),
const SizedBox(height: 12),
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 terdeteksi: ${_userLatitude!.toStringAsFixed(6)}, ${_userLongitude!.toStringAsFixed(6)}',
style: TextStyle(
fontSize: 11,
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' &&
_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;
}
}
}