3313 lines
110 KiB
Dart
3313 lines
110 KiB
Dart
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 '../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: profil mahasiswa (akan di-override oleh questionnaire)
|
|
|
|
_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 edit)',
|
|
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 _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 InkWell(
|
|
onTap: () => _showBobotPicker(index),
|
|
borderRadius: BorderRadius.circular(14),
|
|
child: 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(width: 6),
|
|
Icon(Icons.chevron_right, color: Colors.grey[400]),
|
|
],
|
|
),
|
|
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 ? 'Cost' : 'Benefit',
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w700,
|
|
color: isCost ? Colors.orange[800] : Colors.green[800],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(999),
|
|
child: Stack(
|
|
children: [
|
|
Container(
|
|
height: 10,
|
|
color: _categoryColor.withOpacity(0.12),
|
|
),
|
|
LayoutBuilder(
|
|
builder: (ctx, c) {
|
|
return Container(
|
|
height: 10,
|
|
width: c.maxWidth * (percent / 100.0),
|
|
color: _categoryColor,
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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': 'Benefit',
|
|
'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;
|
|
|
|
final currentValues = [
|
|
_bobotHarga,
|
|
_bobotJarak,
|
|
_bobotKriteria3,
|
|
_bobotKriteria4,
|
|
];
|
|
|
|
final options = _getBobotOptionsFor(index);
|
|
final selected = currentValues[index];
|
|
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
|
),
|
|
builder: (ctx) {
|
|
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(
|
|
'Pilih bobot. Total otomatis diseimbangkan menjadi 100%.',
|
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Wrap(
|
|
spacing: 10,
|
|
runSpacing: 10,
|
|
children: options.map((v) {
|
|
final isSel = v == selected;
|
|
return InkWell(
|
|
borderRadius: BorderRadius.circular(12),
|
|
onTap: () {
|
|
_updateBobot(index, v);
|
|
Navigator.of(ctx).pop();
|
|
},
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
|
decoration: BoxDecoration(
|
|
color: isSel ? _categoryColor : Colors.white,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: isSel
|
|
? _categoryColor
|
|
: const Color(0xFFE4EDF7),
|
|
width: isSel ? 2 : 1,
|
|
),
|
|
),
|
|
child: Text(
|
|
'$v%',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w800,
|
|
color: isSel ? Colors.white : Colors.grey[800],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
const SizedBox(height: 12),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// 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 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);
|
|
}
|
|
|
|
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 {
|
|
// Guard: prevent re-entrant / duplicate calls
|
|
if (_isDetectingLocation) return;
|
|
|
|
setState(() => _isDetectingLocation = true);
|
|
try {
|
|
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
|
if (!serviceEnabled) {
|
|
if (!mounted) return;
|
|
// Offer user to open location settings
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: const Text('GPS mati. Buka pengaturan lokasi?'),
|
|
action: SnackBarAction(
|
|
label: 'Buka',
|
|
onPressed: () => Geolocator.openLocationSettings(),
|
|
),
|
|
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;
|
|
}
|
|
|
|
// Primary attempt: get current position with timeout
|
|
Position? position;
|
|
try {
|
|
position = await Geolocator.getCurrentPosition(
|
|
desiredAccuracy: LocationAccuracy.high,
|
|
).timeout(
|
|
const Duration(seconds: 15),
|
|
onTimeout: () => throw TimeoutException('Deteksi lokasi timeout'),
|
|
);
|
|
} catch (e) {
|
|
debugPrint('getCurrentPosition failed: $e');
|
|
}
|
|
|
|
// Fallback: try last known position
|
|
if (position == null) {
|
|
try {
|
|
position = await Geolocator.getLastKnownPosition();
|
|
debugPrint('getLastKnownPosition result: $position');
|
|
} catch (e) {
|
|
debugPrint('getLastKnownPosition failed: $e');
|
|
}
|
|
}
|
|
|
|
// Further fallback: subscribe briefly to position stream
|
|
if (position == null) {
|
|
try {
|
|
final stream = Geolocator.getPositionStream(
|
|
locationSettings: const LocationSettings(
|
|
accuracy: LocationAccuracy.high,
|
|
distanceFilter: 10,
|
|
),
|
|
);
|
|
position = await stream.first.timeout(const Duration(seconds: 10));
|
|
debugPrint('getPositionStream first: $position');
|
|
} catch (e) {
|
|
debugPrint('getPositionStream failed: $e');
|
|
}
|
|
}
|
|
|
|
if (position == null) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Gagal mendapatkan lokasi. Pastikan GPS dan izin sudah aktif.'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
setState(() => _isDetectingLocation = false);
|
|
return;
|
|
}
|
|
|
|
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'
|
|
? '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(
|
|
'Tap salah satu kriteria untuk mengubah bobot. 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, false),
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|