MIF_E31230069/spk_mobile/lib/screens/recommendation_screen.dart

3283 lines
109 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 'dart:io';
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 '../services/server_discovery_service.dart';
import '../services/location_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;
// ============================================================================
// QUESTIONNAIRE STATE (Smart Questions untuk mahasiswa)
// ============================================================================
bool _showQuestionnaire = false; // Show input view by default
bool _loadingRange = true; // Loading range data
// Range data dari database
int _hargaMin = 0;
int _hargaMax = 10000000;
List<String> _availableFasilitas = [];
// Laundry specific data
List<String> _availableJenisLayanan = [];
// rating removed by request
// Q1: Budget
double? _budgetSelected; // null = belum pilih
// Q2: Required Facilities (checkbox)
Set<String> _selectedFacilities =
{}; // e.g. {'WiFi', 'AC', 'Dapur', 'Parkir', 'Air Saniter'}
// Q3: Distance preference
String _distancePreference =
'sedang'; // 'dekat', 'sedang', 'jauh_ok', 'tidak_peduli'
// Q4: Room preference
String _roomPreference =
'tidak_peduli'; // '1_kamar', '2_3_kamar', '4_plus_kamar', 'tidak_peduli'
// Bobot values (percentage, total must = 100) - CALCULATED FROM QUESTIONNAIRE
// 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 = 'harian';
// Laundry quick preset and antar/jemput
String _selectedPreset = 'Normal';
bool _antarJemput = false;
// 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 => 'Fasilitas';
int get _totalBobot =>
_bobotHarga + _bobotJarak + _bobotKriteria3 + _bobotKriteria4;
Color get _categoryColor => widget.category == 'kontrakan'
? const Color(0xFF1565C0)
: const Color(0xFF00897B);
@override
void initState() {
super.initState();
// Default bobot: kontrakan seimbang 25% tiap kriteria
if (widget.category == 'kontrakan') {
_bobotHarga = 25;
_bobotJarak = 25;
_bobotKriteria3 = 25;
_bobotKriteria4 = 25;
} else {
_bobotHarga = 50;
_bobotJarak = 20;
_bobotKriteria3 = 15;
_bobotKriteria4 = 15;
}
// Load user info
_loadUser();
// Load range data untuk questionnaire (kontrakan & laundry)
if (widget.category == 'kontrakan' || widget.category == 'laundry') {
_loadRangeData();
// Safety fallback: jika API lambat atau menggantung, paksa fallback setelah 5s
Future.delayed(const Duration(seconds: 5), () {
if (!mounted) return;
if (_loadingRange) {
setState(() {
_loadingRange = false;
if (_availableFasilitas.isEmpty) {
_availableFasilitas = [
'WiFi',
'Parkir',
'Dapur Bersama',
'Lemari',
'Air Panas',
];
}
if (_hargaMin == _hargaMax) {
_hargaMax = _hargaMin + 500000;
}
});
}
});
}
// Auto-detect lokasi untuk laundry
if (widget.category == 'laundry') {
WidgetsBinding.instance.addPostFrameCallback((_) {
_detectUserLocation();
});
}
}
Widget _buildBobotStackedBar() {
final items = _getBobotItems();
final values = items.map((e) => e['value'] as int).toList();
final colors = [
_categoryColor.withOpacity(1.0),
_categoryColor.withOpacity(0.78),
_categoryColor.withOpacity(0.56),
_categoryColor.withOpacity(0.34),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LayoutBuilder(
builder: (ctx, constraints) {
final fullWidth = constraints.maxWidth;
final widths = <double>[];
double used = 0;
for (int i = 0; i < values.length; i++) {
if (i == values.length - 1) {
widths.add((fullWidth - used).clamp(0, fullWidth));
} else {
final w = fullWidth * (values[i] / 100.0);
widths.add(w);
used += w;
}
}
return SizedBox(
height: 14,
child: Row(
children: List.generate(values.length, (i) {
final isFirst = i == 0;
return InkWell(
onTap: () => _showBobotPicker(i),
child: Container(
width: widths[i],
decoration: BoxDecoration(
color: colors[i],
border: isFirst
? null
: const Border(
left: BorderSide(color: Colors.white, width: 2),
),
),
),
);
}),
),
);
},
),
),
const SizedBox(height: 8),
Text(
'Ringkasan bobot (tap segmen untuk fokus slider)',
style: TextStyle(fontSize: 11, color: Colors.grey[500]),
),
],
);
}
Widget _buildBobotRows() {
final items = _getBobotItems();
return Column(
children: List.generate(items.length, (i) {
final item = items[i];
return Padding(
padding: EdgeInsets.only(bottom: i == items.length - 1 ? 0 : 10),
child: _buildBobotRow(
index: i,
label: item['label'] as String,
icon: item['icon'] as IconData,
tipe: item['tipe'] as String,
tipeDesc: item['tipeDesc'] as String,
value: item['value'] as int,
),
);
}),
);
}
Widget _buildBobotSlider(int index, int value, {VoidCallback? onChanged}) {
final maxBobot = _getMaxBobot(index);
final clampedValue = value.clamp(10, maxBobot);
final divisions = maxBobot > 10 ? ((maxBobot - 10) ~/ 5) : null;
return Column(
children: [
SliderTheme(
data: SliderTheme.of(context).copyWith(
activeTrackColor: _categoryColor,
inactiveTrackColor: _categoryColor.withOpacity(0.12),
thumbColor: _categoryColor,
overlayColor: _categoryColor.withOpacity(0.15),
trackHeight: 6,
tickMarkShape: SliderTickMarkShape.noTickMark,
),
child: Slider(
value: clampedValue.toDouble(),
min: 10,
max: maxBobot.toDouble(),
divisions: divisions,
label: '$clampedValue%',
onChanged: (v) {
_updateBobot(index, v.round());
onChanged?.call();
},
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('10%', style: TextStyle(fontSize: 10, color: Colors.grey[500])),
Text(
'$maxBobot%',
style: TextStyle(fontSize: 10, color: Colors.grey[500]),
),
],
),
),
],
);
}
Widget _buildBobotRow({
required int index,
required String label,
required IconData icon,
required String tipe,
required String tipeDesc,
required int value,
}) {
final isCost = tipe.toLowerCase() == 'cost';
final percent = value.clamp(0, 100);
final priorityColor = _getPriorityColor(value);
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFE4EDF7)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _categoryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 18, color: _categoryColor),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: Colors.grey[850],
),
),
const SizedBox(height: 2),
Text(
tipeDesc,
style: TextStyle(fontSize: 11, color: Colors.grey[500]),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(color: _categoryColor.withOpacity(0.3)),
),
child: Text(
'$percent%',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
color: _categoryColor,
),
),
),
],
),
const SizedBox(height: 10),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: priorityColor.withOpacity(0.12),
borderRadius: BorderRadius.circular(999),
),
child: Text(
'Prioritas ${_getPriorityLabel(value)}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: priorityColor,
),
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: isCost
? Colors.orange.withOpacity(0.15)
: Colors.green.withOpacity(0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text(
isCost ? 'Biaya' : 'Keuntungan',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: isCost ? Colors.orange[800] : Colors.green[800],
),
),
),
],
),
_buildBobotSlider(index, value),
],
),
);
}
List<Map<String, Object>> _getBobotItems() {
return [
{
'label': 'Harga',
'icon': Icons.payments_outlined,
'tipe': 'Cost',
'tipeDesc': 'Semakin murah semakin baik',
'value': _bobotHarga,
},
{
'label': 'Jarak',
'icon': Icons.location_on_outlined,
'tipe': 'Cost',
'tipeDesc': 'Semakin dekat semakin baik',
'value': _bobotJarak,
},
{
'label': _kriteria3Label,
'icon': widget.category == 'kontrakan'
? Icons.bed_outlined
: Icons.speed_outlined,
'tipe': widget.category == 'kontrakan' ? 'Benefit' : 'Cost',
'tipeDesc': widget.category == 'kontrakan'
? 'Semakin banyak semakin baik'
: 'Semakin cepat semakin baik',
'value': _bobotKriteria3,
},
{
'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 lengkap semakin baik',
'value': _bobotKriteria4,
},
];
}
void _showBobotPicker(int index) {
final items = _getBobotItems();
final item = items[index];
final label = item['label'] as String;
final icon = item['icon'] as IconData;
final tipeDesc = item['tipeDesc'] as String;
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setModalState) {
final currentValues = [
_bobotHarga,
_bobotJarak,
_bobotKriteria3,
_bobotKriteria4,
];
final selected = currentValues[index];
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: _categoryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: _categoryColor),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
tipeDesc,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: _categoryColor.withOpacity(0.3),
),
),
child: Text(
'$selected%',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
color: _categoryColor,
),
),
),
],
),
const SizedBox(height: 10),
Text(
'Geser slider (10%70%). Total otomatis diseimbangkan menjadi 100%.',
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
_buildBobotSlider(
index,
selected,
onChanged: () => setModalState(() {}),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => Navigator.of(ctx).pop(),
style: ElevatedButton.styleFrom(
backgroundColor: _categoryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text(
'Selesai',
style: TextStyle(fontWeight: FontWeight.w700),
),
),
),
],
),
),
);
},
);
},
);
}
/// Load range data (min/max harga, jarak, kamar) dari API
Future<void> _loadRangeData() async {
try {
final endpoint = widget.category == 'kontrakan' ? '/kontrakan/range' : '/laundry/range';
final url = Uri.parse('${AppConfig.baseUrl}$endpoint');
final response = await http.get(url).timeout(const Duration(seconds: 10));
if (response.statusCode == 200) {
final json = jsonDecode(response.body);
if (json['success'] == true) {
final data = json['data'];
if (mounted) {
setState(() {
_hargaMin = data['harga']['min'] ?? 0;
_hargaMax = data['harga']['max'] ?? 10000000;
if (widget.category != 'kontrakan') {
_availableJenisLayanan = List<String>.from(data['jenis_layanan'] ?? []);
}
_availableFasilitas = List<String>.from(data['fasilitas'] ?? []);
if (_availableFasilitas.isEmpty) {
_availableFasilitas = ['Antar Jemput', 'Express', 'Paket Kiloan', 'Satuan'];
}
_loadingRange = false;
});
}
}
}
} catch (e) {
debugPrint('Load range data error: $e');
if (mounted) {
// Provide safe fallbacks so dropdowns appear even when the API fails
setState(() {
_loadingRange = false;
if (_availableFasilitas.isEmpty) {
_availableFasilitas = [
'WiFi',
'Parkir',
'Dapur Bersama',
'Lemari',
'Air Panas',
];
}
if (_hargaMin == _hargaMax) {
// ensure some sensible max for budget dropdown
_hargaMax = _hargaMin + 500000; // add 500k fallback
}
});
}
}
}
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-CALCULATE BOBOT FROM QUESTIONNAIRE ANSWERS
/// ============================================================================
/// Smart logic: infer user priority dari jawaban tanpa perlu slider
/// IMPORTANT: Always ensure total = 100% (auto-normalize)
void _calculateBobotFromQuestionnaire() {
// Start with base values
double hargaBobot = 50.0;
double jarakBobot = 20.0;
double fasilitasBobot = 15.0;
double kamarBobot = 15.0;
// Q1: Budget adjustment (sesuai data real dari database)
if (_budgetSelected != null) {
double budgetRange = (_hargaMax - _hargaMin).toDouble();
double budgetPercent =
(_budgetSelected! - _hargaMin.toDouble()) / budgetRange;
if (budgetPercent < 0.3) {
// Budget kecil (bottom 30%) → harga SUPER penting, tapi kamar tetap relevan
hargaBobot = 70.0;
fasilitasBobot = 15.0;
jarakBobot = 10.0;
kamarBobot = 5.0; // ← Kamar tetap penting (tidak 0%)
} else if (budgetPercent < 0.7) {
// Budget sedang (30-70%) → balanced
hargaBobot = 50.0;
fasilitasBobot = 30.0;
jarakBobot = 15.0;
kamarBobot = 5.0;
} else {
// Budget besar (top 30%) → fasilitas lebih penting
hargaBobot = 30.0;
fasilitasBobot = 35.0;
jarakBobot = 20.0;
kamarBobot = 15.0;
}
}
// Q2: Facilities selected adjustment
if (_selectedFacilities.isNotEmpty) {
// Lebih banyak fasilitas critical → naikkan fasilitas weight
if (_selectedFacilities.length >= 4) {
fasilitasBobot += 10.0;
// Kamar tetap penting, minimal 5%
kamarBobot = (kamarBobot - 2.0).clamp(5.0, 40.0);
}
}
// Q3: Distance preference adjustment
if (_distancePreference == 'dekat') {
jarakBobot += 15.0;
// Jarak penting, tapi jangan reduce kamar terlalu banyak
kamarBobot = (kamarBobot - 3.0).clamp(5.0, 40.0);
} else if (_distancePreference == 'jauh_ok') {
jarakBobot = (jarakBobot - 10.0).clamp(5.0, 40.0);
}
// Q4: Room preference adjustment
// NEW LOGIC: 4+ kamar = very important, 2-3 kamar = medium, 1 kamar = low
if (_roomPreference == '1_kamar') {
// 1 kamar → kamar tidak terlalu penting (mahasiswa fokus hemat)
kamarBobot = (kamarBobot - 5.0).clamp(5.0, 40.0);
fasilitasBobot = (fasilitasBobot + 5.0).clamp(10.0, 50.0);
} else if (_roomPreference == '2_3_kamar') {
// 2-3 kamar → kamar agak penting
kamarBobot = (kamarBobot + 10.0).clamp(5.0, 40.0);
fasilitasBobot = (fasilitasBobot - 5.0).clamp(10.0, 40.0);
} else if (_roomPreference == '4_plus_kamar') {
// 4+ kamar → kamar SANGAT penting (premium selection)
kamarBobot = (kamarBobot + 25.0).clamp(5.0, 40.0);
fasilitasBobot = (fasilitasBobot - 15.0).clamp(10.0, 40.0);
}
// ============================================================
// ENFORCE MINIMUM: Kamar ALWAYS >= 5% (penting untuk seleksi)
// ============================================================
kamarBobot = kamarBobot.clamp(5.0, 40.0);
// ============================================================
// NORMALIZE TO 100% - CRITICAL!
// Simple proportional normalization (safest approach)
// ============================================================
double total = hargaBobot + jarakBobot + fasilitasBobot + kamarBobot;
if (total > 0 && total != 100.0) {
double factor = 100.0 / total;
hargaBobot = (hargaBobot * factor).round().toDouble();
jarakBobot = (jarakBobot * factor).round().toDouble();
fasilitasBobot = (fasilitasBobot * factor).round().toDouble();
// Last one gets exact remainder to guarantee total = 100
double distributed = hargaBobot + jarakBobot + fasilitasBobot;
kamarBobot = 100.0 - distributed;
}
// Final sanity check: ensure kamarBobot >= 5
if (kamarBobot < 5.0) {
// Adjust other bobot to accommodate minimum kamar
double adjustment = 5.0 - kamarBobot;
kamarBobot = 5.0;
// Reduce harga first (usually most critical)
if (hargaBobot > adjustment + 10.0) {
hargaBobot -= adjustment;
} else {
// If can't reduce harga enough, reduce others
hargaBobot = (hargaBobot - adjustment / 2.0).clamp(20.0, 80.0);
fasilitasBobot = (fasilitasBobot - adjustment / 2.0).clamp(10.0, 50.0);
}
// Re-normalize to ensure total = 100
double total2 = hargaBobot + jarakBobot + fasilitasBobot + kamarBobot;
if (total2 != 100.0) {
double factor2 = 100.0 / total2;
hargaBobot = (hargaBobot * factor2).round().toDouble();
jarakBobot = (jarakBobot * factor2).round().toDouble();
fasilitasBobot = (fasilitasBobot * factor2).round().toDouble();
double distributed2 = hargaBobot + jarakBobot + fasilitasBobot;
kamarBobot = 100.0 - distributed2;
}
}
// Final sanity check
if (hargaBobot + jarakBobot + fasilitasBobot + kamarBobot != 100.0) {
// If something weird happened, use defaults
hargaBobot = 50.0;
jarakBobot = 20.0;
fasilitasBobot = 15.0;
kamarBobot = 15.0;
}
setState(() {
_bobotHarga = hargaBobot.toInt();
_bobotJarak = jarakBobot.toInt();
_bobotKriteria4 = fasilitasBobot.toInt(); // Fasilitas
_bobotKriteria3 = kamarBobot.toInt(); // Kamar
});
}
/// Check if questionnaire complete
bool _isQuestionnaireComplete() {
// Facilities optional: allow submit tanpa memilih fasilitas
if (widget.category == 'kontrakan') {
return _budgetSelected != null && _distancePreference.isNotEmpty && _roomPreference.isNotEmpty;
} else {
// Laundry: require jenis layanan + budget
return _budgetSelected != null && _distancePreference.isNotEmpty && _selectedJenisLayanan.isNotEmpty;
}
}
/// ============================================================================
/// AUTO-BALANCE BOBOT (existing function - keep as is)
/// ============================================================================
/// 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 slider value for a given bobot index (min 10, max 70).
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
}
String _getPriorityLabel(int value) {
if (value <= 20) return 'Rendah';
if (value <= 35) return 'Sedang';
if (value <= 50) return 'Tinggi';
return 'Prioritas';
}
Color _getPriorityColor(int value) {
if (value <= 20) return const Color(0xFF8E8E93);
if (value <= 35) return const Color(0xFF00A389);
if (value <= 50) return const Color(0xFF2F80ED);
return const Color(0xFF7B61FF);
}
bool _isConnectivityError(Object error) {
final msg = error.toString().toLowerCase();
return error is TimeoutException ||
error is SocketException ||
error is http.ClientException ||
msg.contains('future not completed') ||
msg.contains('connection') ||
msg.contains('timed out');
}
Future<void> _resetConnectionAndRetry() async {
setState(() {
_isLoading = true;
_errorMessage = null;
_noData = false;
});
try {
await ServerDiscoveryService.resetCache();
final found = await ServerDiscoveryService.discover();
if (!mounted) return;
if (found) {
await _calculateSAW();
} else {
setState(() {
_isLoading = false;
_hasCalculated = true;
_errorMessage =
'Reset koneksi selesai, tetapi server belum ditemukan. Pastikan backend Laravel aktif di jaringan yang sama.';
});
}
} catch (e) {
if (!mounted) return;
setState(() {
_isLoading = false;
_hasCalculated = true;
_errorMessage = 'Gagal reset koneksi: $e';
});
}
}
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;
// CRITICAL: Send selected facilities to API for filtering
bodyParams['selected_facilities'] = _selectedFacilities.toList();
} else {
final hasAntarJemput = _selectedFacilities.any(
(f) => f.toLowerCase().contains('antar'),
);
bodyParams['bobot_kecepatan'] = _bobotKriteria3;
bodyParams['bobot_layanan'] = _bobotKriteria4;
// Choose selected jenis_layanan, or fall back to a sensible default
final preferredJenis = _availableJenisLayanan.firstWhere(
(e) => e.toLowerCase().contains('cuci'),
orElse: () => _availableJenisLayanan.isNotEmpty ? _availableJenisLayanan.first : 'Cuci Baju');
bodyParams['jenis_layanan'] = _selectedJenisLayanan.isNotEmpty ? _selectedJenisLayanan : preferredJenis;
// optional: send selected facilities for laundry (e.g., express, antar)
bodyParams['selected_facilities'] = _selectedFacilities.toList();
// send preset and antar/jemput preferences
bodyParams['preset'] = _selectedPreset;
bodyParams['antar_jemput'] = hasAntarJemput ? 1 : 0;
// rating filter removed
}
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;
final canReset = _isConnectivityError(e);
setState(() {
_errorMessage = canReset
? 'Tidak dapat terhubung ke server (${AppConfig.baseUrl}). Silakan tekan tombol Reset Koneksi.\n\nDetail: $e'
: '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 {
if (_isDetectingLocation) return;
setState(() => _isDetectingLocation = true);
try {
final locationService = LocationService();
final position = await locationService.getCurrentLocation();
if (position != null) {
if (!mounted) return;
setState(() {
_userLatitude = position.latitude;
_userLongitude = position.longitude;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Lokasi berhasil dideteksi!'),
backgroundColor: Colors.green,
duration: Duration(seconds: 2),
),
);
return;
}
if (!mounted) return;
final permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
permission == LocationPermission.deniedForever
? 'Izin lokasi ditolak permanen. Ubah di pengaturan aplikasi.'
: 'Izin lokasi ditolak. Aktifkan izin lokasi untuk melanjutkan.',
),
backgroundColor: Colors.red,
action: permission == LocationPermission.deniedForever
? SnackBarAction(
label: 'Buka',
onPressed: () => Geolocator.openAppSettings(),
)
: null,
),
);
return;
}
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
serviceEnabled
? 'Gagal mendapatkan lokasi. Coba lagi di area terbuka.'
: 'GPS mati. Buka pengaturan lokasi?',
),
backgroundColor: Colors.red,
action: SnackBarAction(
label: 'Buka',
onPressed: () => Geolocator.openLocationSettings(),
),
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Gagal mendeteksi lokasi: ${e.toString().replaceAll('Exception: ', '')}',
),
backgroundColor: Colors.red,
),
);
} finally {
if (mounted) {
setState(() => _isDetectingLocation = false);
}
}
}
@override
Widget build(BuildContext context) {
final categoryTitle = widget.category == 'kontrakan'
? 'Rekomendasi Kontrakan'
: 'Rekomendasi Laundry';
// Show questionnaire first for kontrakan and laundry (only before calculation)
if ((widget.category == 'kontrakan' || widget.category == 'laundry') && _showQuestionnaire && !_hasCalculated) {
return _buildQuestionnaireView(categoryTitle);
}
return Scaffold(
backgroundColor: const Color(0xFFF3F7FB),
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(),
);
}
/// ============================================================================
/// QUESTIONNAIRE VIEW (Smart Questions untuk mahasiswa)
/// ============================================================================
/// ============================================================================
/// DROPDOWN BUILDERS FOR QUESTIONNAIRE
/// ============================================================================
Widget _buildBudgetDropdown() {
final range = (_hargaMax - _hargaMin).clamp(0, 100000000);
final step = (range / 5).ceil().clamp(1, 100000000);
List<double> budgetOptions = [];
for (int i = 0; i <= 5; i++) {
final value = _hargaMin + (i * step);
budgetOptions.add(value.toDouble());
}
final label = _budgetSelected == null
? 'Pilih Budget...'
: _formatCurrency(_budgetSelected!.toInt());
return GestureDetector(
onTap: () => _showBudgetModal(budgetOptions),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFBDBDBD)),
borderRadius: BorderRadius.circular(4),
color: Colors.white,
),
child: Row(
children: [
Expanded(child: Text(label, style: TextStyle(color: _budgetSelected == null ? const Color(0xFF999999) : Colors.black))),
const SizedBox(width: 8),
Icon(Icons.arrow_drop_down, color: Colors.grey[600]),
],
),
),
);
}
Widget _buildFacilitiesDropdown() {
if (_availableFasilitas.isEmpty) {
return Padding(
padding: const EdgeInsets.all(10),
child: Text(
'Fasilitas tidak tersedia',
style: TextStyle(color: Colors.grey[600]),
),
);
}
// Show as a single-line select field that opens a modal multi-select
final placeholder = widget.category == 'laundry'
? 'Pilih Fasilitas (termasuk antar jemput)'
: 'Pilih Fasilitas...';
final summary = _selectedFacilities.isEmpty
? placeholder
: _selectedFacilities.join(', ');
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: _showFacilitiesModal,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFBDBDBD)),
borderRadius: BorderRadius.circular(4),
color: Colors.white,
),
child: Row(
children: [
Expanded(
child: Text(
summary,
style: TextStyle(
color: _selectedFacilities.isEmpty
? const Color(0xFF999999)
: Colors.black,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Icon(Icons.arrow_drop_down, color: Colors.grey[600]),
],
),
),
),
if (_selectedFacilities.isNotEmpty) ...[
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: _selectedFacilities.map((facility) {
return Chip(
label: Text(facility, style: const TextStyle(fontSize: 12)),
onDeleted: () {
setState(() => _selectedFacilities.remove(facility));
},
backgroundColor: _categoryColor.withOpacity(0.2),
deleteIconColor: _categoryColor,
);
}).toList(),
),
],
],
);
}
Widget _buildDistanceDropdown() {
final distanceOptions = [
{'label': 'Dekat (< 2km)', 'value': 'dekat'},
{'label': 'Sedang (2-5km)', 'value': 'sedang'},
{'label': 'Jauh OK (> 5km)', 'value': 'jauh_ok'},
{'label': 'Tidak Peduli', 'value': 'tidak_peduli'},
];
final label = distanceOptions
.firstWhere((o) => o['value'] == _distancePreference, orElse: () => distanceOptions[1])['label'] ??
'Pilih Jarak...';
return GestureDetector(
onTap: () => _showSingleChoiceModal('Pilih Jarak', distanceOptions,
_distancePreference, (val) => setState(() => _distancePreference = val)),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFBDBDBD)),
borderRadius: BorderRadius.circular(4),
color: Colors.white,
),
child: Row(
children: [
Expanded(child: Text(label)),
const SizedBox(width: 8),
Icon(Icons.arrow_drop_down, color: Colors.grey[600]),
],
),
),
);
}
Widget _buildRoomDropdown() {
final roomOptions = [
{'label': '1 Kamar (Hemat)', 'value': '1_kamar'},
{'label': '2-3 Kamar (Sedang)', 'value': '2_3_kamar'},
{'label': '4+ Kamar (Luas)', 'value': '4_plus_kamar'},
{'label': 'Tidak Peduli', 'value': 'tidak_peduli'},
];
final label = roomOptions
.firstWhere((o) => o['value'] == _roomPreference, orElse: () => roomOptions[3])['label'] ??
'Pilih Jumlah Kamar...';
return GestureDetector(
onTap: () => _showSingleChoiceModal('Pilih Jumlah Kamar', roomOptions,
_roomPreference, (val) => setState(() => _roomPreference = val)),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFBDBDBD)),
borderRadius: BorderRadius.circular(4),
color: Colors.white,
),
child: Row(
children: [
Expanded(child: Text(label)),
const SizedBox(width: 8),
Icon(Icons.arrow_drop_down, color: Colors.grey[600]),
],
),
),
);
}
Widget _buildJenisLaundryDropdown() {
if (_availableJenisLayanan.isEmpty) {
return Padding(
padding: const EdgeInsets.all(10),
child: Text(
'Jenis layanan tidak tersedia',
style: TextStyle(color: Colors.grey[600]),
),
);
}
// Prefer a sensible default: any option containing 'cuci' (e.g., 'Cuci Baju')
final preferred = _availableJenisLayanan.firstWhere(
(e) => e.toLowerCase().contains('cuci'),
orElse: () => _availableJenisLayanan.isNotEmpty ? _availableJenisLayanan.first : 'Cuci Baju');
final displayValue = _selectedJenisLayanan.isNotEmpty ? _selectedJenisLayanan : preferred;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFBDBDBD)),
borderRadius: BorderRadius.circular(4),
color: Colors.white,
),
child: Row(
children: [
Expanded(child: Text(displayValue, style: TextStyle(color: _selectedJenisLayanan.isEmpty ? const Color(0xFF666666) : Colors.black))),
const SizedBox(width: 8),
// Small edit affordance instead of full dropdown to keep default simple
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
icon: Icon(Icons.edit, color: Colors.grey[600]),
onPressed: () => _showSingleChoiceModal('Pilih Jenis Layanan',
_availableJenisLayanan.map((e) => {'label': e, 'value': e}).toList(), _selectedJenisLayanan,
(val) => setState(() => _selectedJenisLayanan = val)),
),
],
),
);
}
// rating UI removed per UX feedback
Widget _buildLaundryPresets() {
final presets = ['Normal', 'Cepat (Express)', 'Hemat'];
return Row(
children: presets.map((p) {
final isSel = _selectedPreset == p;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(p),
selected: isSel,
onSelected: (_) => setState(() => _selectedPreset = p),
selectedColor: _categoryColor,
),
);
}).toList(),
);
}
Widget _buildAntarJemputToggle() {
return Row(
children: [
Switch(
value: _antarJemput,
onChanged: (v) => setState(() => _antarJemput = v),
activeColor: _categoryColor,
),
const SizedBox(width: 8),
Text(_antarJemput ? 'Aktif' : 'Mati'),
],
);
}
// Show a modal bottom sheet for multi-select facilities
void _showFacilitiesModal() async {
final selected = Set<String>.from(_selectedFacilities);
await showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (ctx) {
return StatefulBuilder(builder: (ctx2, setInner) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(ctx).viewInsets.bottom),
child: Container(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Pilih Fasilitas', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
TextButton(
onPressed: () {
setState(() {
_selectedFacilities = selected;
});
Navigator.of(ctx).pop();
},
child: const Text('Selesai'),
)
],
),
const SizedBox(height: 8),
Flexible(
child: SingleChildScrollView(
child: Column(
children: _availableFasilitas.map((f) {
final isSel = selected.contains(f);
return CheckboxListTile(
title: Text(f),
value: isSel,
onChanged: (v) => setInner(() => v == true ? selected.add(f) : selected.remove(f)),
);
}).toList(),
),
),
),
],
),
),
);
});
},
);
}
// Show a single-choice modal and call onSelected with chosen value
void _showSingleChoiceModal(String title, List<Map<String, String>> options, String currentValue, Function(String) onSelected) async {
String sel = currentValue;
await showModalBottomSheet(
context: context,
builder: (ctx) {
return StatefulBuilder(builder: (ctx2, setInner) {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
TextButton(
onPressed: () {
onSelected(sel);
Navigator.of(ctx).pop();
},
child: const Text('OK'),
)
],
),
const SizedBox(height: 8),
...options.map((o) {
final v = o['value'] ?? '';
final label = o['label'] ?? v;
return RadioListTile<String>(
value: v,
groupValue: sel,
title: Text(label),
onChanged: (val) => setInner(() => sel = val ?? sel),
);
}).toList(),
],
),
);
});
},
);
}
// Show budget modal with radio-like quick-picker and option list
void _showBudgetModal(List<double> options) async {
double sel = _budgetSelected ?? (options.isNotEmpty ? options[(options.length / 2).floor()] : 0);
await showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (ctx) {
return StatefulBuilder(builder: (ctx2, setInner) {
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(ctx).viewInsets.bottom),
child: Container(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Pilih Budget', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
TextButton(
onPressed: () {
setState(() => _budgetSelected = sel);
Navigator.of(ctx).pop();
},
child: const Text('Selesai'),
)
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: options.map((v) {
final label = _formatCurrency(v.toInt());
final isSel = sel == v;
return GestureDetector(
onTap: () => setInner(() => sel = v),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: isSel ? _categoryColor : Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: isSel ? _categoryColor : const Color(0xFFE4EDF7)),
),
child: Text(
label,
style: TextStyle(color: isSel ? Colors.white : Colors.black),
),
),
);
}).toList(),
),
const SizedBox(height: 12),
// Also show full list as radio options for accessibility
...options.map((v) {
return RadioListTile<double>(
value: v,
groupValue: sel,
title: Text(_formatCurrency(v.toInt())),
onChanged: (val) => setInner(() => sel = val ?? sel),
);
}).toList(),
],
),
),
);
});
},
);
}
/// ============================================================================
/// QUESTIONNAIRE VIEW
/// ============================================================================
Widget _buildQuestionnaireView(String categoryTitle) {
// Show loading state while fetching range data
if (_loadingRange) {
return Scaffold(
backgroundColor: const Color(0xFFF3F7FB),
appBar: AppBar(
backgroundColor: _categoryColor,
foregroundColor: Colors.white,
elevation: 0,
title: Text(
categoryTitle,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(color: _categoryColor),
const SizedBox(height: 16),
const Text(
'Mempersiapkan pertanyaan...',
style: TextStyle(fontSize: 14, color: Colors.black54),
),
],
),
),
);
}
return Scaffold(
backgroundColor: const Color(0xFFF3F7FB),
appBar: AppBar(
backgroundColor: _categoryColor,
foregroundColor: Colors.white,
elevation: 0,
title: Text(
categoryTitle,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFE4EDF7)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.lightbulb_rounded,
size: 20,
color: _categoryColor,
),
const SizedBox(width: 8),
const Expanded(
child: Text(
'Ceritakan Kebutuhan Mu',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 8),
Text(
widget.category == 'kontrakan'
? 'Kami akan cari kontrakan terbaik sesuai budget & kebutuhan mu'
: 'Kami akan cari laundry terbaik sesuai budget & kebutuhan mu',
style: TextStyle(
fontSize: 13,
color: Colors.black.withOpacity(0.6),
),
),
],
),
),
const SizedBox(height: 20),
// Questionnaire for kontrakan vs laundry
if (widget.category == 'kontrakan') ...[
_buildQuestionCard(
title: '💰 Berapa Budget?',
child: _buildBudgetDropdown(),
),
const SizedBox(height: 16),
_buildQuestionCard(
title: '⭐ Fasilitas Wajib Ada?',
child: _buildFacilitiesDropdown(),
),
const SizedBox(height: 16),
_buildQuestionCard(
title: '📍 Jarak ke Kampus?',
child: _buildDistanceDropdown(),
),
const SizedBox(height: 16),
_buildQuestionCard(
title: '🛏️ Jumlah Kamar?',
child: _buildRoomDropdown(),
),
] else ...[
_buildQuestionCard(
title: '🧾 Jenis Layanan',
child: _buildJenisLaundryDropdown(),
),
const SizedBox(height: 12),
_buildQuestionCard(
title: '⚡ Preset Cepat',
child: _buildLaundryPresets(),
),
const SizedBox(height: 12),
_buildQuestionCard(
title: '💰 Berapa Budget?',
child: _buildBudgetDropdown(),
),
const SizedBox(height: 16),
_buildQuestionCard(
title: '📍 Jarak ke Lokasi?',
child: _buildDistanceDropdown(),
),
const SizedBox(height: 16),
// rating removed per UX feedback
_buildQuestionCard(
title: '🔸 Fasilitas (termasuk antar jemput)',
child: _buildFacilitiesDropdown(),
),
],
const SizedBox(height: 24),
// Submit Button
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: _categoryColor,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
disabledBackgroundColor: Colors.grey,
),
onPressed: _isQuestionnaireComplete()
? () {
_calculateBobotFromQuestionnaire();
setState(() => _hasCalculated = true);
// CRITICAL: Call API SAW to get recommendations with calculated bobot
_calculateSAW();
}
: null,
child: Text(
widget.category == 'kontrakan' ? 'TEMUKAN KONTRAKAN' : 'TEMUKAN LAUNDRY',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.white,
letterSpacing: 0.5,
),
),
),
),
const SizedBox(height: 24),
],
),
),
);
}
// Helper widgets untuk questionnaire
Widget _buildQuestionCard({required String title, required Widget child}) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFE4EDF7)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
const SizedBox(height: 14),
child,
],
),
);
}
/// Helper: Format currency to readable format
String _formatCurrency(int value) {
if (value >= 1000000) {
return 'Rp ${(value / 1000000).toStringAsFixed(1)}jt';
} else if (value >= 1000) {
return 'Rp ${(value / 1000).toStringAsFixed(0)}rb';
} else {
return 'Rp $value';
}
}
// ===================== INPUT VIEW =====================
Widget _buildInputView() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildUserInfoCard(),
const SizedBox(height: 16),
_buildSimpleStepCard(),
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 _buildSimpleStepCard() {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFE4EDF7)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.route_rounded, size: 18, color: _categoryColor),
const SizedBox(width: 8),
const Text(
'Langkah Cepat',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700),
),
],
),
const SizedBox(height: 10),
_buildStepLine(
'1',
'Pilih prioritas yang paling penting untuk Anda.',
),
const SizedBox(height: 6),
_buildStepLine('2', 'Pastikan total prioritas 100%.'),
const SizedBox(height: 6),
_buildStepLine('3', 'Tekan Hitung, lalu pilih hasil teratas.'),
],
),
);
}
Widget _buildStepLine(String number, String text) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: _categoryColor.withOpacity(0.12),
shape: BoxShape.circle,
),
child: Center(
child: Text(
number,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: _categoryColor,
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: TextStyle(
fontSize: 12,
color: Colors.grey[700],
height: 1.35,
),
),
),
],
);
}
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() {
final kriteria3 = widget.category == 'kontrakan'
? 'jumlah kamar'
: 'kecepatan layanan';
final kriteria4 = 'fasilitas';
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(
'Cara Rekomendasi Bekerja',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 8),
Text(
'Pilih mana yang paling penting untuk Anda. Sistem akan mengurutkan hasil dari yang paling cocok.',
style: TextStyle(
color: Colors.white.withOpacity(0.9),
fontSize: 13,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.adjust, size: 14, color: Colors.white),
const SizedBox(width: 6),
Expanded(
child: Text(
'1. Atur prioritas harga, jarak, $kriteria3, dan $kriteria4.',
style: TextStyle(
color: Colors.white.withOpacity(0.95),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
],
),
const SizedBox(height: 6),
Row(
children: [
const Icon(Icons.adjust, size: 14, color: Colors.white),
const SizedBox(width: 6),
Expanded(
child: Text(
'2. Total bobot harus 100% (otomatis diseimbangkan).',
style: TextStyle(
color: Colors.white.withOpacity(0.95),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
],
),
const SizedBox(height: 6),
Row(
children: [
const Icon(Icons.adjust, size: 14, color: Colors.white),
const SizedBox(width: 6),
Expanded(
child: Text(
'3. Lihat urutan hasil, lalu pilih yang paling sesuai.',
style: TextStyle(
color: Colors.white.withOpacity(0.95),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
],
),
],
),
),
const SizedBox(height: 8),
Text(
'Tips: Fokuskan 1-2 prioritas utama agar hasil lebih akurat dan mudah dipilih.',
style: TextStyle(
color: Colors.white.withOpacity(0.9),
fontSize: 12,
fontStyle: FontStyle.italic,
),
),
],
),
);
}
Widget _buildJenisLayananSection() {
final jenisOptions = [
{
'value': 'harian',
'label': 'Harian',
'icon': Icons.today,
'desc': 'Paket selesai harian dengan biaya lebih hemat',
},
{
'value': 'jam',
'label': 'Jam',
'icon': Icons.schedule,
'desc': 'Paket selesai dalam hitungan jam (lebih cepat)',
},
];
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(
'Geser slider pada setiap kriteria (10%70%). Total akan disesuaikan otomatis menjadi 100%.',
style: TextStyle(fontSize: 11, color: Colors.grey[500]),
),
const SizedBox(height: 12),
_buildBobotStackedBar(),
const SizedBox(height: 12),
_buildBobotRows(),
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 _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(
'Menyiapkan rekomendasi...',
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, true),
const SizedBox(width: 6),
_buildBobotMiniChip('Jarak', _bobotJarak, true),
const SizedBox(width: 6),
_buildBobotMiniChip(_kriteria3Label, _bobotKriteria3, widget.category == 'laundry'),
const SizedBox(width: 6),
_buildBobotMiniChip(_kriteria4Label, _bobotKriteria4, false),
],
),
],
),
);
}
Widget _buildBobotMiniChip(String label, int value, bool isCost) {
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 'harian':
return 'Harian';
case 'jam':
return 'Jam';
case 'reguler':
return 'Harian';
case 'express':
return 'Jam';
default:
return key;
}
}
Widget _buildResultContent() {
if (_isLoading) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Sedang menyiapkan rekomendasi terbaik...'),
],
),
);
}
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')
: 'Belum Ada Hasil yang Cocok',
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)
Column(
mainAxisSize: MainAxisSize.min,
children: [
ElevatedButton.icon(
onPressed: () => setState(() {
_hasCalculated = false;
_errorMessage = null;
_noData = false;
}),
icon: const Icon(Icons.tune),
label: const Text('Atur Prioritas Lagi'),
style: ElevatedButton.styleFrom(
backgroundColor: _categoryColor,
foregroundColor: Colors.white,
),
),
if (_errorMessage != null &&
(_errorMessage!.toLowerCase().contains(
'tidak dapat terhubung ke server',
) ||
_errorMessage!.toLowerCase().contains('timeout')))
Padding(
padding: const EdgeInsets.only(top: 10),
child: OutlinedButton.icon(
onPressed: _resetConnectionAndRetry,
icon: const Icon(Icons.restart_alt),
label: const Text('Reset Koneksi'),
style: OutlinedButton.styleFrom(
foregroundColor: _categoryColor,
side: BorderSide(color: _categoryColor),
),
),
),
],
)
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),
userLatitude: _userLatitude,
userLongitude: _userLongitude,
selectedJenisLayanan: _selectedJenisLayanan,
),
),
],
),
);
}
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':
case 'Fasilitas 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':
case 'Fasilitas 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;
}
}
}