Fix laundry distance display with user location and cleanup mobile laundry model

This commit is contained in:
micko samawa 2026-04-04 13:16:51 +07:00
parent b192557bb8
commit a8e72f1203
27 changed files with 269 additions and 209 deletions

View File

@ -95,8 +95,8 @@ public function store(Request $request)
$startDate = Carbon::parse($request->tanggal_mulai); $startDate = Carbon::parse($request->tanggal_mulai);
$endDate = $startDate->copy()->addMonths((int)$request->durasi_bulan); $endDate = $startDate->copy()->addMonths((int)$request->durasi_bulan);
// Calculate total biaya // Calculate total biaya from annual price pro-rated to monthly duration
$amount = $kontrakan->harga * (int)$request->durasi_bulan; $amount = $kontrakan->harga * ((int)$request->durasi_bulan / 12);
$bookingData = [ $bookingData = [
'user_id' => $request->user()->id, 'user_id' => $request->user()->id,
@ -211,7 +211,7 @@ public function extend(Request $request, $id)
// Create new booking untuk perpanjangan // Create new booking untuk perpanjangan
$startDate = Carbon::parse($booking->end_date); $startDate = Carbon::parse($booking->end_date);
$endDate = $startDate->copy()->addMonths($request->durasi_bulan); $endDate = $startDate->copy()->addMonths($request->durasi_bulan);
$amount = $booking->kontrakan->harga * $request->durasi_bulan; $amount = $booking->kontrakan->harga * ($request->durasi_bulan / 12);
$newBooking = Booking::create([ $newBooking = Booking::create([
'user_id' => $request->user()->id, 'user_id' => $request->user()->id,

View File

@ -167,7 +167,7 @@ public function calculateKontrakan(Request $request)
public function calculateLaundry(Request $request) public function calculateLaundry(Request $request)
{ {
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'jenis_layanan' => 'nullable|string|in:reguler,express,kilat', 'jenis_layanan' => 'nullable|string|in:reguler,express',
'harga_min' => 'nullable|numeric', 'harga_min' => 'nullable|numeric',
'harga_max' => 'nullable|numeric', 'harga_max' => 'nullable|numeric',
'jarak_max' => 'nullable|numeric', 'jarak_max' => 'nullable|numeric',
@ -220,7 +220,8 @@ public function calculateLaundry(Request $request)
// Filter by jenis_layanan if provided // Filter by jenis_layanan if provided
if ($jenisLayanan) { if ($jenisLayanan) {
$query->whereHas('layanan', function($q) use ($jenisLayanan) { $query->whereHas('layanan', function($q) use ($jenisLayanan) {
$q->where('jenis_layanan', $jenisLayanan); $values = $this->mapJenisLayananQueryValues($jenisLayanan);
$q->whereIn('jenis_layanan', $values);
}); });
} }
@ -464,12 +465,25 @@ private function prosesMetodeSAWLaundry($items, $kriteria, $customBobot = null,
/** /**
* Helper: Get nilai laundry berdasarkan nama kriteria * Helper: Get nilai laundry berdasarkan nama kriteria
*/ */
private function mapJenisLayananQueryValues($jenisLayanan)
{
$map = [
'reguler' => ['reguler', 'kiloan'],
'express' => ['express', 'satuan'],
];
return $map[$jenisLayanan] ?? [$jenisLayanan];
}
private function getNilaiLaundry($item, $field, $jenisLayanan = null) private function getNilaiLaundry($item, $field, $jenisLayanan = null)
{ {
// Filter layanan by jenis if specified // Filter layanan by jenis if specified
$layananCollection = $jenisLayanan if ($jenisLayanan) {
? $item->layanan->where('jenis_layanan', $jenisLayanan) $filterValues = $this->mapJenisLayananQueryValues($jenisLayanan);
: $item->layanan; $layananCollection = $item->layanan->whereIn('jenis_layanan', $filterValues);
} else {
$layananCollection = $item->layanan;
}
switch ($field) { switch ($field) {
case 'harga': case 'harga':

View File

@ -200,7 +200,7 @@ public function map(Request $request)
'harga_min' => 'nullable|numeric|min:0', 'harga_min' => 'nullable|numeric|min:0',
'harga_max' => 'nullable|numeric|min:0|gte:harga_min', 'harga_max' => 'nullable|numeric|min:0|gte:harga_min',
'jarak' => 'nullable|in:dekat,sedang,jauh', 'jarak' => 'nullable|in:dekat,sedang,jauh',
'jenis_layanan' => 'nullable|in:express,reguler,kilat', 'jenis_layanan' => 'nullable|in:express,reguler',
]); ]);
$query = Laundry::with('layanan'); $query = Laundry::with('layanan');
@ -308,7 +308,7 @@ public function store(Request $request)
// Validasi Layanan // Validasi Layanan
'layanan' => 'required|array|min:1', 'layanan' => 'required|array|min:1',
'layanan.*.jenis_layanan' => 'required|in:express,reguler,kilat', 'layanan.*.jenis_layanan' => 'required|in:express,reguler',
'layanan.*.nama_paket' => 'required|string|max:255', 'layanan.*.nama_paket' => 'required|string|max:255',
'layanan.*.harga' => 'required|numeric|min:0', 'layanan.*.harga' => 'required|numeric|min:0',
'layanan.*.estimasi_selesai' => 'required|numeric|min:1', 'layanan.*.estimasi_selesai' => 'required|numeric|min:1',
@ -327,7 +327,7 @@ public function store(Request $request)
'layanan.required' => 'Minimal harus ada 1 jenis layanan', 'layanan.required' => 'Minimal harus ada 1 jenis layanan',
'layanan.min' => 'Minimal harus ada 1 jenis layanan', 'layanan.min' => 'Minimal harus ada 1 jenis layanan',
'layanan.*.jenis_layanan.required' => 'Jenis layanan harus dipilih', 'layanan.*.jenis_layanan.required' => 'Jenis layanan harus dipilih',
'layanan.*.jenis_layanan.in' => 'Jenis layanan harus express, reguler, atau kilat', 'layanan.*.jenis_layanan.in' => 'Jenis layanan harus express atau reguler',
'layanan.*.nama_paket.required' => 'Nama paket harus diisi', 'layanan.*.nama_paket.required' => 'Nama paket harus diisi',
'layanan.*.harga.required' => 'Harga layanan harus diisi', 'layanan.*.harga.required' => 'Harga layanan harus diisi',
'layanan.*.harga.min' => 'Harga tidak boleh negatif', 'layanan.*.harga.min' => 'Harga tidak boleh negatif',
@ -504,7 +504,7 @@ public function update(Request $request, Laundry $laundry)
// Validasi Layanan // Validasi Layanan
'layanan' => 'required|array|min:1', 'layanan' => 'required|array|min:1',
'layanan.*.jenis_layanan' => 'required|in:express,reguler,kilat', 'layanan.*.jenis_layanan' => 'required|in:express,reguler',
'layanan.*.nama_paket' => 'required|string|max:255', 'layanan.*.nama_paket' => 'required|string|max:255',
'layanan.*.harga' => 'required|numeric|min:0', 'layanan.*.harga' => 'required|numeric|min:0',
'layanan.*.estimasi_selesai' => 'required|numeric|min:1', 'layanan.*.estimasi_selesai' => 'required|numeric|min:1',
@ -519,7 +519,7 @@ public function update(Request $request, Laundry $laundry)
'layanan.required' => 'Minimal harus ada 1 jenis layanan', 'layanan.required' => 'Minimal harus ada 1 jenis layanan',
'layanan.min' => 'Minimal harus ada 1 jenis layanan', 'layanan.min' => 'Minimal harus ada 1 jenis layanan',
'layanan.*.jenis_layanan.required' => 'Jenis layanan harus dipilih', 'layanan.*.jenis_layanan.required' => 'Jenis layanan harus dipilih',
'layanan.*.jenis_layanan.in' => 'Jenis layanan harus express, reguler, atau kilat', 'layanan.*.jenis_layanan.in' => 'Jenis layanan harus express atau reguler',
'layanan.*.nama_paket.required' => 'Nama paket harus diisi', 'layanan.*.nama_paket.required' => 'Nama paket harus diisi',
'layanan.*.harga.required' => 'Harga layanan harus diisi', 'layanan.*.harga.required' => 'Harga layanan harus diisi',
'layanan.*.harga.min' => 'Harga tidak boleh negatif', 'layanan.*.harga.min' => 'Harga tidak boleh negatif',

View File

@ -398,7 +398,6 @@ class="form-control @error('longitude') is-invalid @enderror"
<option value="">-- Pilih Jenis --</option> <option value="">-- Pilih Jenis --</option>
<option value="reguler">🕐 Reguler (Normal)</option> <option value="reguler">🕐 Reguler (Normal)</option>
<option value="express"> Express (Cepat)</option> <option value="express"> Express (Cepat)</option>
<option value="kilat">🚀 Kilat (Super Cepat)</option>
</select> </select>
</div> </div>
@ -546,7 +545,7 @@ class="form-control @error('foto') is-invalid @enderror"
<li>Koordinat akan terisi otomatis saat klik peta</li> <li>Koordinat akan terisi otomatis saat klik peta</li>
<li>Reguler: Layanan normal dengan harga standar</li> <li>Reguler: Layanan normal dengan harga standar</li>
<li>Express: Layanan cepat dengan harga lebih tinggi</li> <li>Express: Layanan cepat dengan harga lebih tinggi</li>
<li>Kilat: Layanan super cepat dengan harga premium</li> <li>Express: Layanan cepat dengan waktu penyelesaian lebih singkat.</li>
</ul> </ul>
</div> </div>
</div> </div>
@ -722,7 +721,6 @@ function(error) {
<option value="">-- Pilih Jenis --</option> <option value="">-- Pilih Jenis --</option>
<option value="reguler">🕐 Reguler (Normal)</option> <option value="reguler">🕐 Reguler (Normal)</option>
<option value="express"> Express (Cepat)</option> <option value="express"> Express (Cepat)</option>
<option value="kilat">🚀 Kilat (Super Cepat)</option>
</select> </select>
</div> </div>

View File

@ -393,7 +393,6 @@ class="form-control"
<option value="">-- Pilih Jenis --</option> <option value="">-- Pilih Jenis --</option>
<option value="reguler" {{ $layanan->jenis_layanan == 'reguler' ? 'selected' : '' }}>🕐 Reguler (Normal)</option> <option value="reguler" {{ $layanan->jenis_layanan == 'reguler' ? 'selected' : '' }}>🕐 Reguler (Normal)</option>
<option value="express" {{ $layanan->jenis_layanan == 'express' ? 'selected' : '' }}> Express (Cepat)</option> <option value="express" {{ $layanan->jenis_layanan == 'express' ? 'selected' : '' }}> Express (Cepat)</option>
<option value="kilat" {{ $layanan->jenis_layanan == 'kilat' ? 'selected' : '' }}>🚀 Kilat (Super Cepat)</option>
</select> </select>
</div> </div>
@ -488,7 +487,6 @@ class="form-control"
<option value="">-- Pilih Jenis --</option> <option value="">-- Pilih Jenis --</option>
<option value="reguler">🕐 Reguler (Normal)</option> <option value="reguler">🕐 Reguler (Normal)</option>
<option value="express"> Express (Cepat)</option> <option value="express"> Express (Cepat)</option>
<option value="kilat">🚀 Kilat (Super Cepat)</option>
</select> </select>
</div> </div>
@ -755,7 +753,6 @@ function updateJarakKampus(lat, lng) {
<option value="">-- Pilih Jenis --</option> <option value="">-- Pilih Jenis --</option>
<option value="reguler">🕐 Reguler (Normal)</option> <option value="reguler">🕐 Reguler (Normal)</option>
<option value="express"> Express (Cepat)</option> <option value="express"> Express (Cepat)</option>
<option value="kilat">🚀 Kilat (Super Cepat)</option>
</select> </select>
</div> </div>

View File

@ -5,7 +5,7 @@
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<application <application
android:label="spk_mobile" android:label="Kontrak Kampus"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true"> android:usesCleartextTraffic="true">

View File

@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Spk Mobile</string> <string>Kontrak Kampus</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@ -13,7 +13,7 @@
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>spk_mobile</string> <string>KontrakKampus</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>

View File

@ -9,7 +9,7 @@ class AppConfig {
// ============================================================================ // ============================================================================
// Default fallback (dipakai jika auto-detect gagal) // Default fallback (dipakai jika auto-detect gagal)
static const String _defaultServer = 'http://192.168.1.154:8000'; static const String _defaultServer = 'http://192.168.18.16:8000';
// Runtime values diupdate otomatis oleh ServerDiscoveryService // Runtime values diupdate otomatis oleh ServerDiscoveryService
static String _serverUrl = _defaultServer; static String _serverUrl = _defaultServer;

View File

@ -8,12 +8,12 @@ class Environment {
// Pilih sesuai platform dan environment saat development // Pilih sesuai platform dan environment saat development
static const String apiBaseUrl = String.fromEnvironment( static const String apiBaseUrl = String.fromEnvironment(
'API_BASE_URL', 'API_BASE_URL',
defaultValue: 'http://192.168.1.154:8000', // Default IP lokal defaultValue: 'http://192.168.18.16:8000', // Default IP lokal
); );
static const String storageBaseUrl = String.fromEnvironment( static const String storageBaseUrl = String.fromEnvironment(
'STORAGE_BASE_URL', 'STORAGE_BASE_URL',
defaultValue: 'http://192.168.1.154:8000/storage', defaultValue: 'http://192.168.18.16:8000/storage',
); );
// Mode debugging // Mode debugging

View File

@ -25,7 +25,7 @@ class MyApp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
title: 'SPK Rekomendasi Kontrakan & Laundry', title: 'Kontrak Kampus',
theme: ThemeData( theme: ThemeData(
colorScheme: ColorScheme.fromSeed( colorScheme: ColorScheme.fromSeed(
seedColor: _primary, seedColor: _primary,
@ -71,7 +71,10 @@ class MyApp extends StatelessWidget {
inputDecorationTheme: InputDecorationTheme( inputDecorationTheme: InputDecorationTheme(
filled: true, filled: true,
fillColor: const Color(0xFFF7F8FC), fillColor: const Color(0xFFF7F8FC),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFFE0E0E0)), borderSide: const BorderSide(color: Color(0xFFE0E0E0)),
@ -120,9 +123,10 @@ class _SplashScreenState extends State<SplashScreen>
vsync: this, vsync: this,
duration: const Duration(milliseconds: 1200), duration: const Duration(milliseconds: 1200),
); );
_fadeAnim = Tween<double>(begin: 0, end: 1).animate( _fadeAnim = Tween<double>(
CurvedAnimation(parent: _animController, curve: Curves.easeOut), begin: 0,
); end: 1,
).animate(CurvedAnimation(parent: _animController, curve: Curves.easeOut));
_scaleAnim = Tween<double>(begin: 0.7, end: 1).animate( _scaleAnim = Tween<double>(begin: 0.7, end: 1).animate(
CurvedAnimation(parent: _animController, curve: Curves.elasticOut), CurvedAnimation(parent: _animController, curve: Curves.elasticOut),
); );
@ -174,11 +178,7 @@ class _SplashScreenState extends State<SplashScreen>
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [ colors: [Color(0xFF1565C0), Color(0xFF0D47A1), Color(0xFF1A237E)],
Color(0xFF1565C0),
Color(0xFF0D47A1),
Color(0xFF1A237E),
],
), ),
), ),
child: Center( child: Center(
@ -210,7 +210,7 @@ class _SplashScreenState extends State<SplashScreen>
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
const Text( const Text(
'SPK Rekomendasi', 'Kontrak Kampus',
style: TextStyle( style: TextStyle(
fontSize: 30, fontSize: 30,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
@ -220,7 +220,7 @@ class _SplashScreenState extends State<SplashScreen>
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Kontrakan & Laundry Terbaik untuk Mahasiswa', 'Rekomendasi Kontrakan & Laundry untuk Mahasiswa',
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
color: Colors.white.withOpacity(0.8), color: Colors.white.withOpacity(0.8),

View File

@ -11,6 +11,7 @@ class Laundry {
final String jamBuka; final String jamBuka;
final String jamTutup; final String jamTutup;
final double hargaPerKg; final double hargaPerKg;
final double hargaKiloan;
final int waktuProses; // dalam jam final int waktuProses; // dalam jam
final String? deskripsi; final String? deskripsi;
final String? fotoUtama; final String? fotoUtama;
@ -31,6 +32,7 @@ class Laundry {
required this.jamBuka, required this.jamBuka,
required this.jamTutup, required this.jamTutup,
required this.hargaPerKg, required this.hargaPerKg,
required this.hargaKiloan,
required this.waktuProses, required this.waktuProses,
this.deskripsi, this.deskripsi,
this.fotoUtama, this.fotoUtama,
@ -53,13 +55,31 @@ class Laundry {
// Get harga from layanan if harga_per_kg is not set directly // Get harga from layanan if harga_per_kg is not set directly
double harga = 0; double harga = 0;
double hargaKiloan = 0;
if (json['harga_per_kg'] != null) { if (json['harga_per_kg'] != null) {
harga = double.tryParse(json['harga_per_kg'].toString()) ?? 0; harga = double.tryParse(json['harga_per_kg'].toString()) ?? 0;
} else if (json['layanan'] != null && }
if (json['harga_kiloan'] != null) {
hargaKiloan = double.tryParse(json['harga_kiloan'].toString()) ?? 0;
}
if ((harga == 0 || hargaKiloan == 0) &&
json['layanan'] != null &&
(json['layanan'] as List).isNotEmpty) { (json['layanan'] as List).isNotEmpty) {
final layananList = json['layanan'] as List; final layananList = json['layanan'] as List;
harga = harga = harga > 0
double.tryParse(layananList.first['harga']?.toString() ?? '0') ?? 0; ? harga
: double.tryParse(layananList.first['harga']?.toString() ?? '0') ?? 0;
for (final item in layananList) {
final jenis = item['jenis_layanan']?.toString().toLowerCase();
final itemHarga =
double.tryParse(item['harga']?.toString() ?? '0') ?? 0;
if (jenis == 'kiloan') {
hargaKiloan = itemHarga;
}
}
} }
// Get foto from API and build gallery list // Get foto from API and build gallery list
@ -68,19 +88,21 @@ class Laundry {
// Parse galeri array if exists // Parse galeri array if exists
if (json['galeri'] != null && (json['galeri'] as List).isNotEmpty) { if (json['galeri'] != null && (json['galeri'] as List).isNotEmpty) {
galleryList.addAll( galleryList.addAll(
(json['galeri'] as List).map((g) => GaleriLaundry.fromJson(g)).toList() (json['galeri'] as List).map((g) => GaleriLaundry.fromJson(g)).toList(),
); );
} }
// If gallery is empty but foto field exists, create a galeri item from it // If gallery is empty but foto field exists, create a galeri item from it
if (galleryList.isEmpty && json['foto'] != null && json['foto'].toString().isNotEmpty) { if (galleryList.isEmpty &&
json['foto'] != null &&
json['foto'].toString().isNotEmpty) {
galleryList.add( galleryList.add(
GaleriLaundry( GaleriLaundry(
id: json['id'] ?? 0, id: json['id'] ?? 0,
foto: json['foto'].toString(), foto: json['foto'].toString(),
isPrimary: true, isPrimary: true,
urutan: 0, urutan: 0,
) ),
); );
} }
@ -99,6 +121,7 @@ class Laundry {
jamBuka: _parseTime(json['jam_buka'], '08:00'), jamBuka: _parseTime(json['jam_buka'], '08:00'),
jamTutup: _parseTime(json['jam_tutup'], '20:00'), jamTutup: _parseTime(json['jam_tutup'], '20:00'),
hargaPerKg: harga, hargaPerKg: harga,
hargaKiloan: hargaKiloan,
waktuProses: json['waktu_proses'] ?? 24, waktuProses: json['waktu_proses'] ?? 24,
deskripsi: json['deskripsi'], deskripsi: json['deskripsi'],
fotoUtama: json['foto_utama'], fotoUtama: json['foto_utama'],
@ -149,13 +172,23 @@ class Laundry {
// Format harga dengan Rupiah // Format harga dengan Rupiah
String get formattedHarga { String get formattedHarga {
return 'Rp ${hargaPerKg.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}'; return 'Rp ${_formatRupiah(hargaPerKg)}';
}
String _formatRupiah(double value) {
return value
.toStringAsFixed(0)
.replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
(Match m) => '${m[1]}.',
);
}
String get formattedHargaKiloan {
final value = hargaKiloan > 0 ? hargaKiloan : hargaPerKg;
return 'Rp ${_formatRupiah(value)}';
} }
// Legacy getters for backward compatibility
String get formattedHargaKiloan => formattedHarga;
String get formattedHargaSatuan => formattedHarga;
double get hargaKiloan => hargaPerKg;
double get jarak => jarakKampus; // alias for jarak field double get jarak => jarakKampus; // alias for jarak field
double get rating => avgRating ?? 0.0; double get rating => avgRating ?? 0.0;
String get estimasiSelesai => '$waktuProses jam'; String get estimasiSelesai => '$waktuProses jam';

View File

@ -6,6 +6,7 @@ import 'package:image_picker/image_picker.dart';
import '../models/kontrakan.dart'; import '../models/kontrakan.dart';
import '../services/booking_service.dart'; import '../services/booking_service.dart';
import '../services/auth_service.dart'; import '../services/auth_service.dart';
import 'improved_home_screen.dart';
class BookingFormScreen extends StatefulWidget { class BookingFormScreen extends StatefulWidget {
final Kontrakan kontrakan; final Kontrakan kontrakan;
@ -23,7 +24,7 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
final _imagePicker = ImagePicker(); final _imagePicker = ImagePicker();
DateTime? _tanggalMulai; DateTime? _tanggalMulai;
int _durasiBulan = 1; int _durasiBulan = 6;
bool _isSubmitting = false; bool _isSubmitting = false;
File? _paymentProofImage; File? _paymentProofImage;
@ -33,7 +34,11 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
decimalDigits: 0, decimalDigits: 0,
); );
double get _totalBiaya => widget.kontrakan.harga * _durasiBulan; // Harga kontrakan disimpan sebagai harga tahunan, jadi total disesuaikan
// proporsional terhadap durasi sewa (6 bulan atau 12 bulan).
double get _totalBiaya => widget.kontrakan.harga * (_durasiBulan / 12);
String get _durasiLabel => _durasiBulan == 12 ? '1 tahun' : '6 bulan';
@override @override
void initState() { void initState() {
@ -194,7 +199,7 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
intl.DateFormat('dd MMMM yyyy', 'id_ID').format(_tanggalMulai!), intl.DateFormat('dd MMMM yyyy', 'id_ID').format(_tanggalMulai!),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildConfirmRow('Durasi', '$_durasiBulan bulan'), _buildConfirmRow('Durasi', _durasiLabel),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildConfirmRow( _buildConfirmRow(
'Total Biaya', 'Total Biaya',
@ -290,7 +295,13 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
child: ElevatedButton( child: ElevatedButton(
onPressed: () { onPressed: () {
Navigator.pop(ctx); // Close dialog Navigator.pop(ctx); // Close dialog
Navigator.pop(context, true); // Go back to detail with result Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
builder: (_) => const ImprovedHomeScreen(),
),
(route) => false,
);
}, },
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF667eea), backgroundColor: const Color(0xFF667eea),
@ -413,7 +424,7 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: Text( child: Text(
'${widget.kontrakan.formattedHarga}/bulan', '${widget.kontrakan.formattedHarga}/tahun',
style: const TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@ -490,7 +501,7 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
_buildFormCard( _buildFormCard(
icon: Icons.access_time, icon: Icons.access_time,
title: 'Durasi Sewa', title: 'Durasi Sewa',
subtitle: 'Tentukan berapa bulan Anda ingin menyewa', subtitle: 'Pilih durasi 6 bulan atau 1 tahun',
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -513,12 +524,16 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
color: Colors.black87, color: Colors.black87,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
items: List.generate(12, (i) => i + 1).map((bulan) { items: const [
return DropdownMenuItem<int>( DropdownMenuItem<int>(
value: bulan, value: 6,
child: Text('$bulan bulan'), child: Text('6 bulan'),
); ),
}).toList(), DropdownMenuItem<int>(
value: 12,
child: Text('1 tahun'),
),
],
onChanged: (val) => onChanged: (val) =>
setState(() => _durasiBulan = val!), setState(() => _durasiBulan = val!),
), ),
@ -669,11 +684,11 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildBiayaRow( _buildBiayaRow(
'Harga per bulan', 'Harga per tahun',
widget.kontrakan.formattedHarga, widget.kontrakan.formattedHarga,
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
_buildBiayaRow('Durasi sewa', '$_durasiBulan bulan'), _buildBiayaRow('Durasi sewa', _durasiLabel),
const Divider(height: 20), const Divider(height: 20),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,

View File

@ -491,7 +491,7 @@ class _FavoritesScreenState extends State<FavoritesScreen>
), ),
), ),
Text( Text(
'/bln', '/thn',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,

View File

@ -845,7 +845,7 @@ class _HomeScreenState extends State<HomeScreen> {
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Text( child: Text(
'Rp ${_formatPrice(kontrakan.harga)}/bln', 'Rp ${_formatPrice(kontrakan.harga)}/thn',
style: const TextStyle( style: const TextStyle(
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,

View File

@ -509,7 +509,7 @@ class _HomeScreenState extends State<HomeScreen> {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
kontrakan.formattedHarga + '/bln', kontrakan.formattedHarga + '/thn',
style: const TextStyle( style: const TextStyle(
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,

View File

@ -281,7 +281,7 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( const Text(
'SPK Rekomendasi', 'Kontrak Kampus',
style: TextStyle( style: TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 22, fontSize: 22,
@ -1088,7 +1088,7 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
), ),
), ),
Text( Text(
'/bln', '/thn',
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,

View File

@ -35,7 +35,8 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
/// Hitung jarak dari Kampus Polije ke kontrakan ini secara langsung. /// Hitung jarak dari Kampus Polije ke kontrakan ini secara langsung.
void _calcDistanceFromPolije() { void _calcDistanceFromPolije() {
if (widget.kontrakan.latitude == null || widget.kontrakan.longitude == null) return; if (widget.kontrakan.latitude == null || widget.kontrakan.longitude == null)
return;
final dist = LocationService.calculateDistance( final dist = LocationService.calculateDistance(
_polije_lat, _polije_lat,
_polije_lng, _polije_lng,
@ -100,7 +101,9 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
content: Text(result['message'] ?? 'Gagal mengubah favorit'), content: Text(result['message'] ?? 'Gagal mengubah favorit'),
backgroundColor: Colors.red[700], backgroundColor: Colors.red[700],
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
margin: const EdgeInsets.all(16), margin: const EdgeInsets.all(16),
), ),
); );
@ -226,7 +229,7 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
), ),
), ),
Text( Text(
' /bulan', ' /tahun',
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@ -478,7 +481,11 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
children: [ children: [
Row( Row(
children: [ children: [
const Icon(Icons.school_rounded, color: Color(0xFF1565C0), size: 22), const Icon(
Icons.school_rounded,
color: Color(0xFF1565C0),
size: 22,
),
const SizedBox(width: 10), const SizedBox(width: 10),
const Text( const Text(
'Jarak dari Kampus Polije', 'Jarak dari Kampus Polije',
@ -521,10 +528,7 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
children: [ children: [
const Text( const Text(
'Politeknik Negeri Jember', 'Politeknik Negeri Jember',
style: TextStyle( style: TextStyle(fontSize: 12, color: Colors.grey),
fontSize: 12,
color: Colors.grey,
),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
@ -588,5 +592,4 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
} }
} }
} }
} }

View File

@ -263,8 +263,8 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
), ),
_buildInfoRow( _buildInfoRow(
Icons.schedule, Icons.schedule,
'Estimasi Selesai', 'Kecepatan Layanan',
'${widget.laundry.estimasiSelesai} jam', widget.laundry.estimasiSelesai,
), ),
], ],
), ),
@ -283,13 +283,6 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
Icons.scale, Icons.scale,
const Color(0xFF00897B), const Color(0xFF00897B),
), ),
const SizedBox(height: 12),
_buildPriceCard(
'Laundry Satuan',
widget.laundry.formattedHargaSatuan,
Icons.checkroom,
Colors.purple,
),
], ],
), ),
), ),

View File

@ -449,37 +449,6 @@ class _LaundryListScreenState extends State<LaundryListScreen> {
), ),
), ),
), ),
const SizedBox(width: 12),
Expanded(
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.purple.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Satuan',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
const SizedBox(height: 4),
Text(
laundry.formattedHargaSatuan,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.purple,
),
),
],
),
),
),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -496,7 +465,7 @@ class _LaundryListScreenState extends State<LaundryListScreen> {
Icon(Icons.schedule, size: 16, color: Colors.orange[700]), Icon(Icons.schedule, size: 16, color: Colors.orange[700]),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
'Selesai dalam ${laundry.estimasiSelesai} jam', 'Selesai dalam ${laundry.estimasiSelesai}',
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,

View File

@ -83,7 +83,7 @@ class _MobileHomeScreenState extends State<MobileHomeScreen> {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Text( const Text(
'SPK Kontrakan', 'Kontrak Kampus',
style: TextStyle( style: TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 18, fontSize: 18,

View File

@ -72,7 +72,8 @@ class _ProfileScreenState extends State<ProfileScreen>
final bookings = await _bookingService.getBookingHistory(); final bookings = await _bookingService.getBookingHistory();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_totalFavorites = (favResult['kontrakan']?.length ?? 0) + _totalFavorites =
(favResult['kontrakan']?.length ?? 0) +
(favResult['laundry']?.length ?? 0); (favResult['laundry']?.length ?? 0);
_totalBookings = bookings.length; _totalBookings = bookings.length;
}); });
@ -105,7 +106,7 @@ class _ProfileScreenState extends State<ProfileScreen>
'September', 'September',
'Oktober', 'Oktober',
'November', 'November',
'Desember' 'Desember',
]; ];
return '${months[date.month]} ${date.year}'; return '${months[date.month]} ${date.year}';
} }
@ -116,7 +117,8 @@ class _ProfileScreenState extends State<ProfileScreen>
backgroundColor: const Color(0xFFF5F6FA), backgroundColor: const Color(0xFFF5F6FA),
body: _isLoading body: _isLoading
? const Center( ? const Center(
child: CircularProgressIndicator(color: Color(0xFF1565C0))) child: CircularProgressIndicator(color: Color(0xFF1565C0)),
)
: RefreshIndicator( : RefreshIndicator(
onRefresh: _loadAll, onRefresh: _loadAll,
color: const Color(0xFF1565C0), color: const Color(0xFF1565C0),
@ -156,8 +158,8 @@ class _ProfileScreenState extends State<ProfileScreen>
final result = await Navigator.push( final result = await Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (_) => EditProfileScreen( builder: (_) =>
user: _currentUser!), EditProfileScreen(user: _currentUser!),
), ),
); );
if (result == true) _loadAll(); if (result == true) _loadAll();
@ -185,7 +187,9 @@ class _ProfileScreenState extends State<ProfileScreen>
// --- Aktivitas --- // --- Aktivitas ---
_buildSectionTitle( _buildSectionTitle(
'Aktivitas', Icons.timeline_rounded), 'Aktivitas',
Icons.timeline_rounded,
),
const SizedBox(height: 10), const SizedBox(height: 10),
_buildMenuCard([ _buildMenuCard([
_buildMenuItem( _buildMenuItem(
@ -225,7 +229,9 @@ class _ProfileScreenState extends State<ProfileScreen>
// --- Lainnya --- // --- Lainnya ---
_buildSectionTitle( _buildSectionTitle(
'Lainnya', Icons.more_horiz_rounded), 'Lainnya',
Icons.more_horiz_rounded,
),
const SizedBox(height: 10), const SizedBox(height: 10),
_buildMenuCard([ _buildMenuCard([
_buildMenuItem( _buildMenuItem(
@ -318,8 +324,7 @@ class _ProfileScreenState extends State<ProfileScreen>
// Title bar // Title bar
const Row( const Row(
children: [ children: [
Icon(Icons.person_rounded, Icon(Icons.person_rounded, color: Colors.white, size: 24),
color: Colors.white, size: 24),
SizedBox(width: 10), SizedBox(width: 10),
Text( Text(
'Profil Saya', 'Profil Saya',
@ -355,10 +360,7 @@ class _ProfileScreenState extends State<ProfileScreen>
decoration: const BoxDecoration( decoration: const BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
gradient: LinearGradient( gradient: LinearGradient(
colors: [ colors: [Colors.white, Color(0xFFBBDEFB)],
Colors.white,
Color(0xFFBBDEFB),
],
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
), ),
@ -412,7 +414,9 @@ class _ProfileScreenState extends State<ProfileScreen>
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4), horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15), color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
@ -420,18 +424,20 @@ class _ProfileScreenState extends State<ProfileScreen>
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.calendar_month_rounded, Icon(
Icons.calendar_month_rounded,
size: 13, size: 13,
color: Colors.white color: Colors.white.withValues(alpha: 0.85),
.withValues(alpha: 0.85)), ),
const SizedBox(width: 5), const SizedBox(width: 5),
Text( Text(
'Bergabung ${_getMemberSince(_currentUser?.createdAt)}', 'Bergabung ${_getMemberSince(_currentUser?.createdAt)}',
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Colors.white color: Colors.white.withValues(
.withValues(alpha: 0.9), alpha: 0.9,
),
), ),
), ),
], ],
@ -454,8 +460,7 @@ class _ProfileScreenState extends State<ProfileScreen>
Widget _headerInfoRow(IconData icon, String text) { Widget _headerInfoRow(IconData icon, String text) {
return Row( return Row(
children: [ children: [
Icon(icon, Icon(icon, size: 14, color: Colors.white.withValues(alpha: 0.75)),
size: 14, color: Colors.white.withValues(alpha: 0.75)),
const SizedBox(width: 6), const SizedBox(width: 6),
Expanded( Expanded(
child: Text( child: Text(
@ -559,11 +564,7 @@ class _ProfileScreenState extends State<ProfileScreen>
} }
Widget _buildStatDivider() { Widget _buildStatDivider() {
return Container( return Container(width: 1, height: 45, color: Colors.grey.shade200);
width: 1,
height: 45,
color: Colors.grey.shade200,
);
} }
// ==================== SECTION TITLE ==================== // ==================== SECTION TITLE ====================
@ -681,8 +682,11 @@ class _ProfileScreenState extends State<ProfileScreen>
color: Colors.grey.shade50, color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Icon(Icons.chevron_right_rounded, child: Icon(
color: Colors.grey.shade400, size: 20), Icons.chevron_right_rounded,
color: Colors.grey.shade400,
size: 20,
),
), ),
], ],
), ),
@ -709,8 +713,7 @@ class _ProfileScreenState extends State<ProfileScreen>
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.logout_rounded, Icon(Icons.logout_rounded, color: Colors.red.shade600, size: 20),
color: Colors.red.shade600, size: 20),
const SizedBox(width: 10), const SizedBox(width: 10),
Text( Text(
'Keluar dari Akun', 'Keluar dari Akun',
@ -749,19 +752,21 @@ class _ProfileScreenState extends State<ProfileScreen>
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: color: const Color(0xFF1565C0).withValues(alpha: 0.3),
const Color(0xFF1565C0).withValues(alpha: 0.3),
blurRadius: 12, blurRadius: 12,
offset: const Offset(0, 4), offset: const Offset(0, 4),
), ),
], ],
), ),
child: const Icon(Icons.home_work_rounded, child: const Icon(
color: Colors.white, size: 40), Icons.home_work_rounded,
color: Colors.white,
size: 40,
),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
const Text( const Text(
'SPK Kontrakan & Laundry', 'Kontrak Kampus',
style: TextStyle( style: TextStyle(
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
@ -771,8 +776,7 @@ class _ProfileScreenState extends State<ProfileScreen>
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Container( Container(
padding: padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF1565C0).withValues(alpha: 0.1), color: const Color(0xFF1565C0).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
@ -805,14 +809,15 @@ class _ProfileScreenState extends State<ProfileScreen>
), ),
child: Column( child: Column(
children: [ children: [
_aboutDetailRow( _aboutDetailRow(Icons.code_rounded, 'Developer', 'Tim TA'),
Icons.code_rounded, 'Developer', 'Tim TA'), const SizedBox(height: 10),
_aboutDetailRow(Icons.school_rounded, 'Institusi', 'Polije'),
const SizedBox(height: 10), const SizedBox(height: 10),
_aboutDetailRow( _aboutDetailRow(
Icons.school_rounded, 'Institusi', 'Polije'), Icons.calendar_today_rounded,
const SizedBox(height: 10), 'Tahun',
_aboutDetailRow( '2025',
Icons.calendar_today_rounded, 'Tahun', '2025'), ),
], ],
), ),
), ),
@ -882,8 +887,11 @@ class _ProfileScreenState extends State<ProfileScreen>
color: Colors.red.shade50, color: Colors.red.shade50,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon(Icons.logout_rounded, child: Icon(
color: Colors.red.shade600, size: 32), Icons.logout_rounded,
color: Colors.red.shade600,
size: 32,
),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
const Text( const Text(
@ -920,8 +928,10 @@ class _ProfileScreenState extends State<ProfileScreen>
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
), ),
child: const Text('Batal', child: const Text(
style: TextStyle(fontWeight: FontWeight.w600)), 'Batal',
style: TextStyle(fontWeight: FontWeight.w600),
),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@ -937,8 +947,10 @@ class _ProfileScreenState extends State<ProfileScreen>
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
), ),
child: const Text('Keluar', child: const Text(
style: TextStyle(fontWeight: FontWeight.w700)), 'Keluar',
style: TextStyle(fontWeight: FontWeight.w700),
),
), ),
), ),
], ],

View File

@ -385,8 +385,8 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final categoryTitle = widget.category == 'kontrakan' final categoryTitle = widget.category == 'kontrakan'
? 'SPK Rekomendasi Kontrakan' ? 'Kontrak Kampus - Rekomendasi Kontrakan'
: 'SPK Rekomendasi Laundry'; : 'Kontrak Kampus - Rekomendasi Laundry';
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF5F5F5), backgroundColor: const Color(0xFFF5F5F5),
@ -660,12 +660,6 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
'icon': Icons.flash_on, 'icon': Icons.flash_on,
'desc': 'Layanan cepat dengan waktu lebih singkat', 'desc': 'Layanan cepat dengan waktu lebih singkat',
}, },
{
'value': 'kilat',
'label': 'Kilat',
'icon': Icons.bolt,
'desc': 'Layanan tercepat, selesai dalam hitungan jam',
},
]; ];
return Container( return Container(
@ -1390,8 +1384,6 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
return 'Reguler'; return 'Reguler';
case 'express': case 'express':
return 'Express'; return 'Express';
case 'kilat':
return 'Kilat';
default: default:
return key; return key;
} }
@ -1704,7 +1696,11 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: widget.category == 'kontrakan' child: widget.category == 'kontrakan'
? KontrakanCard(kontrakan: Kontrakan.fromJson(itemData)) ? KontrakanCard(kontrakan: Kontrakan.fromJson(itemData))
: LaundryCard(laundry: Laundry.fromJson(itemData)), : LaundryCard(
laundry: Laundry.fromJson(itemData),
userLatitude: _userLatitude,
userLongitude: _userLongitude,
),
), ),
], ],
), ),

View File

@ -850,7 +850,7 @@ class _SearchScreenState extends State<SearchScreen> {
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Text( Text(
'${kontrakan.formattedHarga}/bln', '${kontrakan.formattedHarga}/thn',
style: const TextStyle( style: const TextStyle(
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,

View File

@ -14,14 +14,13 @@ class ServerDiscoveryService {
static const int _port = 8000; static const int _port = 8000;
static const String _cacheKey = 'discovered_server_url'; static const String _cacheKey = 'discovered_server_url';
static const Duration _probeTimeout = Duration(seconds: 2); static const Duration _probeTimeout = Duration(seconds: 2);
static const int _scanBatchSize = 24;
// PUBLIC ENTRY POINT // PUBLIC ENTRY POINT
/// Temukan server dan update [AppConfig] secara otomatis. /// Temukan server dan update [AppConfig] secara otomatis.
/// Kembalikan `true` jika server ditemukan, `false` jika tidak. /// Kembalikan `true` jika server ditemukan, `false` jika tidak.
static Future<bool> discover({ static Future<bool> discover({void Function(String status)? onStatus}) async {
void Function(String status)? onStatus,
}) async {
onStatus?.call('Mencari server...'); onStatus?.call('Mencari server...');
// 1. Coba cache terlebih dahulu // 1. Coba cache terlebih dahulu
@ -73,12 +72,12 @@ class ServerDiscoveryService {
try { try {
final response = await http final response = await http
.get( .get(
Uri.parse('$serverUrl/api'), Uri.parse('$serverUrl/api/health'),
headers: {'Accept': 'application/json'}, headers: {'Accept': 'application/json'},
) )
.timeout(_probeTimeout); .timeout(_probeTimeout);
// Respons 4xx/5xx tetap berarti server bisa dijangkau // Pastikan benar-benar backend API aplikasi ini.
return response.statusCode > 0; return response.statusCode == 200;
} catch (_) { } catch (_) {
return false; return false;
} }
@ -110,27 +109,33 @@ class ServerDiscoveryService {
List<String> subnets, { List<String> subnets, {
void Function(String)? onStatus, void Function(String)? onStatus,
}) async { }) async {
final completer = Completer<String?>(); if (subnets.isEmpty) return null;
int remaining = subnets.length * 254;
if (remaining == 0) return null;
// Scan host secara bertahap untuk menghindari "socket flood" di device.
for (final subnet in subnets) { for (final subnet in subnets) {
for (int i = 1; i <= 254; i++) { onStatus?.call('Memindai subnet $subnet.x ...');
for (int start = 1; start <= 254; start += _scanBatchSize) {
final end = (start + _scanBatchSize - 1) > 254
? 254
: (start + _scanBatchSize - 1);
final futures = <Future<String?>>[];
for (int i = start; i <= end; i++) {
final url = 'http://$subnet.$i:$_port'; final url = 'http://$subnet.$i:$_port';
_isAlive(url).then((alive) { futures.add(_isAlive(url).then((alive) => alive ? url : null));
if (alive && !completer.isCompleted) { }
completer.complete(url);
final results = await Future.wait(futures);
for (final found in results) {
if (found != null) {
return found;
} }
remaining--;
if (remaining <= 0 && !completer.isCompleted) {
completer.complete(null);
} }
});
} }
} }
return completer.future; return null;
} }
static Future<String?> _getCachedUrl() async { static Future<String?> _getCachedUrl() async {

View File

@ -191,7 +191,7 @@ class KontrakanCard extends StatelessWidget {
), ),
), ),
Text( Text(
'/bln', '/thn',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: Colors.grey[500], color: Colors.grey[500],

View File

@ -2,12 +2,15 @@ import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import '../models/laundry.dart'; import '../models/laundry.dart';
import '../screens/laundry_detail_screen.dart'; import '../screens/laundry_detail_screen.dart';
import '../services/location_service.dart';
class LaundryCard extends StatelessWidget { class LaundryCard extends StatelessWidget {
final Laundry laundry; final Laundry laundry;
final int? ranking; final int? ranking;
final double? skor; final double? skor;
final bool showRanking; final bool showRanking;
final double? userLatitude;
final double? userLongitude;
const LaundryCard({ const LaundryCard({
super.key, super.key,
@ -15,6 +18,8 @@ class LaundryCard extends StatelessWidget {
this.ranking, this.ranking,
this.skor, this.skor,
this.showRanking = false, this.showRanking = false,
this.userLatitude,
this.userLongitude,
}); });
@override @override
@ -243,7 +248,7 @@ class LaundryCard extends StatelessWidget {
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
'${laundry.jarakKampus.toStringAsFixed(1)} km dari kampus', _distanceText(),
style: const TextStyle( style: const TextStyle(
fontSize: 11, fontSize: 11,
color: Colors.grey, color: Colors.grey,
@ -304,6 +309,26 @@ class LaundryCard extends StatelessWidget {
return const Color(0xFF00897B); return const Color(0xFF00897B);
} }
String _distanceText() {
if (userLatitude != null &&
userLongitude != null &&
laundry.latitude != null &&
laundry.longitude != null) {
final distance = LocationService.calculateDistance(
userLatitude!,
userLongitude!,
laundry.latitude!,
laundry.longitude!,
);
if (distance < 1) {
return '${(distance * 1000).toStringAsFixed(0)} m dari lokasi Anda';
}
return '${distance.toStringAsFixed(1)} km dari lokasi Anda';
}
return '${laundry.jarakKampus.toStringAsFixed(1)} km dari kampus';
}
IconData _getRankingIcon(int ranking) { IconData _getRankingIcon(int ranking) {
if (ranking <= 3) return Icons.emoji_events; if (ranking <= 3) return Icons.emoji_events;
return Icons.star; return Icons.star;