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

View File

@ -167,7 +167,7 @@ public function calculateKontrakan(Request $request)
public function calculateLaundry(Request $request)
{
$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_max' => 'nullable|numeric',
'jarak_max' => 'nullable|numeric',
@ -220,7 +220,8 @@ public function calculateLaundry(Request $request)
// Filter by jenis_layanan if provided
if ($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
*/
private function mapJenisLayananQueryValues($jenisLayanan)
{
$map = [
'reguler' => ['reguler', 'kiloan'],
'express' => ['express', 'satuan'],
];
return $map[$jenisLayanan] ?? [$jenisLayanan];
}
private function getNilaiLaundry($item, $field, $jenisLayanan = null)
{
// Filter layanan by jenis if specified
$layananCollection = $jenisLayanan
? $item->layanan->where('jenis_layanan', $jenisLayanan)
: $item->layanan;
if ($jenisLayanan) {
$filterValues = $this->mapJenisLayananQueryValues($jenisLayanan);
$layananCollection = $item->layanan->whereIn('jenis_layanan', $filterValues);
} else {
$layananCollection = $item->layanan;
}
switch ($field) {
case 'harga':

View File

@ -200,7 +200,7 @@ public function map(Request $request)
'harga_min' => 'nullable|numeric|min:0',
'harga_max' => 'nullable|numeric|min:0|gte:harga_min',
'jarak' => 'nullable|in:dekat,sedang,jauh',
'jenis_layanan' => 'nullable|in:express,reguler,kilat',
'jenis_layanan' => 'nullable|in:express,reguler',
]);
$query = Laundry::with('layanan');
@ -308,7 +308,7 @@ public function store(Request $request)
// Validasi Layanan
'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.*.harga' => 'required|numeric|min:0',
'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.min' => 'Minimal harus ada 1 jenis layanan',
'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.*.harga.required' => 'Harga layanan harus diisi',
'layanan.*.harga.min' => 'Harga tidak boleh negatif',
@ -504,7 +504,7 @@ public function update(Request $request, Laundry $laundry)
// Validasi Layanan
'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.*.harga' => 'required|numeric|min:0',
'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.min' => 'Minimal harus ada 1 jenis layanan',
'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.*.harga.required' => 'Harga layanan harus diisi',
'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="reguler">🕐 Reguler (Normal)</option>
<option value="express"> Express (Cepat)</option>
<option value="kilat">🚀 Kilat (Super Cepat)</option>
</select>
</div>
@ -546,7 +545,7 @@ class="form-control @error('foto') is-invalid @enderror"
<li>Koordinat akan terisi otomatis saat klik peta</li>
<li>Reguler: Layanan normal dengan harga standar</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>
</div>
</div>
@ -722,7 +721,6 @@ function(error) {
<option value="">-- Pilih Jenis --</option>
<option value="reguler">🕐 Reguler (Normal)</option>
<option value="express"> Express (Cepat)</option>
<option value="kilat">🚀 Kilat (Super Cepat)</option>
</select>
</div>

View File

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@ class AppConfig {
// ============================================================================
// 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
static String _serverUrl = _defaultServer;

View File

@ -8,12 +8,12 @@ class Environment {
// Pilih sesuai platform dan environment saat development
static const String apiBaseUrl = String.fromEnvironment(
'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(
'STORAGE_BASE_URL',
defaultValue: 'http://192.168.1.154:8000/storage',
defaultValue: 'http://192.168.18.16:8000/storage',
);
// Mode debugging

View File

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

View File

@ -11,6 +11,7 @@ class Laundry {
final String jamBuka;
final String jamTutup;
final double hargaPerKg;
final double hargaKiloan;
final int waktuProses; // dalam jam
final String? deskripsi;
final String? fotoUtama;
@ -31,6 +32,7 @@ class Laundry {
required this.jamBuka,
required this.jamTutup,
required this.hargaPerKg,
required this.hargaKiloan,
required this.waktuProses,
this.deskripsi,
this.fotoUtama,
@ -53,34 +55,54 @@ class Laundry {
// Get harga from layanan if harga_per_kg is not set directly
double harga = 0;
double hargaKiloan = 0;
if (json['harga_per_kg'] != null) {
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) {
final layananList = json['layanan'] as List;
harga =
double.tryParse(layananList.first['harga']?.toString() ?? '0') ?? 0;
harga = harga > 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
final List<GaleriLaundry> galleryList = [];
// Parse galeri array if exists
if (json['galeri'] != null && (json['galeri'] as List).isNotEmpty) {
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 (galleryList.isEmpty && json['foto'] != null && json['foto'].toString().isNotEmpty) {
if (galleryList.isEmpty &&
json['foto'] != null &&
json['foto'].toString().isNotEmpty) {
galleryList.add(
GaleriLaundry(
id: json['id'] ?? 0,
foto: json['foto'].toString(),
isPrimary: true,
urutan: 0,
)
),
);
}
@ -99,6 +121,7 @@ class Laundry {
jamBuka: _parseTime(json['jam_buka'], '08:00'),
jamTutup: _parseTime(json['jam_tutup'], '20:00'),
hargaPerKg: harga,
hargaKiloan: hargaKiloan,
waktuProses: json['waktu_proses'] ?? 24,
deskripsi: json['deskripsi'],
fotoUtama: json['foto_utama'],
@ -149,13 +172,23 @@ class Laundry {
// Format harga dengan Rupiah
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 rating => avgRating ?? 0.0;
String get estimasiSelesai => '$waktuProses jam';

View File

@ -39,7 +39,7 @@ class User {
if (roleLabel != null && roleLabel!.isNotEmpty) {
return roleLabel!;
}
switch (role) {
case 'super_admin':
return 'Super Admin';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -21,7 +21,7 @@ class KontrakanDetailScreen extends StatefulWidget {
}
class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
double? distance; // jarak dari kampus Polije ke kontrakan ini
double? distance; // jarak dari kampus Polije ke kontrakan ini
bool _isFavorite = false;
bool _isFavLoading = false;
final _favoriteService = FavoriteService();
@ -35,7 +35,8 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
/// Hitung jarak dari Kampus Polije ke kontrakan ini secara langsung.
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(
_polije_lat,
_polije_lng,
@ -100,7 +101,9 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
content: Text(result['message'] ?? 'Gagal mengubah favorit'),
backgroundColor: Colors.red[700],
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
margin: const EdgeInsets.all(16),
),
);
@ -226,7 +229,7 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
),
),
Text(
' /bulan',
' /tahun',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
@ -478,7 +481,11 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
children: [
Row(
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 Text(
'Jarak dari Kampus Polije',
@ -521,18 +528,15 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
children: [
const Text(
'Politeknik Negeri Jember',
style: TextStyle(
fontSize: 12,
color: Colors.grey,
),
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 2),
Text(
distance == null
? '-'
: distance! < 1
? '${(distance! * 1000).toStringAsFixed(0)} meter'
: '${distance!.toStringAsFixed(2)} km',
? '${(distance! * 1000).toStringAsFixed(0)} meter'
: '${distance!.toStringAsFixed(2)} km',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w800,
@ -588,5 +592,4 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
}
}
}
}

View File

@ -263,8 +263,8 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
),
_buildInfoRow(
Icons.schedule,
'Estimasi Selesai',
'${widget.laundry.estimasiSelesai} jam',
'Kecepatan Layanan',
widget.laundry.estimasiSelesai,
),
],
),
@ -283,13 +283,6 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
Icons.scale,
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),
@ -496,7 +465,7 @@ class _LaundryListScreenState extends State<LaundryListScreen> {
Icon(Icons.schedule, size: 16, color: Colors.orange[700]),
const SizedBox(width: 6),
Text(
'Selesai dalam ${laundry.estimasiSelesai} jam',
'Selesai dalam ${laundry.estimasiSelesai}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,

View File

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

View File

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

View File

@ -385,8 +385,8 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
@override
Widget build(BuildContext context) {
final categoryTitle = widget.category == 'kontrakan'
? 'SPK Rekomendasi Kontrakan'
: 'SPK Rekomendasi Laundry';
? 'Kontrak Kampus - Rekomendasi Kontrakan'
: 'Kontrak Kampus - Rekomendasi Laundry';
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
@ -660,12 +660,6 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
'icon': Icons.flash_on,
'desc': 'Layanan cepat dengan waktu lebih singkat',
},
{
'value': 'kilat',
'label': 'Kilat',
'icon': Icons.bolt,
'desc': 'Layanan tercepat, selesai dalam hitungan jam',
},
];
return Container(
@ -1390,8 +1384,6 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
return 'Reguler';
case 'express':
return 'Express';
case 'kilat':
return 'Kilat';
default:
return key;
}
@ -1704,7 +1696,11 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
padding: const EdgeInsets.all(8),
child: widget.category == 'kontrakan'
? 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),
Text(
'${kontrakan.formattedHarga}/bln',
'${kontrakan.formattedHarga}/thn',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,

View File

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

View File

@ -191,7 +191,7 @@ class KontrakanCard extends StatelessWidget {
),
),
Text(
'/bln',
'/thn',
style: TextStyle(
fontSize: 12,
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 '../models/laundry.dart';
import '../screens/laundry_detail_screen.dart';
import '../services/location_service.dart';
class LaundryCard extends StatelessWidget {
final Laundry laundry;
final int? ranking;
final double? skor;
final bool showRanking;
final double? userLatitude;
final double? userLongitude;
const LaundryCard({
super.key,
@ -15,6 +18,8 @@ class LaundryCard extends StatelessWidget {
this.ranking,
this.skor,
this.showRanking = false,
this.userLatitude,
this.userLongitude,
});
@override
@ -243,7 +248,7 @@ class LaundryCard extends StatelessWidget {
),
const SizedBox(width: 4),
Text(
'${laundry.jarakKampus.toStringAsFixed(1)} km dari kampus',
_distanceText(),
style: const TextStyle(
fontSize: 11,
color: Colors.grey,
@ -304,6 +309,26 @@ class LaundryCard extends StatelessWidget {
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) {
if (ranking <= 3) return Icons.emoji_events;
return Icons.star;