feat: modernize mobile UI and normalize laundry service handling

This commit is contained in:
micko samawa 2026-04-16 01:00:10 +07:00
parent a8e72f1203
commit 864be692e8
47 changed files with 5316 additions and 1994 deletions

View File

@ -62,9 +62,6 @@ public function login(LoginRequest $request)
], 401);
}
// Hapus token lama untuk device ini (limit token bloat)
$user->tokens()->where('name', 'mobile-app-token')->delete();
$token = $user->createToken('mobile-app-token')->plainTextToken;
return response()->json([

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',
'jenis_layanan' => 'nullable|string|in:harian,jam,reguler,express,kiloan,satuan,kilat',
'harga_min' => 'nullable|numeric',
'harga_max' => 'nullable|numeric',
'jarak_max' => 'nullable|numeric',
@ -468,8 +468,12 @@ private function prosesMetodeSAWLaundry($items, $kriteria, $customBobot = null,
private function mapJenisLayananQueryValues($jenisLayanan)
{
$map = [
'reguler' => ['reguler', 'kiloan'],
'express' => ['express', 'satuan'],
// New canonical labels for mobile
'harian' => ['harian', 'reguler', 'kiloan'],
'jam' => ['jam', 'express', 'satuan', 'kilat'],
// Backward compatibility for old clients/data
'reguler' => ['harian', 'reguler', 'kiloan'],
'express' => ['jam', 'express', 'satuan', 'kilat'],
];
return $map[$jenisLayanan] ?? [$jenisLayanan];

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
@ -31,10 +32,13 @@ public function index(Request $request)
$files = File::files($this->backupPath);
foreach ($files as $file) {
$modifiedAt = Carbon::createFromTimestamp($file->getMTime(), config('app.timezone'));
$backups[] = [
'name' => $file->getFilename(),
'size' => $file->getSize(),
'date' => $file->getMTime(),
'date' => $modifiedAt->timestamp,
'date_display' => $modifiedAt->format('d M Y H:i'),
'path' => $file->getRealPath(),
];
}

View File

@ -376,6 +376,72 @@ public function destroy(Booking $booking)
return redirect()->route('admin.bookings.index')->with('success', 'Booking berhasil dihapus.');
}
/**
* Hapus booking secara massal (terpilih / semua data sesuai filter)
*/
public function bulkDestroy(Request $request)
{
$request->validate([
'action' => 'required|in:selected,all',
'booking_ids' => 'nullable|array',
'booking_ids.*' => 'integer|exists:bookings,id',
'status' => 'nullable|string',
'kontrakan_id' => 'nullable|integer|exists:kontrakans,id',
]);
$query = Booking::query();
if ($request->action === 'selected') {
$ids = $request->input('booking_ids', []);
if (empty($ids)) {
return back()->with('error', 'Pilih minimal 1 booking yang ingin dihapus.');
}
$query->whereIn('id', $ids);
} else {
if ($request->filled('status')) {
$query->where('status', $request->status);
}
if ($request->filled('kontrakan_id')) {
$query->where('kontrakan_id', $request->kontrakan_id);
}
}
$bookings = $query->get();
if ($bookings->isEmpty()) {
return back()->with('error', 'Tidak ada data booking yang bisa dihapus.');
}
$isSuperAdmin = auth()->user()->role === 'super_admin';
$deletedCount = 0;
$skippedCount = 0;
foreach ($bookings as $booking) {
$canDelete = $isSuperAdmin || in_array($booking->status, [Booking::STATUS_PENDING, Booking::STATUS_CANCELLED]);
if (!$canDelete) {
$skippedCount++;
continue;
}
$booking->delete();
$deletedCount++;
}
if ($deletedCount === 0) {
return back()->with('error', 'Tidak ada booking yang dihapus. Admin biasa hanya boleh menghapus status pending/dibatalkan.');
}
if ($skippedCount > 0) {
return back()->with('success', "Berhasil hapus {$deletedCount} booking. {$skippedCount} booking dilewati karena tidak memiliki izin hapus.");
}
return back()->with('success', "Berhasil hapus {$deletedCount} booking.");
}
/**
* API: Cek ketersediaan kontrakan
*/

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',
'jenis_layanan' => 'nullable|in:jam,harian',
]);
$query = Laundry::with('layanan');
@ -308,10 +308,11 @@ public function store(Request $request)
// Validasi Layanan
'layanan' => 'required|array|min:1',
'layanan.*.jenis_layanan' => 'required|in:express,reguler',
'layanan.*.jenis_layanan' => 'required|in:jam,harian',
'layanan.*.nama_paket' => 'required|string|max:255',
'layanan.*.harga' => 'required|numeric|min:0',
'layanan.*.estimasi_selesai' => 'required|numeric|min:1',
'layanan.*.estimasi_satuan' => 'required|in:jam,harian',
'layanan.*.deskripsi' => 'nullable|string|max:1000',
'layanan.*.status' => 'required|in:aktif,nonaktif',
], [
@ -327,12 +328,14 @@ 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 atau reguler',
'layanan.*.jenis_layanan.in' => 'Jenis layanan harus jam atau harian',
'layanan.*.nama_paket.required' => 'Nama paket harus diisi',
'layanan.*.harga.required' => 'Harga layanan harus diisi',
'layanan.*.harga.min' => 'Harga tidak boleh negatif',
'layanan.*.estimasi_selesai.required' => 'Estimasi waktu selesai harus diisi (jam)',
'layanan.*.estimasi_selesai.min' => 'Estimasi minimal 1 jam',
'layanan.*.estimasi_selesai.required' => 'Estimasi waktu selesai harus diisi',
'layanan.*.estimasi_selesai.min' => 'Estimasi minimal 1',
'layanan.*.estimasi_satuan.required' => 'Satuan estimasi harus dipilih',
'layanan.*.estimasi_satuan.in' => 'Satuan estimasi harus jam atau harian',
'layanan.*.status.required' => 'Status layanan harus dipilih',
'layanan.*.status.in' => 'Status harus aktif atau nonaktif',
]);
@ -405,11 +408,16 @@ public function store(Request $request)
// Simpan Layanan
foreach ($request->layanan as $layananData) {
$estimasiJam = (float) $layananData['estimasi_selesai'];
if (($layananData['estimasi_satuan'] ?? 'jam') === 'harian') {
$estimasiJam *= 24;
}
$laundry->layanan()->create([
'jenis_layanan' => $layananData['jenis_layanan'],
'nama_paket' => $layananData['nama_paket'],
'harga' => $layananData['harga'],
'estimasi_selesai' => $layananData['estimasi_selesai'],
'estimasi_selesai' => max(1, (int) round($estimasiJam)),
'deskripsi' => $layananData['deskripsi'] ?? null,
'status' => $layananData['status'] ?? 'aktif',
]);
@ -504,10 +512,11 @@ public function update(Request $request, Laundry $laundry)
// Validasi Layanan
'layanan' => 'required|array|min:1',
'layanan.*.jenis_layanan' => 'required|in:express,reguler',
'layanan.*.jenis_layanan' => 'required|in:jam,harian',
'layanan.*.nama_paket' => 'required|string|max:255',
'layanan.*.harga' => 'required|numeric|min:0',
'layanan.*.estimasi_selesai' => 'required|numeric|min:1',
'layanan.*.estimasi_satuan' => 'required|in:jam,harian',
'layanan.*.deskripsi' => 'nullable|string|max:1000',
'layanan.*.status' => 'required|in:aktif,nonaktif',
], [
@ -519,12 +528,14 @@ 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 atau reguler',
'layanan.*.jenis_layanan.in' => 'Jenis layanan harus jam atau harian',
'layanan.*.nama_paket.required' => 'Nama paket harus diisi',
'layanan.*.harga.required' => 'Harga layanan harus diisi',
'layanan.*.harga.min' => 'Harga tidak boleh negatif',
'layanan.*.estimasi_selesai.required' => 'Estimasi waktu selesai harus diisi (jam)',
'layanan.*.estimasi_selesai.min' => 'Estimasi minimal 1 jam',
'layanan.*.estimasi_selesai.required' => 'Estimasi waktu selesai harus diisi',
'layanan.*.estimasi_selesai.min' => 'Estimasi minimal 1',
'layanan.*.estimasi_satuan.required' => 'Satuan estimasi harus dipilih',
'layanan.*.estimasi_satuan.in' => 'Satuan estimasi harus jam atau harian',
'layanan.*.status.required' => 'Status layanan harus dipilih',
'layanan.*.status.in' => 'Status harus aktif atau nonaktif',
]);
@ -622,11 +633,16 @@ public function update(Request $request, Laundry $laundry)
$laundry->layanan()->delete();
foreach ($request->layanan as $layananData) {
$estimasiJam = (float) $layananData['estimasi_selesai'];
if (($layananData['estimasi_satuan'] ?? 'jam') === 'harian') {
$estimasiJam *= 24;
}
$laundry->layanan()->create([
'jenis_layanan' => $layananData['jenis_layanan'],
'nama_paket' => $layananData['nama_paket'],
'harga' => $layananData['harga'],
'estimasi_selesai' => $layananData['estimasi_selesai'],
'estimasi_selesai' => max(1, (int) round($estimasiJam)),
'deskripsi' => $layananData['deskripsi'] ?? null,
'status' => $layananData['status'] ?? 'aktif',
]);

View File

@ -178,9 +178,10 @@ private function getData($tipe, $jenisLayanan)
}
$data = Laundry::with('layanan')->get();
$jenisValues = $this->mapLaundryJenisLayananValues($jenisLayanan);
return $data->filter(function($laundry) use ($jenisLayanan) {
return $laundry->layanan->where('jenis_layanan', $jenisLayanan)->isNotEmpty();
return $data->filter(function($laundry) use ($jenisValues) {
return $laundry->layanan->whereIn('jenis_layanan', $jenisValues)->isNotEmpty();
});
} catch (Exception $e) {
@ -192,7 +193,9 @@ private function getData($tipe, $jenisLayanan)
// Proses data dengan hitung fasilitas/jarak
private function processData($data, $tipe, $jenisLayanan, $refLat, $refLng)
{
return $data->map(function($item) use ($tipe, $jenisLayanan, $refLat, $refLng) {
$jenisValues = $this->mapLaundryJenisLayananValues($jenisLayanan);
return $data->map(function($item) use ($tipe, $jenisValues, $refLat, $refLng) {
try {
if ($tipe == 'kontrakan') {
$item->jumlah_fasilitas = $item->fasilitas ? count(explode(',', $item->fasilitas)) : 0;
@ -215,7 +218,7 @@ private function processData($data, $tipe, $jenisLayanan, $refLat, $refLng)
} else {
// LAUNDRY
$layanan = $item->layanan->where('jenis_layanan', $jenisLayanan)->first();
$layanan = $item->layanan->whereIn('jenis_layanan', $jenisValues)->first();
if (!$layanan) {
return null;
@ -249,6 +252,21 @@ private function processData($data, $tipe, $jenisLayanan, $refLat, $refLng)
}
})->filter();
}
// Mapping jenis layanan baru ke nilai lama agar kompatibel dengan data existing.
private function mapLaundryJenisLayananValues($jenisLayanan)
{
$map = [
'harian' => ['harian', 'reguler', 'kiloan'],
'jam' => ['jam', 'express', 'kilat', 'satuan'],
// Backward compatibility
'reguler' => ['harian', 'reguler', 'kiloan'],
'express' => ['jam', 'express', 'kilat', 'satuan'],
'kilat' => ['jam', 'express', 'kilat', 'satuan'],
];
return $map[$jenisLayanan] ?? [$jenisLayanan];
}
// Hitung max/min untuk setiap kriteria
private function calculateMaxMin($dataWithFasilitas, $tipe)

View File

@ -65,7 +65,7 @@
|
*/
'timezone' => 'UTC',
'timezone' => env('APP_TIMEZONE', 'Asia/Jakarta'),
/*
|--------------------------------------------------------------------------

View File

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Normalize legacy layanan types to new canonical values:
* - harian: regular/day package
* - jam: fast/hour package
*/
public function up(): void
{
DB::table('layanan_laundry')
->whereIn('jenis_layanan', ['reguler', 'kiloan'])
->update(['jenis_layanan' => 'harian']);
DB::table('layanan_laundry')
->whereIn('jenis_layanan', ['express', 'kilat', 'satuan'])
->update(['jenis_layanan' => 'jam']);
}
/**
* Revert to closest legacy labels for compatibility.
*/
public function down(): void
{
DB::table('layanan_laundry')
->where('jenis_layanan', 'harian')
->update(['jenis_layanan' => 'reguler']);
DB::table('layanan_laundry')
->where('jenis_layanan', 'jam')
->update(['jenis_layanan' => 'express']);
}
};

View File

@ -32,14 +32,14 @@ public function run(): void
],
'layanan' => [
[
'jenis_layanan' => 'kiloan',
'jenis_layanan' => 'harian',
'nama_paket' => 'Cuci + Setrika',
'harga' => 7000,
'estimasi_selesai' => 24,
'deskripsi' => 'Paket cuci komplit dengan setrika rapi',
],
[
'jenis_layanan' => 'satuan',
'jenis_layanan' => 'jam',
'nama_paket' => 'Setrika Saja',
'harga' => 5000,
'estimasi_selesai' => 12,
@ -61,21 +61,21 @@ public function run(): void
],
'layanan' => [
[
'jenis_layanan' => 'kiloan',
'jenis_layanan' => 'jam',
'nama_paket' => 'Express 6 Jam',
'harga' => 12000,
'estimasi_selesai' => 6,
'deskripsi' => 'Layanan kilat selesai 6 jam',
],
[
'jenis_layanan' => 'kiloan',
'jenis_layanan' => 'harian',
'nama_paket' => 'Reguler',
'harga' => 8000,
'estimasi_selesai' => 24,
'deskripsi' => 'Paket reguler 1 hari selesai',
],
[
'jenis_layanan' => 'satuan',
'jenis_layanan' => 'jam',
'nama_paket' => 'Dry Clean',
'harga' => 25000,
'estimasi_selesai' => 48,
@ -97,14 +97,14 @@ public function run(): void
],
'layanan' => [
[
'jenis_layanan' => 'kiloan',
'jenis_layanan' => 'harian',
'nama_paket' => 'Cuci Lipat',
'harga' => 5000,
'estimasi_selesai' => 48,
'deskripsi' => 'Paket ekonomis cuci dan lipat',
],
[
'jenis_layanan' => 'kiloan',
'jenis_layanan' => 'harian',
'nama_paket' => 'Cuci + Setrika',
'harga' => 6000,
'estimasi_selesai' => 48,
@ -126,14 +126,14 @@ public function run(): void
],
'layanan' => [
[
'jenis_layanan' => 'kiloan',
'jenis_layanan' => 'harian',
'nama_paket' => 'Reguler',
'harga' => 7500,
'estimasi_selesai' => 24,
'deskripsi' => 'Paket standar cuci setrika',
],
[
'jenis_layanan' => 'satuan',
'jenis_layanan' => 'jam',
'nama_paket' => 'Sepatu',
'harga' => 15000,
'estimasi_selesai' => 48,
@ -155,7 +155,7 @@ public function run(): void
],
'layanan' => [
[
'jenis_layanan' => 'kiloan',
'jenis_layanan' => 'harian',
'nama_paket' => 'Paket Mahasiswa',
'harga' => 6000,
'estimasi_selesai' => 36,

View File

@ -370,7 +370,7 @@ private function createLaundryServices($laundry)
{
$services = [
[
'jenis_layanan' => 'kiloan',
'jenis_layanan' => 'harian',
'nama_paket' => 'Cuci Biasa',
'harga' => 5000,
'estimasi_selesai' => 48,
@ -378,7 +378,7 @@ private function createLaundryServices($laundry)
'status' => 'aktif'
],
[
'jenis_layanan' => 'kiloan',
'jenis_layanan' => 'harian',
'nama_paket' => 'Cuci Setrika',
'harga' => 8000,
'estimasi_selesai' => 24,
@ -386,7 +386,7 @@ private function createLaundryServices($laundry)
'status' => 'aktif'
],
[
'jenis_layanan' => 'kiloan',
'jenis_layanan' => 'jam',
'nama_paket' => 'Cuci Express',
'harga' => 12000,
'estimasi_selesai' => 4,
@ -394,7 +394,7 @@ private function createLaundryServices($laundry)
'status' => 'aktif'
],
[
'jenis_layanan' => 'satuan',
'jenis_layanan' => 'jam',
'nama_paket' => 'Setrika Saja',
'harga' => 3000,
'estimasi_selesai' => 12,

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

View File

@ -453,26 +453,66 @@ class="form-control border-start-0 @error('jarak') is-invalid @enderror"
<!-- Fasilitas -->
<div class="col-md-6">
@php
$facilityOptions = [
'WiFi',
'Kasur',
'Lemari',
'Meja Belajar',
'Kamar Mandi Dalam',
'Kamar Mandi Luar',
'Closet Duduk',
'Dapur',
'Dapur Bersama',
'Tempat Cuci Piring',
'Kompor',
'Ruang Tamu',
'Jemuran',
'Parkir Motor',
'Parkir Mobil',
'Garasi',
'Listrik Termasuk',
'Air PDAM/Sumur',
'Keamanan 24 Jam',
'CCTV',
'Akses 24 Jam',
'Musholla',
'Perlengkapan Tidur',
];
$selectedFacilities = collect(explode(',', old('fasilitas', '')))
->map(fn($item) => strtolower(trim($item)))
->filter()
->values()
->all();
@endphp
<label for="fasilitas" class="form-label fw-semibold">
Fasilitas
</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0">
<i class="bi bi-star text-warning"></i>
</span>
<input
type="text"
name="fasilitas"
class="form-control border-start-0 @error('fasilitas') is-invalid @enderror"
id="fasilitas"
placeholder="Contoh: WiFi, AC, Kasur, Lemari"
value="{{ old('fasilitas') }}"
>
<input type="hidden" name="fasilitas" id="fasilitas" value="{{ old('fasilitas') }}">
<div class="border rounded p-2 bg-light @error('fasilitas') border-danger @enderror">
<div class="row g-2">
@foreach($facilityOptions as $facility)
<div class="col-6">
<div class="form-check">
<input
class="form-check-input fasilitas-checkbox"
type="checkbox"
value="{{ $facility }}"
id="fasilitas_{{ $loop->index }}"
{{ in_array(strtolower($facility), $selectedFacilities) ? 'checked' : '' }}
>
<label class="form-check-label small" for="fasilitas_{{ $loop->index }}">
{{ $facility }}
</label>
</div>
</div>
@endforeach
</div>
@error('fasilitas')
<div class="invalid-feedback">{{ $message }}</div>
<div class="text-danger small mt-2">{{ $message }}</div>
@enderror
</div>
<small class="text-muted">Pisahkan dengan koma (,) untuk fasilitas lebih dari satu</small>
<small class="text-muted">Checklist satu atau lebih fasilitas sesuai data kontrakan</small>
</div>
</div>
</div>
@ -932,6 +972,33 @@ function(error) {
}
}
});
// ========== FASILITAS CHECKLIST ==========
const fasilitasHiddenInput = document.getElementById('fasilitas');
const fasilitasCheckboxes = document.querySelectorAll('.fasilitas-checkbox');
let fasilitasTouched = false;
function syncFasilitasChecklist() {
if (!fasilitasHiddenInput || !fasilitasCheckboxes.length) return;
const selectedFacilities = Array.from(fasilitasCheckboxes)
.filter(checkbox => checkbox.checked)
.map(checkbox => checkbox.value.trim())
.filter(Boolean);
fasilitasHiddenInput.value = selectedFacilities.join(', ');
}
if (fasilitasCheckboxes.length) {
fasilitasCheckboxes.forEach(function(checkbox) {
checkbox.addEventListener('change', function() {
fasilitasTouched = true;
syncFasilitasChecklist();
});
});
}
if (fasilitasHiddenInput && !fasilitasHiddenInput.value.trim()) {
syncFasilitasChecklist();
}
// ========== LIVE PREVIEW ==========
const form = document.getElementById('kontrakanForm');
@ -1012,6 +1079,10 @@ function updatePreview() {
// ========== FORM VALIDATION ==========
form.addEventListener('submit', function(e) {
if (fasilitasHiddenInput && (fasilitasTouched || !fasilitasHiddenInput.value.trim())) {
syncFasilitasChecklist();
}
const lat = parseFloat(latInput.value);
const lng = parseFloat(lngInput.value);

View File

@ -481,26 +481,66 @@ class="form-control border-start-0 @error('jarak') is-invalid @enderror"
<!-- Fasilitas -->
<div class="col-md-6">
@php
$facilityOptions = [
'WiFi',
'Kasur',
'Lemari',
'Meja Belajar',
'Kamar Mandi Dalam',
'Kamar Mandi Luar',
'Closet Duduk',
'Dapur',
'Dapur Bersama',
'Tempat Cuci Piring',
'Kompor',
'Ruang Tamu',
'Jemuran',
'Parkir Motor',
'Parkir Mobil',
'Garasi',
'Listrik Termasuk',
'Air PDAM/Sumur',
'Keamanan 24 Jam',
'CCTV',
'Akses 24 Jam',
'Musholla',
'Perlengkapan Tidur',
];
$selectedFacilities = collect(explode(',', old('fasilitas', $kontrakan->fasilitas ?? '')))
->map(fn($item) => strtolower(trim($item)))
->filter()
->values()
->all();
@endphp
<label for="fasilitas" class="form-label fw-semibold">
Fasilitas
</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0">
<i class="bi bi-star text-warning"></i>
</span>
<input
type="text"
name="fasilitas"
class="form-control border-start-0 @error('fasilitas') is-invalid @enderror"
id="fasilitas"
placeholder="Contoh: WiFi, AC, Kasur, Lemari"
value="{{ old('fasilitas', $kontrakan->fasilitas) }}"
>
<input type="hidden" name="fasilitas" id="fasilitas" value="{{ old('fasilitas', $kontrakan->fasilitas) }}">
<div class="border rounded p-2 bg-light @error('fasilitas') border-danger @enderror">
<div class="row g-2">
@foreach($facilityOptions as $facility)
<div class="col-6">
<div class="form-check">
<input
class="form-check-input fasilitas-checkbox"
type="checkbox"
value="{{ $facility }}"
id="fasilitas_{{ $loop->index }}"
{{ in_array(strtolower($facility), $selectedFacilities) ? 'checked' : '' }}
>
<label class="form-check-label small" for="fasilitas_{{ $loop->index }}">
{{ $facility }}
</label>
</div>
</div>
@endforeach
</div>
@error('fasilitas')
<div class="invalid-feedback">{{ $message }}</div>
<div class="text-danger small mt-2">{{ $message }}</div>
@enderror
</div>
<small class="text-muted">Pisahkan dengan koma (,) untuk fasilitas lebih dari satu</small>
<small class="text-muted">Checklist satu atau lebih fasilitas sesuai data kontrakan</small>
</div>
</div>
</div>
@ -991,6 +1031,33 @@ function(error) {
}
}
});
// ========== FASILITAS CHECKLIST ==========
const fasilitasHiddenInput = document.getElementById('fasilitas');
const fasilitasCheckboxes = document.querySelectorAll('.fasilitas-checkbox');
let fasilitasTouched = false;
function syncFasilitasChecklist() {
if (!fasilitasHiddenInput || !fasilitasCheckboxes.length) return;
const selectedFacilities = Array.from(fasilitasCheckboxes)
.filter(checkbox => checkbox.checked)
.map(checkbox => checkbox.value.trim())
.filter(Boolean);
fasilitasHiddenInput.value = selectedFacilities.join(', ');
}
if (fasilitasCheckboxes.length) {
fasilitasCheckboxes.forEach(function(checkbox) {
checkbox.addEventListener('change', function() {
fasilitasTouched = true;
syncFasilitasChecklist();
});
});
}
if (fasilitasHiddenInput && !fasilitasHiddenInput.value.trim()) {
syncFasilitasChecklist();
}
// ========== FOTO PREVIEW ==========
const fotoInput = document.getElementById('foto');
@ -1037,6 +1104,10 @@ function(error) {
const form = document.getElementById('kontrakanForm');
form.addEventListener('submit', function(e) {
if (fasilitasHiddenInput && (fasilitasTouched || !fasilitasHiddenInput.value.trim())) {
syncFasilitasChecklist();
}
const lat = parseFloat(latInput.value);
const lng = parseFloat(lngInput.value);

View File

@ -1497,7 +1497,7 @@ class="btn btn-sm btn-outline-secondary"
const searchSuggestions = document.getElementById('searchSuggestions');
if (searchInput && searchSuggestions) {
const commonSearches = ['WiFi', 'AC', 'Dapur', 'Kamar Mandi Dalam', 'Parkir', 'Listrik', 'Air', 'Dekat Kampus'];
const commonSearches = ['WiFi', 'AC', 'Dapur', 'Tempat Cuci Piring', 'Kamar Mandi Dalam', 'Parkir', 'Listrik', 'Air', 'Dekat Kampus'];
searchInput.addEventListener('input', function() {
const value = this.value.toLowerCase();

View File

@ -297,7 +297,7 @@ class="form-control border-start-0 @error('jam_tutup') is-invalid @enderror"
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>
<strong>Tips:</strong> Klik pada peta untuk menentukan lokasi, atau gunakan tombol "Deteksi Lokasi Saya" untuk mendapatkan koordinat otomatis.
<strong>Tips:</strong> Klik pada peta, ketik manual latitude/longitude, atau gunakan tombol "Deteksi Lokasi Saya" untuk mendapatkan koordinat otomatis.
</div>
<div class="row g-3">
@ -317,7 +317,6 @@ class="form-control @error('latitude') is-invalid @enderror"
id="latitude"
placeholder="-7.7828012"
value="{{ old('latitude') }}"
readonly
required
>
@error('latitude')
@ -342,7 +341,6 @@ class="form-control @error('longitude') is-invalid @enderror"
id="longitude"
placeholder="110.4086598"
value="{{ old('longitude') }}"
readonly
required
>
@error('longitude')
@ -396,8 +394,8 @@ class="form-control @error('longitude') is-invalid @enderror"
</label>
<select name="layanan[0][jenis_layanan]" class="form-select" required>
<option value="">-- Pilih Jenis --</option>
<option value="reguler">🕐 Reguler (Normal)</option>
<option value="express"> Express (Cepat)</option>
<option value="harian">🕐 Harian</option>
<option value="jam"> Jam</option>
</select>
</div>
@ -410,7 +408,7 @@ class="form-control @error('longitude') is-invalid @enderror"
type="text"
name="layanan[0][nama_paket]"
class="form-control"
placeholder="Paket Reguler"
placeholder="Paket Harian"
required
>
</div>
@ -436,16 +434,22 @@ class="form-control"
<!-- Estimasi Selesai -->
<div class="col-md-6">
<label class="form-label fw-semibold">
Estimasi Selesai (Jam) <span class="text-danger">*</span>
Estimasi Selesai <span class="text-danger">*</span>
</label>
<input
type="number"
name="layanan[0][estimasi_selesai]"
class="form-control"
placeholder="24"
min="1"
required
>
<div class="input-group">
<input
type="number"
name="layanan[0][estimasi_selesai]"
class="form-control"
placeholder="1"
min="1"
required
>
<select name="layanan[0][estimasi_satuan]" class="form-select" style="max-width: 140px;" required>
<option value="jam" selected>Jam</option>
<option value="harian">Harian</option>
</select>
</div>
</div>
<!-- Deskripsi -->
@ -543,9 +547,8 @@ class="form-control @error('foto') is-invalid @enderror"
<li>Minimal harus ada 1 jenis layanan</li>
<li>Klik pada peta untuk menentukan lokasi laundry</li>
<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>Express: Layanan cepat dengan waktu penyelesaian lebih singkat.</li>
<li>Harian: Layanan selesai harian</li>
<li>Jam: Layanan selesai dalam hitungan jam</li>
</ul>
</div>
</div>
@ -693,6 +696,44 @@ function(error) {
alert('Browser Anda tidak mendukung Geolocation.');
}
});
// Sinkronkan marker saat koordinat diubah manual
const latInput = document.getElementById('latitude');
const lngInput = document.getElementById('longitude');
function handleManualCoordinateInput() {
const lat = parseFloat(latInput.value);
const lng = parseFloat(lngInput.value);
if (Number.isNaN(lat) || Number.isNaN(lng)) {
return;
}
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) {
return;
}
if (marker) {
marker.setLatLng([lat, lng]);
} else {
marker = L.marker([lat, lng], {
draggable: true
}).addTo(map);
marker.on('dragend', function() {
const position = marker.getLatLng();
updateCoordinates(position.lat, position.lng);
});
}
map.setView([lat, lng], 15);
updateJarakKampus(lat, lng);
}
latInput.addEventListener('change', handleManualCoordinateInput);
lngInput.addEventListener('change', handleManualCoordinateInput);
latInput.addEventListener('blur', handleManualCoordinateInput);
lngInput.addEventListener('blur', handleManualCoordinateInput);
// ========== LAYANAN FUNCTIONALITY ==========
let layananCount = 1;
@ -719,8 +760,8 @@ function(error) {
</label>
<select name="layanan[${layananCount}][jenis_layanan]" class="form-select" required>
<option value="">-- Pilih Jenis --</option>
<option value="reguler">🕐 Reguler (Normal)</option>
<option value="express"> Express (Cepat)</option>
<option value="harian">🕐 Harian</option>
<option value="jam"> Jam</option>
</select>
</div>
@ -732,7 +773,7 @@ function(error) {
type="text"
name="layanan[${layananCount}][nama_paket]"
class="form-control"
placeholder="Paket Reguler"
placeholder="Paket Harian"
required
>
</div>
@ -756,16 +797,22 @@ class="form-control"
<div class="col-md-6">
<label class="form-label fw-semibold">
Estimasi Selesai (Jam) <span class="text-danger">*</span>
Estimasi Selesai <span class="text-danger">*</span>
</label>
<input
type="number"
name="layanan[${layananCount}][estimasi_selesai]"
class="form-control"
placeholder="24"
min="1"
required
>
<div class="input-group">
<input
type="number"
name="layanan[${layananCount}][estimasi_selesai]"
class="form-control"
placeholder="1"
min="1"
required
>
<select name="layanan[${layananCount}][estimasi_satuan]" class="form-select" style="max-width: 140px;" required>
<option value="jam" selected>Jam</option>
<option value="harian">Harian</option>
</select>
</div>
</div>
<div class="col-md-12">

View File

@ -296,7 +296,7 @@ class="form-control border-start-0 @error('jam_tutup') is-invalid @enderror"
<div class="d-flex">
<i class="bi bi-info-circle me-2 mt-1"></i>
<div>
<strong>Tips:</strong> Klik pada peta untuk memperbarui lokasi, atau gunakan tombol "Deteksi Lokasi Saya" untuk mendapatkan koordinat otomatis.
<strong>Tips:</strong> Klik pada peta, ketik manual latitude/longitude, atau gunakan tombol "Deteksi Lokasi Saya" untuk mendapatkan koordinat otomatis.
</div>
</div>
</div>
@ -319,7 +319,6 @@ class="form-control"
placeholder="-6.966667"
value="{{ old('latitude', $laundry->latitude) }}"
required
readonly
>
</div>
</div>
@ -341,7 +340,6 @@ class="form-control"
placeholder="110.416664"
value="{{ old('longitude', $laundry->longitude) }}"
required
readonly
>
</div>
</div>
@ -391,8 +389,8 @@ class="form-control"
</label>
<select name="layanan[{{ $index }}][jenis_layanan]" class="form-select" required>
<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="harian" {{ in_array($layanan->jenis_layanan, ['harian', 'reguler', 'kiloan']) ? 'selected' : '' }}>🕐 Harian</option>
<option value="jam" {{ in_array($layanan->jenis_layanan, ['jam', 'express', 'kilat', 'satuan']) ? 'selected' : '' }}> Jam</option>
</select>
</div>
@ -404,7 +402,7 @@ class="form-control"
type="text"
name="layanan[{{ $index }}][nama_paket]"
class="form-control"
placeholder="Paket Reguler"
placeholder="Paket Harian"
value="{{ $layanan->nama_paket }}"
required
>
@ -429,18 +427,32 @@ class="form-control"
</div>
<div class="col-md-6">
@php
$estimasiValue = (float) $layanan->estimasi_selesai;
$estimasiSatuan = 'jam';
if ($estimasiValue >= 24 && fmod($estimasiValue, 24) == 0.0) {
$estimasiSatuan = 'harian';
$estimasiValue = $estimasiValue / 24;
}
@endphp
<label class="form-label fw-semibold">
Estimasi Selesai (Jam) <span class="text-danger">*</span>
Estimasi Selesai <span class="text-danger">*</span>
</label>
<input
type="number"
name="layanan[{{ $index }}][estimasi_selesai]"
class="form-control"
placeholder="24"
value="{{ $layanan->estimasi_selesai }}"
min="1"
required
>
<div class="input-group">
<input
type="number"
name="layanan[{{ $index }}][estimasi_selesai]"
class="form-control"
placeholder="1"
value="{{ rtrim(rtrim(number_format($estimasiValue, 2, '.', ''), '0'), '.') }}"
min="1"
required
>
<select name="layanan[{{ $index }}][estimasi_satuan]" class="form-select" style="max-width: 140px;" required>
<option value="jam" {{ $estimasiSatuan == 'jam' ? 'selected' : '' }}>Jam</option>
<option value="harian" {{ $estimasiSatuan == 'harian' ? 'selected' : '' }}>Harian</option>
</select>
</div>
</div>
<div class="col-md-12">
@ -485,8 +497,8 @@ class="form-control"
</label>
<select name="layanan[0][jenis_layanan]" class="form-select" required>
<option value="">-- Pilih Jenis --</option>
<option value="reguler">🕐 Reguler (Normal)</option>
<option value="express"> Express (Cepat)</option>
<option value="harian">🕐 Harian</option>
<option value="jam"> Jam</option>
</select>
</div>
@ -516,23 +528,29 @@ class="form-control"
type="text"
name="layanan[0][nama_paket]"
class="form-control"
placeholder="Paket Reguler"
placeholder="Paket Harian"
required
>
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">
Estimasi Selesai (Jam) <span class="text-danger">*</span>
Estimasi Selesai <span class="text-danger">*</span>
</label>
<input
type="number"
name="layanan[0][estimasi_selesai]"
class="form-control"
placeholder="24"
min="1"
required
>
<div class="input-group">
<input
type="number"
name="layanan[0][estimasi_selesai]"
class="form-control"
placeholder="1"
min="1"
required
>
<select name="layanan[0][estimasi_satuan]" class="form-select" style="max-width: 140px;" required>
<option value="jam" selected>Jam</option>
<option value="harian">Harian</option>
</select>
</div>
</div>
<div class="col-md-12">
@ -751,8 +769,8 @@ function updateJarakKampus(lat, lng) {
</label>
<select name="layanan[${layananCount}][jenis_layanan]" class="form-select" required>
<option value="">-- Pilih Jenis --</option>
<option value="reguler">🕐 Reguler (Normal)</option>
<option value="express"> Express (Cepat)</option>
<option value="harian">🕐 Harian</option>
<option value="jam"> Jam</option>
</select>
</div>
@ -764,7 +782,7 @@ function updateJarakKampus(lat, lng) {
type="text"
name="layanan[${layananCount}][nama_paket]"
class="form-control"
placeholder="Paket Reguler"
placeholder="Paket Harian"
required
>
</div>
@ -788,16 +806,22 @@ class="form-control"
<div class="col-md-6">
<label class="form-label fw-semibold">
Estimasi Selesai (Jam) <span class="text-danger">*</span>
Estimasi Selesai <span class="text-danger">*</span>
</label>
<input
type="number"
name="layanan[${layananCount}][estimasi_selesai]"
class="form-control"
placeholder="24"
min="1"
required
>
<div class="input-group">
<input
type="number"
name="layanan[${layananCount}][estimasi_selesai]"
class="form-control"
placeholder="1"
min="1"
required
>
<select name="layanan[${layananCount}][estimasi_satuan]" class="form-select" style="max-width: 140px;" required>
<option value="jam" selected>Jam</option>
<option value="harian">Harian</option>
</select>
</div>
</div>
<div class="col-md-12">
@ -1014,6 +1038,32 @@ function(error) {
alert('Browser Anda tidak mendukung Geolocation.');
}
});
// Sinkronkan marker saat koordinat diubah manual
const latInput = document.getElementById('latitude');
const lngInput = document.getElementById('longitude');
function handleManualCoordinateInput() {
const lat = parseFloat(latInput.value);
const lng = parseFloat(lngInput.value);
if (Number.isNaN(lat) || Number.isNaN(lng)) {
return;
}
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) {
return;
}
marker.setLatLng([lat, lng]);
map.setView([lat, lng], map.getZoom());
updateJarakKampus(lat, lng);
}
latInput.addEventListener('change', handleManualCoordinateInput);
lngInput.addEventListener('change', handleManualCoordinateInput);
latInput.addEventListener('blur', handleManualCoordinateInput);
lngInput.addEventListener('blur', handleManualCoordinateInput);
});
// Modal functions

View File

@ -144,14 +144,11 @@ class="form-control"
</label>
<select name="jenis_layanan" class="form-select">
<option value="">Semua Layanan</option>
<option value="express" {{ ($filters['jenis_layanan'] ?? '') == 'express' ? 'selected' : '' }}>
Express
<option value="jam" {{ ($filters['jenis_layanan'] ?? '') == 'jam' ? 'selected' : '' }}>
Jam
</option>
<option value="reguler" {{ ($filters['jenis_layanan'] ?? '') == 'reguler' ? 'selected' : '' }}>
🕐 Reguler
</option>
<option value="kilat" {{ ($filters['jenis_layanan'] ?? '') == 'kilat' ? 'selected' : '' }}>
🚀 Kilat
<option value="harian" {{ ($filters['jenis_layanan'] ?? '') == 'harian' ? 'selected' : '' }}>
🕐 Harian
</option>
</select>
</div>
@ -323,9 +320,14 @@ function formatRupiah(number) {
if (location.layanan && location.layanan.length > 0) {
layananHtml = location.layanan.map(function(layanan) {
const jenisIcon = {
'jam': '⚡',
'harian': '🕐',
// Backward compatibility for old values
'express': '⚡',
'kilat': '⚡',
'satuan': '⚡',
'reguler': '🕐',
'kilat': '🚀'
'kiloan': '🕐'
};
return `
<div style="display: flex; justify-content: space-between; align-items: center; padding: 8px; background: #f8f9fa; border-radius: 5px; margin-bottom: 5px;">

View File

@ -236,28 +236,20 @@
<i class="bi bi-speedometer2 me-2" style="color: #667eea;"></i>Pilih Jenis Layanan
</h6>
<div class="row g-2 g-md-3">
<div class="col-12 col-md-4">
<input type="radio" class="btn-check" name="jenis_layanan" id="layanan_reguler" value="reguler">
<label class="btn btn-outline-warning w-100 py-2 py-md-3" for="layanan_reguler" style="border-color: #667eea; color: #667eea;">
<div class="col-12 col-md-6">
<input type="radio" class="btn-check" name="jenis_layanan" id="layanan_harian" value="harian">
<label class="btn btn-outline-warning w-100 py-2 py-md-3" for="layanan_harian" style="border-color: #667eea; color: #667eea;">
<i class="bi bi-clock fs-5 fs-md-4 d-block mb-2"></i>
<strong class="small">Reguler</strong>
<small class="d-block text-muted" style="font-size: 0.75rem;">Normal</small>
<strong class="small">Harian</strong>
<small class="d-block text-muted" style="font-size: 0.75rem;">Selesai harian</small>
</label>
</div>
<div class="col-12 col-md-4">
<input type="radio" class="btn-check" name="jenis_layanan" id="layanan_express" value="express">
<label class="btn btn-outline-warning w-100 py-2 py-md-3" for="layanan_express" style="border-color: #667eea; color: #667eea;">
<div class="col-12 col-md-6">
<input type="radio" class="btn-check" name="jenis_layanan" id="layanan_jam" value="jam">
<label class="btn btn-outline-warning w-100 py-2 py-md-3" for="layanan_jam" style="border-color: #667eea; color: #667eea;">
<i class="bi bi-lightning-charge fs-5 fs-md-4 d-block mb-2"></i>
<strong class="small">Express</strong>
<small class="d-block text-muted" style="font-size: 0.75rem;">Cepat</small>
</label>
</div>
<div class="col-12 col-md-4">
<input type="radio" class="btn-check" name="jenis_layanan" id="layanan_kilat" value="kilat">
<label class="btn btn-outline-warning w-100 py-2 py-md-3" for="layanan_kilat" style="border-color: #667eea; color: #667eea;">
<i class="bi bi-rocket-takeoff fs-5 fs-md-4 d-block mb-2"></i>
<strong class="small">Kilat</strong>
<small class="d-block text-muted" style="font-size: 0.75rem;">Super Cepat</small>
<strong class="small">Jam</strong>
<small class="d-block text-muted" style="font-size: 0.75rem;">Selesai per jam</small>
</label>
</div>
</div>

View File

@ -88,7 +88,7 @@
<h6 class="mb-1 fw-semibold">{{ $backup['name'] }}</h6>
<small class="text-muted">
<i class="bi bi-calendar me-1"></i>
{{ date('d M Y H:i', $backup['date']) }}
{{ $backup['date_display'] }}
<span class="mx-2"></span>
<i class="bi bi-file-size me-1"></i>
{{ number_format($backup['size'] / 1024 / 1024, 2) }} MB
@ -145,7 +145,7 @@
</small>
@if(count($backups) > 0)
<small class="text-muted d-block">
<strong>Latest:</strong> {{ date('d M Y H:i', $backups[0]['date']) }}
<strong>Latest:</strong> {{ $backups[0]['date_display'] }}
</small>
@endif
</div>

View File

@ -100,11 +100,34 @@
{{-- Table --}}
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 pb-0">
<form id="bulkDeleteForm" action="{{ route('admin.bookings.bulk-destroy') }}" method="POST" class="d-flex flex-wrap gap-2 align-items-center">
@csrf
<input type="hidden" name="action" id="bulkDeleteAction" value="selected">
<input type="hidden" name="status" value="{{ request('status') }}">
<input type="hidden" name="kontrakan_id" value="{{ request('kontrakan_id') }}">
<button type="button" id="btnDeleteSelected" class="btn btn-danger btn-sm" disabled>
<i class="bi bi-trash me-1"></i>Hapus Terpilih
</button>
@if(auth()->user()->role === 'super_admin')
<button type="button" id="btnDeleteAll" class="btn btn-outline-danger btn-sm">
<i class="bi bi-trash3 me-1"></i>Hapus Semua
</button>
<small class="text-muted">Hapus Semua akan mengikuti filter aktif.</small>
@else
<small class="text-muted">Admin hanya dapat menghapus booking status pending atau dibatalkan.</small>
@endif
</form>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-light">
<tr>
<th class="ps-3" style="width: 44px;">
<input type="checkbox" class="form-check-input" id="selectAllBookings" title="Pilih semua di halaman ini">
</th>
<th class="ps-3">ID</th>
<th>Kontrakan</th>
<th>Penyewa</th>
@ -117,7 +140,13 @@
</thead>
<tbody>
@forelse($bookings as $booking)
@php
$canDeleteBooking = auth()->user()->role === 'super_admin' || in_array($booking->status, ['pending', 'cancelled']);
@endphp
<tr>
<td class="ps-3">
<input type="checkbox" class="form-check-input booking-checkbox" name="booking_ids[]" value="{{ $booking->id }}" form="bulkDeleteForm" {{ $canDeleteBooking ? '' : 'disabled' }}>
</td>
<td class="ps-3">
<span class="badge bg-light text-dark">#{{ $booking->id }}</span>
</td>
@ -188,7 +217,7 @@
</button>
</form>
@endif
@if(auth()->user()->role == 'super_admin' || $booking->status == 'pending')
@if(auth()->user()->role == 'super_admin' || in_array($booking->status, ['pending', 'cancelled']))
<form action="{{ route('admin.bookings.destroy', $booking->id) }}" method="POST" class="d-inline">
@csrf
@method('DELETE')
@ -202,7 +231,7 @@
</tr>
@empty
<tr>
<td colspan="8" class="text-center py-5 text-muted">
<td colspan="9" class="text-center py-5 text-muted">
<i class="bi bi-calendar-x fs-1 d-block mb-2"></i>
Belum ada data booking
</td>
@ -220,3 +249,115 @@
</div>
</div>
@endsection
@section('scripts')
<script>
document.addEventListener('DOMContentLoaded', function () {
const selectAll = document.getElementById('selectAllBookings');
const checkboxes = Array.from(document.querySelectorAll('.booking-checkbox'));
const selectableCheckboxes = checkboxes.filter(item => !item.disabled);
const btnDeleteSelected = document.getElementById('btnDeleteSelected');
const btnDeleteAll = document.getElementById('btnDeleteAll');
const bulkDeleteForm = document.getElementById('bulkDeleteForm');
const bulkDeleteAction = document.getElementById('bulkDeleteAction');
function showWarning(message) {
if (typeof Swal !== 'undefined') {
Swal.fire({
icon: 'warning',
title: 'Perhatian',
text: message,
confirmButtonColor: '#d33'
});
return;
}
alert(message);
}
function confirmDelete(message, onConfirm) {
if (typeof Swal !== 'undefined') {
Swal.fire({
icon: 'warning',
title: 'Konfirmasi Hapus',
text: message,
showCancelButton: true,
confirmButtonText: 'Ya, hapus',
cancelButtonText: 'Batal',
confirmButtonColor: '#d33',
cancelButtonColor: '#6c757d',
reverseButtons: true
}).then((result) => {
if (result.isConfirmed) {
onConfirm();
}
});
return;
}
if (confirm(message)) {
onConfirm();
}
}
function checkedCount() {
return selectableCheckboxes.filter(item => item.checked).length;
}
function refreshSelectedState() {
const count = checkedCount();
btnDeleteSelected.disabled = count === 0;
btnDeleteSelected.innerHTML = count > 0
? `<i class="bi bi-trash me-1"></i>Hapus Terpilih (${count})`
: '<i class="bi bi-trash me-1"></i>Hapus Terpilih';
if (selectAll) {
selectAll.checked = selectableCheckboxes.length > 0 && count === selectableCheckboxes.length;
}
}
if (selectAll) {
selectAll.addEventListener('change', function () {
selectableCheckboxes.forEach(item => {
item.checked = selectAll.checked;
});
refreshSelectedState();
});
}
checkboxes.forEach(item => {
item.addEventListener('change', refreshSelectedState);
});
btnDeleteSelected.addEventListener('click', function () {
const count = checkedCount();
if (count === 0) {
showWarning('Pilih minimal 1 booking yang ingin dihapus.');
return;
}
confirmDelete(`Yakin ingin menghapus ${count} booking terpilih? Tindakan ini tidak dapat dibatalkan.`, function () {
bulkDeleteAction.value = 'selected';
bulkDeleteForm.submit();
});
});
if (btnDeleteAll) {
btnDeleteAll.addEventListener('click', function () {
const filterActive = {!! (request('status') || request('kontrakan_id')) ? 'true' : 'false' !!};
const message = filterActive
? 'Yakin ingin menghapus semua booking sesuai filter saat ini? Tindakan ini tidak dapat dibatalkan.'
: 'Yakin ingin menghapus semua data booking? Tindakan ini tidak dapat dibatalkan.';
confirmDelete(message, function () {
bulkDeleteAction.value = 'all';
bulkDeleteForm.submit();
});
});
}
refreshSelectedState();
});
</script>
@endsection

View File

@ -94,6 +94,7 @@
Route::get('/', [BookingController::class, 'index'])->name('index');
Route::get('/create', [BookingController::class, 'create'])->name('create');
Route::post('/', [BookingController::class, 'store'])->name('store');
Route::post('/bulk-destroy', [BookingController::class, 'bulkDestroy'])->name('bulk-destroy');
Route::get('/{booking}', [BookingController::class, 'show'])->name('show');
Route::get('/{booking}/edit', [BookingController::class, 'edit'])->name('edit');
Route::put('/{booking}', [BookingController::class, 'update'])->name('update');

View File

@ -13,8 +13,7 @@ echo.
REM Dapatkan IP address komputer
echo Mendeteksi IP Address komputer...
for /f "tokens=2 delims=:" %%A in ('ipconfig ^| findstr /i "IPv4" ^| findstr /i "192.168"') do set IP=%%A
set IP=%IP: =%
for /f %%I in ('powershell -NoProfile -Command "(Get-NetIPAddress -AddressFamily IPv4 ^| Where-Object IPAddress -NotLike '127.*' ^| Where-Object IPAddress -NotLike '169.254.*' ^| Where-Object IPAddress -NotLike '192.168.137.*' ^| Select-Object -First 1 -ExpandProperty IPAddress)"') do set IP=%%I
if "%IP%"=="" (
echo ❌ ERROR: Tidak bisa menemukan IP Address!

View File

@ -1,5 +1,3 @@
<?php $__env->startSection('title', 'Kelola Booking'); ?>
<?php $__env->startSection('content'); ?>
@ -103,11 +101,34 @@
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 pb-0">
<form id="bulkDeleteForm" action="<?php echo e(route('admin.bookings.bulk-destroy')); ?>" method="POST" class="d-flex flex-wrap gap-2 align-items-center">
<?php echo csrf_field(); ?>
<input type="hidden" name="action" id="bulkDeleteAction" value="selected">
<input type="hidden" name="status" value="<?php echo e(request('status')); ?>">
<input type="hidden" name="kontrakan_id" value="<?php echo e(request('kontrakan_id')); ?>">
<button type="button" id="btnDeleteSelected" class="btn btn-danger btn-sm" disabled>
<i class="bi bi-trash me-1"></i>Hapus Terpilih
</button>
<?php if(auth()->user()->role === 'super_admin'): ?>
<button type="button" id="btnDeleteAll" class="btn btn-outline-danger btn-sm">
<i class="bi bi-trash3 me-1"></i>Hapus Semua
</button>
<small class="text-muted">Hapus Semua akan mengikuti filter aktif.</small>
<?php else: ?>
<small class="text-muted">Admin hanya dapat menghapus booking status pending atau dibatalkan.</small>
<?php endif; ?>
</form>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-light">
<tr>
<th class="ps-3" style="width: 44px;">
<input type="checkbox" class="form-check-input" id="selectAllBookings" title="Pilih semua di halaman ini">
</th>
<th class="ps-3">ID</th>
<th>Kontrakan</th>
<th>Penyewa</th>
@ -120,7 +141,13 @@
</thead>
<tbody>
<?php $__empty_1 = true; $__currentLoopData = $bookings; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $booking): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); $__empty_1 = false; ?>
<?php
$canDeleteBooking = auth()->user()->role === 'super_admin' || in_array($booking->status, ['pending', 'cancelled']);
?>
<tr>
<td class="ps-3">
<input type="checkbox" class="form-check-input booking-checkbox" name="booking_ids[]" value="<?php echo e($booking->id); ?>" form="bulkDeleteForm" <?php echo e($canDeleteBooking ? '' : 'disabled'); ?>>
</td>
<td class="ps-3">
<span class="badge bg-light text-dark">#<?php echo e($booking->id); ?></span>
</td>
@ -194,7 +221,7 @@
</button>
</form>
<?php endif; ?>
<?php if(auth()->user()->role == 'super_admin' || $booking->status == 'pending'): ?>
<?php if(auth()->user()->role == 'super_admin' || in_array($booking->status, ['pending', 'cancelled'])): ?>
<form action="<?php echo e(route('admin.bookings.destroy', $booking->id)); ?>" method="POST" class="d-inline">
<?php echo csrf_field(); ?>
<?php echo method_field('DELETE'); ?>
@ -208,7 +235,7 @@
</tr>
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); if ($__empty_1): ?>
<tr>
<td colspan="8" class="text-center py-5 text-muted">
<td colspan="9" class="text-center py-5 text-muted">
<i class="bi bi-calendar-x fs-1 d-block mb-2"></i>
Belum ada data booking
</td>
@ -228,4 +255,116 @@
</div>
<?php $__env->stopSection(); ?>
<?php $__env->startSection('scripts'); ?>
<script>
document.addEventListener('DOMContentLoaded', function () {
const selectAll = document.getElementById('selectAllBookings');
const checkboxes = Array.from(document.querySelectorAll('.booking-checkbox'));
const selectableCheckboxes = checkboxes.filter(item => !item.disabled);
const btnDeleteSelected = document.getElementById('btnDeleteSelected');
const btnDeleteAll = document.getElementById('btnDeleteAll');
const bulkDeleteForm = document.getElementById('bulkDeleteForm');
const bulkDeleteAction = document.getElementById('bulkDeleteAction');
function showWarning(message) {
if (typeof Swal !== 'undefined') {
Swal.fire({
icon: 'warning',
title: 'Perhatian',
text: message,
confirmButtonColor: '#d33'
});
return;
}
alert(message);
}
function confirmDelete(message, onConfirm) {
if (typeof Swal !== 'undefined') {
Swal.fire({
icon: 'warning',
title: 'Konfirmasi Hapus',
text: message,
showCancelButton: true,
confirmButtonText: 'Ya, hapus',
cancelButtonText: 'Batal',
confirmButtonColor: '#d33',
cancelButtonColor: '#6c757d',
reverseButtons: true
}).then((result) => {
if (result.isConfirmed) {
onConfirm();
}
});
return;
}
if (confirm(message)) {
onConfirm();
}
}
function checkedCount() {
return selectableCheckboxes.filter(item => item.checked).length;
}
function refreshSelectedState() {
const count = checkedCount();
btnDeleteSelected.disabled = count === 0;
btnDeleteSelected.innerHTML = count > 0
? `<i class="bi bi-trash me-1"></i>Hapus Terpilih (${count})`
: '<i class="bi bi-trash me-1"></i>Hapus Terpilih';
if (selectAll) {
selectAll.checked = selectableCheckboxes.length > 0 && count === selectableCheckboxes.length;
}
}
if (selectAll) {
selectAll.addEventListener('change', function () {
selectableCheckboxes.forEach(item => {
item.checked = selectAll.checked;
});
refreshSelectedState();
});
}
checkboxes.forEach(item => {
item.addEventListener('change', refreshSelectedState);
});
btnDeleteSelected.addEventListener('click', function () {
const count = checkedCount();
if (count === 0) {
showWarning('Pilih minimal 1 booking yang ingin dihapus.');
return;
}
confirmDelete(`Yakin ingin menghapus ${count} booking terpilih? Tindakan ini tidak dapat dibatalkan.`, function () {
bulkDeleteAction.value = 'selected';
bulkDeleteForm.submit();
});
});
if (btnDeleteAll) {
btnDeleteAll.addEventListener('click', function () {
const filterActive = <?php echo (request('status') || request('kontrakan_id')) ? 'true' : 'false'; ?>;
const message = filterActive
? 'Yakin ingin menghapus semua booking sesuai filter saat ini? Tindakan ini tidak dapat dibatalkan.'
: 'Yakin ingin menghapus semua data booking? Tindakan ini tidak dapat dibatalkan.';
confirmDelete(message, function () {
bulkDeleteAction.value = 'all';
bulkDeleteForm.submit();
});
});
}
refreshSelectedState();
});
</script>
<?php $__env->stopSection(); ?>
<?php echo $__env->make('layouts.admin', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?><?php /**PATH C:\laragon\www\TA\spk_kontrakan\resources\views/admin/bookings/index.blade.php ENDPATH**/ ?>

View File

@ -1,62 +0,0 @@
<?php $__env->startSection('title', 'Halaman Tidak Ditemukan'); ?>
<?php $__env->startSection('content'); ?>
<div class="container-fluid px-4">
<div class="row align-items-center justify-content-center min-vh-100">
<div class="col-md-6 col-lg-5">
<div class="text-center">
<div class="mb-4">
<h1 class="display-1 fw-bold" style="color: #f5576c;">404</h1>
<p class="fs-4 fw-semibold mb-2">Halaman Tidak Ditemukan</p>
<p class="text-muted mb-4">
Maaf, halaman yang Anda cari tidak ditemukan.
Mungkin URL sudah berubah atau halaman telah dihapus.
</p>
</div>
<div class="d-flex gap-2 justify-content-center">
<a href="<?php echo e(route('dashboard')); ?>" class="btn btn-primary">
<i class="bi bi-house me-2"></i>Kembali ke Dashboard
</a>
<a href="javascript:history.back()" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Halaman Sebelumnya
</a>
</div>
<div class="mt-5">
<small class="text-muted">
<i class="bi bi-search me-1"></i>
Path: <?php echo e(request()->getPathInfo()); ?>
</small>
</div>
</div>
</div>
</div>
</div>
<style>
body {
background: linear-gradient(135deg, #f5576c 0%, #f093fb 100%);
min-height: 100vh;
display: flex;
align-items: center;
}
.container-fluid {
background: white;
border-radius: 12px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15);
margin: 2rem auto;
}
.display-1 {
font-size: 5rem;
line-height: 1;
}
</style>
<?php $__env->stopSection(); ?>
<?php echo $__env->make('layouts.app', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?><?php /**PATH C:\laragon\www\TA\spk_kontrakan\resources\views/errors/404.blade.php ENDPATH**/ ?>

View File

@ -0,0 +1,176 @@
<?php $__env->startSection('title', 'Backup & Restore Database'); ?>
<?php $__env->startSection('content'); ?>
<div class="container-fluid px-4">
<style>
.backup-card {
border: none;
border-radius: 12px;
box-shadow: 0 4px 15px rgba(0,0,0,0.08);
transition: all 0.3s ease;
}
.backup-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
}
</style>
<!-- Header -->
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 12px; padding: 2rem; color: white; margin-bottom: 2rem;">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
<div>
<h2 class="mb-2">
<i class="bi bi-cloud-arrow-down me-3"></i>Backup & Restore Database
</h2>
<p class="mb-0 fs-6">Kelola backup database untuk keamanan data</p>
</div>
<form action="<?php echo e(route('admin.backup.create')); ?>" method="POST">
<?php echo csrf_field(); ?>
<button type="submit" class="btn btn-light fw-semibold" onclick="return confirm('Mulai backup database?')">
<i class="bi bi-cloud-check me-2"></i>Buat Backup Baru
</button>
</form>
</div>
</div>
<!-- Alert -->
<?php if(session('success')): ?>
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="bi bi-check-circle-fill me-2"></i><?php echo e(session('success')); ?>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<?php endif; ?>
<?php if(session('error')): ?>
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i><?php echo e(session('error')); ?>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<?php endif; ?>
<!-- Info Card -->
<div class="alert alert-info border-0 rounded-3 mb-4">
<i class="bi bi-info-circle me-2"></i>
<strong>Tips:</strong> Lakukan backup secara berkala untuk mengamankan data. Backup dibuat dalam format ZIP dan dapat di-download.
</div>
<!-- Backups List -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-archive me-2"></i>Daftar Backup (<?php echo e(count($backups)); ?>)
</h5>
</div>
<div class="card-body p-0">
<?php if(empty($backups)): ?>
<div class="text-center py-5">
<i class="bi bi-inbox text-muted" style="font-size: 3rem;"></i>
<h5 class="text-muted mt-3">Tidak ada backup ditemukan</h5>
<p class="text-muted mb-4">Klik tombol "Buat Backup Baru" untuk membuat backup pertama Anda</p>
</div>
<?php else: ?>
<div class="row g-3 p-4">
<?php $__currentLoopData = $backups; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $backup): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
<div class="col-12">
<div class="backup-card card">
<div class="card-body p-4">
<div class="row align-items-center">
<div class="col-md-6">
<div class="d-flex align-items-center gap-3">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); width: 50px; height: 50px; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: white;">
<i class="bi bi-file-zip" style="font-size: 1.5rem;"></i>
</div>
<div>
<h6 class="mb-1 fw-semibold"><?php echo e($backup['name']); ?></h6>
<small class="text-muted">
<i class="bi bi-calendar me-1"></i>
<?php echo e($backup['date_display']); ?>
<span class="mx-2"></span>
<i class="bi bi-file-size me-1"></i>
<?php echo e(number_format($backup['size'] / 1024 / 1024, 2)); ?> MB
</small>
</div>
</div>
</div>
<div class="col-md-6">
<div class="d-flex gap-2 justify-content-md-end mt-3 mt-md-0">
<a href="<?php echo e(route('admin.backup.download', $backup['name'])); ?>" class="btn btn-sm btn-primary">
<i class="bi bi-download me-1"></i>Download
</a>
<form action="<?php echo e(route('admin.backup.restore', $backup['name'])); ?>" method="POST" style="display: inline;" onsubmit="return confirm('Restore dari backup ini? Data saat ini akan ditimpa!');">
<?php echo csrf_field(); ?>
<button type="submit" class="btn btn-sm btn-warning">
<i class="bi bi-arrow-counterclockwise me-1"></i>Restore
</button>
</form>
<form action="<?php echo e(route('admin.backup.delete', $backup['name'])); ?>" method="POST" style="display: inline;" onsubmit="return confirm('Hapus backup ini?');">
<?php echo csrf_field(); ?>
<?php echo method_field('DELETE'); ?>
<button type="submit" class="btn btn-sm btn-danger">
<i class="bi bi-trash me-1"></i>Hapus
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); ?>
</div>
<?php endif; ?>
</div>
</div>
<!-- Backup Statistics -->
<div class="row g-3 mt-4">
<div class="col-md-6">
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<h6 class="fw-semibold mb-3">
<i class="bi bi-shield-check me-2" style="color: #667eea;"></i>Backup Information
</h6>
<small class="text-muted d-block mb-2">
<strong>Total Backups:</strong> <?php echo e(count($backups)); ?>
</small>
<small class="text-muted d-block mb-2">
<strong>Total Size:</strong> <?php echo e(number_format(array_sum(array_column($backups, 'size')) / 1024 / 1024, 2)); ?> MB
</small>
<?php if(count($backups) > 0): ?>
<small class="text-muted d-block">
<strong>Latest:</strong> <?php echo e($backups[0]['date_display']); ?>
</small>
<?php endif; ?>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card border-0 shadow-sm border-warning">
<div class="card-body p-4 bg-warning bg-opacity-10">
<h6 class="fw-semibold mb-3">
<i class="bi bi-exclamation-triangle me-2"></i>Reminder
</h6>
<small class="text-muted d-block">
⚠️ Backup database secara rutin (minimal 1x sehari) untuk mencegah kehilangan data yang tidak dapat dipulihkan.
</small>
</div>
</div>
</div>
</div>
</div>
<?php $__env->stopSection(); ?>
<?php echo $__env->make('layouts.admin', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?><?php /**PATH C:\laragon\www\TA\spk_kontrakan\resources\views/admin/backup/index.blade.php ENDPATH**/ ?>

View File

@ -1,5 +1,3 @@
<?php $__env->startSection('title', 'Kelola Kriteria SAW'); ?>
<?php $__env->startSection('content'); ?>

View File

@ -1,5 +1,3 @@
<?php $__env->startSection('title', 'Daftar Kontrakan'); ?>
<?php $__env->startSection('content'); ?>
@ -1508,7 +1506,7 @@ class="btn btn-sm btn-outline-secondary"
const searchSuggestions = document.getElementById('searchSuggestions');
if (searchInput && searchSuggestions) {
const commonSearches = ['WiFi', 'AC', 'Dapur', 'Kamar Mandi Dalam', 'Parkir', 'Listrik', 'Air', 'Dekat Kampus'];
const commonSearches = ['WiFi', 'AC', 'Dapur', 'Tempat Cuci Piring', 'Kamar Mandi Dalam', 'Parkir', 'Listrik', 'Air', 'Dekat Kampus'];
searchInput.addEventListener('input', function() {
const value = this.value.toLowerCase();

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,3 @@
<?php $__env->startSection('title', 'Kelola User'); ?>
<?php $__env->startSection('content'); ?>

View File

@ -1,131 +0,0 @@
<!-- Toast Notification Component -->
<div id="toastContainer" class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 9999;">
</div>
<style>
.toast-container {
pointer-events: none;
}
.toast {
pointer-events: auto;
min-width: 300px;
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15);
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.toast.hide {
animation: slideOut 0.3s ease-in forwards;
}
@keyframes slideOut {
to {
transform: translateX(400px);
opacity: 0;
}
}
.toast-success {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.toast-error {
background: linear-gradient(135deg, #f5576c 0%, #f093fb 100%);
color: white;
}
.toast-warning {
background: linear-gradient(135deg, #ffa502 0%, #ffb84d 100%);
color: white;
}
.toast-info {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
}
.toast .toast-header {
background: transparent;
border: none;
color: white;
padding: 0;
}
.toast .toast-body {
padding: 0;
font-size: 0.95rem;
}
</style>
<script>
function showToast(message, type = 'success', duration = 4000) {
const container = document.getElementById('toastContainer');
const toastId = 'toast-' + Date.now();
const icons = {
'success': 'bi-check-circle-fill',
'error': 'bi-exclamation-triangle-fill',
'warning': 'bi-exclamation-circle-fill',
'info': 'bi-info-circle-fill'
};
const html = `
<div id="${toastId}" class="toast show toast-${type}" role="alert">
<div class="d-flex align-items-center p-3">
<i class="bi ${icons[type] || icons.info} me-2"></i>
<span class="flex-grow-1">${message}</span>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast"></button>
</div>
</div>
`;
container.insertAdjacentHTML('beforeend', html);
const toastElement = document.getElementById(toastId);
if (duration > 0) {
setTimeout(() => {
toastElement.classList.add('hide');
setTimeout(() => toastElement.remove(), 300);
}, duration);
}
// Close button
toastElement.querySelector('.btn-close').addEventListener('click', () => {
toastElement.classList.add('hide');
setTimeout(() => toastElement.remove(), 300);
});
}
// Auto-show session toasts
document.addEventListener('DOMContentLoaded', function() {
<?php if(session('success')): ?>
showToast("<?php echo e(session('success')); ?>", 'success');
<?php endif; ?>
<?php if(session('error')): ?>
showToast("<?php echo e(session('error')); ?>", 'error');
<?php endif; ?>
<?php if(session('warning')): ?>
showToast("<?php echo e(session('warning')); ?>", 'warning');
<?php endif; ?>
<?php if(session('info')): ?>
showToast("<?php echo e(session('info')); ?>", 'info');
<?php endif; ?>
});
</script>
<?php /**PATH C:\laragon\www\TA\spk_kontrakan\resources\views/components/toast-notification.blade.php ENDPATH**/ ?>

View File

@ -1,5 +1,3 @@
<?php $__env->startSection('title', 'Daftar Laundry'); ?>
<?php $__env->startSection('content'); ?>

File diff suppressed because it is too large Load Diff

View File

@ -21,6 +21,8 @@ class MyApp extends StatelessWidget {
static const _primary = Color(0xFF1565C0);
static const _primaryDark = Color(0xFF0D47A1);
static const _accent = Color(0xFF00897B);
static const _surfaceTint = Color(0xFFF3F7FB);
@override
Widget build(BuildContext context) {
@ -30,12 +32,31 @@ class MyApp extends StatelessWidget {
colorScheme: ColorScheme.fromSeed(
seedColor: _primary,
primary: _primary,
secondary: _primaryDark,
secondary: _accent,
surface: Colors.white,
brightness: Brightness.light,
),
useMaterial3: true,
scaffoldBackgroundColor: const Color(0xFFF7F8FC),
scaffoldBackgroundColor: _surfaceTint,
canvasColor: _surfaceTint,
splashFactory: InkSparkle.splashFactory,
textTheme: const TextTheme(
titleLarge: TextStyle(
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
color: Color(0xFF1B2A41),
),
titleMedium: TextStyle(
fontWeight: FontWeight.w600,
letterSpacing: 0.15,
color: Color(0xFF23324D),
),
bodyLarge: TextStyle(
fontWeight: FontWeight.w500,
color: Color(0xFF2B3A55),
),
bodyMedium: TextStyle(color: Color(0xFF3B4A64)),
),
appBarTheme: const AppBarTheme(
backgroundColor: _primary,
foregroundColor: Colors.white,
@ -53,10 +74,14 @@ class MyApp extends StatelessWidget {
borderRadius: BorderRadius.circular(16),
),
color: Colors.white,
shadowColor: Colors.black.withOpacity(0.05),
margin: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
elevation: 0,
backgroundColor: _primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 24),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
@ -68,31 +93,151 @@ class MyApp extends StatelessWidget {
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: _primary,
side: const BorderSide(color: Color(0xFFD2DFEF)),
padding: const EdgeInsets.symmetric(vertical: 13, horizontal: 18),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: _primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
textStyle: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
progressIndicatorTheme: const ProgressIndicatorThemeData(
color: _primary,
linearTrackColor: Color(0xFFE2EAF3),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: const Color(0xFFF7F8FC),
fillColor: const Color(0xFFFAFCFF),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFFE0E0E0)),
borderSide: const BorderSide(color: Color(0xFFE2EAF3)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFFE0E0E0)),
borderSide: const BorderSide(color: Color(0xFFE2EAF3)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: _primary, width: 1.5),
),
hintStyle: const TextStyle(
color: Color(0xFF8D9BB0),
fontWeight: FontWeight.w500,
),
),
chipTheme: ChipThemeData(
backgroundColor: const Color(0xFFE9F1FB),
selectedColor: const Color(0xFF1565C0),
secondarySelectedColor: const Color(0xFF1565C0),
disabledColor: const Color(0xFFE2E8F0),
labelStyle: const TextStyle(
color: Color(0xFF34435E),
fontWeight: FontWeight.w600,
),
secondaryLabelStyle: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
),
side: const BorderSide(color: Color(0xFFD6E2F1)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
backgroundColor: const Color(0xFF1C2A44),
contentTextStyle: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
dialogTheme: DialogThemeData(
backgroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
bottomSheetTheme: const BottomSheetThemeData(
backgroundColor: Colors.white,
modalBackgroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: Colors.white,
selectedItemColor: _primary,
unselectedItemColor: Color(0xFF94A0B2),
type: BottomNavigationBarType.fixed,
elevation: 8,
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: Colors.white,
indicatorColor: _primary.withOpacity(0.12),
labelTextStyle: WidgetStateProperty.resolveWith((states) {
final selected = states.contains(WidgetState.selected);
return TextStyle(
fontSize: 12,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: selected ? _primary : const Color(0xFF94A0B2),
);
}),
iconTheme: WidgetStateProperty.resolveWith((states) {
final selected = states.contains(WidgetState.selected);
return IconThemeData(
color: selected ? _primary : const Color(0xFF94A0B2),
size: 24,
);
}),
),
listTileTheme: const ListTileThemeData(
iconColor: Color(0xFF5A6B85),
textColor: Color(0xFF24344D),
tileColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
dividerTheme: const DividerThemeData(
color: Color(0xFFEEEEEE),
color: Color(0xFFE8EDF4),
thickness: 1,
space: 0,
),
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.android: FadeForwardsPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
TargetPlatform.windows: FadeUpwardsPageTransitionsBuilder(),
TargetPlatform.macOS: FadeUpwardsPageTransitionsBuilder(),
TargetPlatform.linux: FadeUpwardsPageTransitionsBuilder(),
},
),
),
home: const SplashScreen(),
debugShowCheckedModeBanner: false,

View File

@ -17,6 +17,7 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
final _bookingService = BookingService();
final _imagePicker = ImagePicker();
late TabController _tabController;
int _currentTabIndex = 0;
List<Booking> _activeBookings = [];
List<Booking> _pastBookings = [];
@ -27,11 +28,20 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_tabController.addListener(_handleTabChange);
_loadBookings();
}
void _handleTabChange() {
if (!mounted) return;
if (_currentTabIndex != _tabController.index) {
setState(() => _currentTabIndex = _tabController.index);
}
}
@override
void dispose() {
_tabController.removeListener(_handleTabChange);
_tabController.dispose();
super.dispose();
}
@ -333,13 +343,28 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
),
),
const SizedBox(width: 14),
const Text(
'Booking Saya',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: Colors.white,
letterSpacing: 0.3,
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Booking Saya',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: Colors.white,
letterSpacing: 0.3,
),
),
SizedBox(height: 2),
Text(
'Pantau status aktif dan riwayat booking',
style: TextStyle(
fontSize: 13,
color: Colors.white70,
),
),
],
),
),
],
@ -353,35 +378,7 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
borderRadius: BorderRadius.circular(14),
),
padding: const EdgeInsets.all(4),
child: TabBar(
controller: _tabController,
indicator: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(11),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
dividerColor: Colors.transparent,
labelColor: const Color(0xFF1565C0),
unselectedLabelColor: Colors.white.withOpacity(0.85),
labelStyle: const TextStyle(
fontWeight: FontWeight.w700,
fontSize: 14,
),
unselectedLabelStyle: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14,
),
tabs: const [
Tab(text: 'Aktif'),
Tab(text: 'Riwayat'),
],
),
child: _buildSegmentedTabs(),
),
],
),
@ -953,4 +950,90 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
(Match m) => '${m[1]}.',
);
}
Widget _buildSegmentedTabs() {
return Row(
children: [
Expanded(
child: _buildSegmentItem(
index: 0,
icon: Icons.bolt_rounded,
label: 'Aktif',
count: _activeBookings.length,
),
),
const SizedBox(width: 8),
Expanded(
child: _buildSegmentItem(
index: 1,
icon: Icons.history_rounded,
label: 'Riwayat',
count: _pastBookings.length,
),
),
],
);
}
Widget _buildSegmentItem({
required int index,
required IconData icon,
required String label,
required int count,
}) {
final selected = _currentTabIndex == index;
return InkWell(
borderRadius: BorderRadius.circular(11),
onTap: () {
_tabController.animateTo(index);
setState(() => _currentTabIndex = index);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOut,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: selected ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(11),
boxShadow: selected
? [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 6,
offset: const Offset(0, 2),
),
]
: null,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 17,
color: selected
? const Color(0xFF1565C0)
: Colors.white.withOpacity(0.9),
),
const SizedBox(width: 6),
Flexible(
child: Text(
'$label ($count)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
fontSize: 13,
color: selected
? const Color(0xFF1565C0)
: Colors.white.withOpacity(0.9),
),
),
),
],
),
),
);
}
}

View File

@ -18,6 +18,7 @@ class _FavoritesScreenState extends State<FavoritesScreen>
with SingleTickerProviderStateMixin {
final _favoriteService = FavoriteService();
late TabController _tabController;
int _currentTabIndex = 0;
List<Kontrakan> _kontrakanFavorites = [];
List<Laundry> _laundryFavorites = [];
@ -27,11 +28,20 @@ class _FavoritesScreenState extends State<FavoritesScreen>
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_tabController.addListener(_handleTabChange);
_loadFavorites();
}
void _handleTabChange() {
if (!mounted) return;
if (_currentTabIndex != _tabController.index) {
setState(() => _currentTabIndex = _tabController.index);
}
}
@override
void dispose() {
_tabController.removeListener(_handleTabChange);
_tabController.dispose();
super.dispose();
}
@ -242,56 +252,7 @@ class _FavoritesScreenState extends State<FavoritesScreen>
borderRadius: BorderRadius.circular(14),
),
padding: const EdgeInsets.all(4),
child: TabBar(
controller: _tabController,
indicator: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(11),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
dividerColor: Colors.transparent,
labelColor: const Color(0xFF1565C0),
unselectedLabelColor: Colors.white.withOpacity(0.85),
labelStyle: const TextStyle(
fontWeight: FontWeight.w700,
fontSize: 14,
),
unselectedLabelStyle: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14,
),
tabs: [
Tab(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.home_work_rounded, size: 18),
const SizedBox(width: 6),
Text('Kontrakan (${_kontrakanFavorites.length})'),
],
),
),
Tab(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.local_laundry_service_rounded,
size: 18,
),
const SizedBox(width: 6),
Text('Laundry (${_laundryFavorites.length})'),
],
),
),
],
),
child: _buildSegmentedTabs(),
),
],
),
@ -786,6 +747,92 @@ class _FavoritesScreenState extends State<FavoritesScreen>
);
}
Widget _buildSegmentedTabs() {
return Row(
children: [
Expanded(
child: _buildSegmentItem(
index: 0,
icon: Icons.home_work_rounded,
label: 'Kontrakan',
count: _kontrakanFavorites.length,
),
),
const SizedBox(width: 8),
Expanded(
child: _buildSegmentItem(
index: 1,
icon: Icons.local_laundry_service_rounded,
label: 'Laundry',
count: _laundryFavorites.length,
),
),
],
);
}
Widget _buildSegmentItem({
required int index,
required IconData icon,
required String label,
required int count,
}) {
final selected = _currentTabIndex == index;
return InkWell(
borderRadius: BorderRadius.circular(11),
onTap: () {
_tabController.animateTo(index);
setState(() => _currentTabIndex = index);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOut,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: selected ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(11),
boxShadow: selected
? [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 6,
offset: const Offset(0, 2),
),
]
: null,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 17,
color: selected
? const Color(0xFF1565C0)
: Colors.white.withOpacity(0.9),
),
const SizedBox(width: 6),
Flexible(
child: Text(
'$label ($count)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
fontSize: 13,
color: selected
? const Color(0xFF1565C0)
: Colors.white.withOpacity(0.9),
),
),
),
],
),
),
);
}
Widget _buildEmptyState({
required IconData icon,
required String title,

View File

@ -233,133 +233,179 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
slivers: [
// Gradient Header
SliverAppBar(
expandedHeight: 190,
expandedHeight: 210,
floating: false,
pinned: true,
backgroundColor: const Color(0xFF1565C0),
elevation: 0,
flexibleSpace: FlexibleSpaceBar(
background: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFF1565C0),
Color(0xFF0D47A1),
Color(0xFF1A237E),
],
background: Stack(
children: [
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFF1565C0),
Color(0xFF0D47A1),
Color(0xFF0A2E73),
],
),
),
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.15),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: Colors.white.withOpacity(0.2),
Positioned(
top: -30,
right: -20,
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.09),
shape: BoxShape.circle,
),
),
),
Positioned(
bottom: 30,
left: -26,
child: Container(
width: 90,
height: 90,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.07),
shape: BoxShape.circle,
),
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.15),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: Colors.white.withOpacity(0.2),
),
),
child: const Icon(
Icons.school_rounded,
color: Colors.white,
size: 26,
),
),
child: const Icon(
Icons.school_rounded,
color: Colors.white,
size: 26,
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Kontrak Kampus',
style: TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w800,
letterSpacing: 0.3,
),
),
const SizedBox(height: 3),
Text(
'Halo, ${_authService.currentUser?.name ?? "User"} 👋',
style: TextStyle(
color: Colors.white.withOpacity(0.85),
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
],
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.12),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.white.withOpacity(0.18),
),
),
child: IconButton(
icon: const Icon(
Icons.logout_rounded,
color: Colors.white,
size: 22,
),
onPressed: _handleLogout,
),
),
],
),
const SizedBox(height: 18),
GestureDetector(
onTap: () => setState(() => _selectedIndex = 1),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 13,
),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.94),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.white),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
const Text(
'Kontrak Kampus',
style: TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w800,
letterSpacing: 0.3,
Icon(
Icons.search_rounded,
color: Colors.grey[500],
size: 22,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Cari kontrakan atau laundry...',
style: TextStyle(
color: Colors.grey[500],
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 3),
Text(
'Halo, ${_authService.currentUser?.name ?? "User"} 👋',
style: TextStyle(
color: Colors.white.withOpacity(0.85),
fontSize: 14,
fontWeight: FontWeight.w400,
Container(
padding: const EdgeInsets.all(7),
decoration: BoxDecoration(
color: const Color(0xFFE3F2FD),
borderRadius: BorderRadius.circular(9),
),
child: const Icon(
Icons.north_east_rounded,
size: 14,
color: Color(0xFF1565C0),
),
),
],
),
),
Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.12),
borderRadius: BorderRadius.circular(12),
),
child: IconButton(
icon: const Icon(
Icons.logout_rounded,
color: Colors.white,
size: 22,
),
onPressed: _handleLogout,
),
),
],
),
const SizedBox(height: 18),
GestureDetector(
onTap: () => setState(() => _selectedIndex = 1),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 13,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
Icon(
Icons.search_rounded,
color: Colors.grey[400],
size: 22,
),
const SizedBox(width: 12),
Text(
'Cari kontrakan atau laundry...',
style: TextStyle(
color: Colors.grey[400],
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
],
),
),
),
],
],
),
),
),
),
],
),
),
),
@ -373,12 +419,12 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF667EEA), Color(0xFF764BA2)],
colors: [Color(0xFF0F9D8A), Color(0xFF0A7F8C)],
),
borderRadius: BorderRadius.circular(18),
boxShadow: [
BoxShadow(
color: const Color(0xFF667EEA).withOpacity(0.3),
color: const Color(0xFF0F9D8A).withOpacity(0.32),
blurRadius: 16,
offset: const Offset(0, 6),
),
@ -1399,7 +1445,14 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFEEEEEE)),
border: Border.all(color: const Color(0xFFEDF2F8)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.035),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: [

View File

@ -126,6 +126,13 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
}
}
String _formatDistanceValue(double km) {
if (km < 1) {
return '${(km * 1000).toStringAsFixed(0)} m';
}
return '${km.toStringAsFixed(2)} km';
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -207,6 +214,11 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
color: Color(0xFF1A1A2E),
),
),
const SizedBox(height: 6),
Text(
'Hunian nyaman dekat kampus untuk kebutuhan harian Anda',
style: TextStyle(fontSize: 13, color: Colors.grey[600]),
),
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.symmetric(
@ -229,7 +241,7 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
),
),
Text(
' /tahun',
' /thn',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
@ -241,11 +253,43 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_buildSummaryChip(
icon: widget.kontrakan.isAvailable
? Icons.check_circle_rounded
: Icons.do_not_disturb_on_rounded,
label: widget.kontrakan.isAvailable
? 'Tersedia'
: 'Penuh',
color: widget.kontrakan.isAvailable
? const Color(0xFF2E7D32)
: const Color(0xFFE65100),
),
_buildSummaryChip(
icon: Icons.bed_rounded,
label: '${widget.kontrakan.jumlahKamar} kamar',
color: const Color(0xFF1565C0),
),
_buildSummaryChip(
icon: Icons.near_me_rounded,
label: _formatDistanceValue(
widget.kontrakan.jarakKampus,
),
color: const Color(0xFF00897B),
),
],
),
const SizedBox(height: 16),
// Location
_buildInfoRow(Icons.location_on, widget.kontrakan.alamat),
_buildInfoRow(
Icons.directions_walk,
'${widget.kontrakan.jarakKampus} km dari kampus',
'${_formatDistanceValue(widget.kontrakan.jarakKampus)} dari kampus',
),
_buildInfoRow(
Icons.bed,
@ -274,29 +318,54 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
Wrap(
spacing: 8,
runSpacing: 8,
children: widget.kontrakan.fasilitasList.map((f) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: const Color(0xFFE3F2FD),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: const Color(0xFF1565C0).withOpacity(0.15),
),
),
child: Text(
f,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Color(0xFF1565C0),
),
),
);
}).toList(),
children: widget.kontrakan.fasilitasList.isEmpty
? [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
decoration: BoxDecoration(
color: const Color(0xFFF3F7FB),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: const Color(0xFFDDE7F3),
),
),
child: const Text(
'Fasilitas belum tersedia',
style: TextStyle(
fontSize: 13,
color: Color(0xFF5A6B85),
),
),
),
]
: widget.kontrakan.fasilitasList.map((f) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: const Color(0xFFE3F2FD),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: const Color(
0xFF1565C0,
).withOpacity(0.15),
),
),
child: Text(
f,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Color(0xFF1565C0),
),
),
);
}).toList(),
),
if (widget.kontrakan.deskripsi != null) ...[
@ -368,7 +437,7 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
Icon(Icons.chat_rounded, size: 20),
SizedBox(width: 6),
Text(
'WhatsApp',
'Chat Pemilik',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
@ -420,8 +489,8 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
),
child: Text(
widget.kontrakan.isAvailable
? 'Booking Sekarang'
: 'Tidak Tersedia',
? 'Ajukan Booking'
: 'Sedang Penuh',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
@ -465,6 +534,36 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
);
}
Widget _buildSummaryChip({
required IconData icon,
required String label,
required Color color,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: color.withOpacity(0.22)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 5),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: color,
),
),
],
),
);
}
Widget _buildLocationCard() {
return Container(
padding: const EdgeInsets.all(16),

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/laundry.dart';
import '../services/location_service.dart';
@ -145,87 +146,143 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
),
],
flexibleSpace: FlexibleSpaceBar(
background: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF00897B), Color(0xFF00695C)],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: const Icon(
Icons.local_laundry_service,
size: 40,
color: Color(0xFF00897B),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.laundry.nama,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.star,
size: 20,
color: Colors.amber[300],
),
const SizedBox(width: 6),
Text(
widget.laundry.rating.toStringAsFixed(
1,
),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(
alpha: 0.95,
),
),
),
],
),
],
),
),
],
background: Stack(
fit: StackFit.expand,
children: [
CachedNetworkImage(
imageUrl: widget.laundry.primaryPhoto,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF00897B), Color(0xFF00695C)],
),
],
),
),
errorWidget: (context, url, error) => Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF00897B), Color(0xFF00695C)],
),
),
),
),
),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withOpacity(0.2),
Colors.black.withOpacity(0.55),
],
),
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(
alpha: 0.2,
),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: const Icon(
Icons.local_laundry_service,
size: 40,
color: Color(0xFF00897B),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.laundry.nama,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 2),
Text(
'Layanan laundry cepat dan praktis',
style: TextStyle(
fontSize: 12,
color: Colors.white.withOpacity(0.9),
),
),
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.star,
size: 20,
color: Colors.amber[300],
),
const SizedBox(width: 6),
Text(
widget.laundry.rating.toStringAsFixed(
1,
),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(
alpha: 0.95,
),
),
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_buildHeaderChip(
icon: Icons.payments_rounded,
label:
'${widget.laundry.formattedHargaKiloan}/kg',
),
_buildHeaderChip(
icon: Icons.schedule_rounded,
label: widget.laundry.estimasiSelesai,
),
],
),
],
),
),
],
),
],
),
),
),
],
),
),
),
@ -289,7 +346,8 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
const SizedBox(height: 24),
// Action Buttons
if (widget.laundry.noWhatsapp != null) ...[
if (widget.laundry.noWhatsapp != null &&
widget.laundry.noWhatsapp!.isNotEmpty) ...[
SizedBox(
width: double.infinity,
height: 50,
@ -298,7 +356,7 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
_launchWhatsApp(widget.laundry.noWhatsapp!),
icon: const Icon(Icons.message, size: 24),
label: const Text(
'Hubungi via WhatsApp',
'Chat Laundry',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
@ -328,7 +386,7 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
),
icon: const Icon(Icons.map, size: 24),
label: const Text(
'Lihat di Maps',
'Buka Maps',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
@ -411,10 +469,10 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.purple.withValues(alpha: 0.1),
color: const Color(0xFF00897B).withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.purple.withValues(alpha: 0.3),
color: const Color(0xFF00897B).withValues(alpha: 0.26),
width: 1,
),
),
@ -426,14 +484,18 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
children: [
Row(
children: [
Icon(Icons.location_on, color: Colors.purple, size: 24),
const Icon(
Icons.location_on,
color: Color(0xFF00897B),
size: 24,
),
const SizedBox(width: 12),
Text(
const Text(
'Deteksi Lokasi Saya',
style: const TextStyle(
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black87,
color: Color(0xFF1A1A2E),
),
),
],
@ -444,7 +506,9 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.purple),
valueColor: const AlwaysStoppedAnimation<Color>(
Color(0xFF00897B),
),
),
),
],
@ -522,7 +586,7 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.purple,
backgroundColor: const Color(0xFF00897B),
foregroundColor: Colors.white,
disabledBackgroundColor: Colors.grey,
shape: RoundedRectangleBorder(
@ -536,6 +600,33 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
);
}
Widget _buildHeaderChip({required IconData icon, required String label}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withValues(alpha: 0.28)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(width: 1),
Icon(icon, size: 13, color: Colors.white),
const SizedBox(width: 5),
Text(
label,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
);
}
Future<void> _detectLocation() async {
setState(() {
isLoadingLocation = true;

View File

@ -2,12 +2,14 @@ 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';
@ -41,7 +43,7 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
int _bobotKriteria4 = 15; // fasilitas (kontrakan) or layanan (laundry)
// Jenis layanan selection for laundry
String _selectedJenisLayanan = 'reguler';
String _selectedJenisLayanan = 'harian';
// Location values untuk referensi jarak (deteksi lokasi user)
double? _userLatitude;
@ -57,8 +59,8 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
_bobotHarga + _bobotJarak + _bobotKriteria3 + _bobotKriteria4;
Color get _categoryColor => widget.category == 'kontrakan'
? const Color(0xFF667eea)
: const Color(0xFF764ba2);
? const Color(0xFF1565C0)
: const Color(0xFF00897B);
@override
void initState() {
@ -180,6 +182,69 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
return List.generate(((max - 10) ~/ 5) + 1, (i) => 10 + i * 5);
}
int _closestOption(List<int> options, int target) {
return options.reduce(
(a, b) => (a - target).abs() <= (b - target).abs() ? a : b,
);
}
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(
@ -285,9 +350,11 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
debugPrint('SAW API Error: $e');
debugPrint('URL: ${AppConfig.baseUrl}$endpoint');
if (!mounted) return;
final canReset = _isConnectivityError(e);
setState(() {
_errorMessage =
'Tidak dapat terhubung ke server (${AppConfig.baseUrl}). Periksa koneksi internet Anda dan coba lagi.\n\nDetail: $e';
_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 {
@ -385,11 +452,11 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
@override
Widget build(BuildContext context) {
final categoryTitle = widget.category == 'kontrakan'
? 'Kontrak Kampus - Rekomendasi Kontrakan'
: 'Kontrak Kampus - Rekomendasi Laundry';
? 'Rekomendasi Kontrakan'
: 'Rekomendasi Laundry';
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
backgroundColor: const Color(0xFFF3F7FB),
appBar: AppBar(
backgroundColor: _categoryColor,
foregroundColor: Colors.white,
@ -425,6 +492,8 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
children: [
_buildUserInfoCard(),
const SizedBox(height: 16),
_buildSimpleStepCard(),
const SizedBox(height: 16),
_buildMethodInfoCard(),
const SizedBox(height: 16),
if (widget.category == 'laundry') ...[
@ -444,6 +513,78 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
);
}
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 ?? '';
@ -584,6 +725,13 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
}
Widget _buildMethodInfoCard() {
final kriteria3 = widget.category == 'kontrakan'
? 'jumlah kamar'
: 'kecepatan layanan';
final kriteria4 = widget.category == 'kontrakan'
? 'fasilitas'
: 'variasi layanan';
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
@ -607,7 +755,7 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
Icon(Icons.analytics, color: Colors.white, size: 24),
SizedBox(width: 8),
Text(
'Metode SAW',
'Cara Rekomendasi Bekerja',
style: TextStyle(
color: Colors.white,
fontSize: 18,
@ -618,7 +766,7 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
),
const SizedBox(height: 8),
Text(
'Simple Additive Weighting (SAW) menghitung skor rekomendasi berdasarkan bobot kriteria yang Anda tentukan.',
'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,
@ -626,19 +774,74 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Vi = Σ(Wj × Rij)',
style: TextStyle(
color: Colors.white.withOpacity(0.95),
fontSize: 14,
fontWeight: FontWeight.w600,
fontFamily: 'monospace',
),
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,
),
),
],
@ -649,16 +852,16 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
Widget _buildJenisLayananSection() {
final jenisOptions = [
{
'value': 'reguler',
'label': 'Reguler',
'icon': Icons.schedule,
'desc': 'Layanan standar dengan harga terjangkau',
'value': 'harian',
'label': 'Harian',
'icon': Icons.today,
'desc': 'Paket selesai harian dengan biaya lebih hemat',
},
{
'value': 'express',
'label': 'Express',
'icon': Icons.flash_on,
'desc': 'Layanan cepat dengan waktu lebih singkat',
'value': 'jam',
'label': 'Jam',
'icon': Icons.schedule,
'desc': 'Paket selesai dalam hitungan jam (lebih cepat)',
},
];
@ -852,6 +1055,8 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
'Bobot otomatis disesuaikan agar total selalu 100%',
style: TextStyle(fontSize: 11, color: Colors.grey[500]),
),
const SizedBox(height: 10),
_buildPriorityGuide(),
const SizedBox(height: 16),
_buildBobotDropdown(
label: 'Harga',
@ -930,6 +1135,93 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
);
}
Widget _buildPriorityGuide() {
final items = [
{
'label': 'Rendah',
'range': '10-20%',
'desc': 'Pengaruh kecil pada hasil.',
'color': const Color(0xFF8E8E93),
},
{
'label': 'Sedang',
'range': '25-35%',
'desc': 'Cukup penting.',
'color': const Color(0xFF00A389),
},
{
'label': 'Tinggi',
'range': '40-50%',
'desc': 'Sangat berpengaruh pada hasil.',
'color': const Color(0xFF2F80ED),
},
{
'label': 'Prioritas',
'range': '55-70%',
'desc': 'Faktor utama penentu hasil.',
'color': const Color(0xFF7B61FF),
},
];
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.blueGrey.withOpacity(0.06),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.blueGrey.withOpacity(0.16)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Arti level prioritas',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: Colors.grey[800],
),
),
const SizedBox(height: 8),
...items.map((item) {
final color = item['color'] as Color;
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
margin: const EdgeInsets.only(top: 2),
width: 8,
height: 8,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Expanded(
child: RichText(
text: TextSpan(
style: TextStyle(fontSize: 11, color: Colors.grey[700]),
children: [
TextSpan(
text: '${item['label']} (${item['range']}): ',
style: const TextStyle(fontWeight: FontWeight.w700),
),
TextSpan(text: item['desc'] as String),
],
),
),
),
],
),
);
}),
],
),
);
}
Widget _buildBobotDropdown({
required String label,
required IconData icon,
@ -940,12 +1232,26 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
required ValueChanged<int?> onChanged,
}) {
final isCost = tipe.toLowerCase() == 'cost';
// Ensure current value is in options list
final safeValue = options.contains(value)
? value
: options.reduce(
(a, b) => (a - value).abs() < (b - value).abs() ? a : b,
);
: _closestOption(options, value);
final priorityColor = _getPriorityColor(safeValue);
final quickTargets = [
{'label': 'Rendah', 'value': 15},
{'label': 'Sedang', 'value': 25},
{'label': 'Tinggi', 'value': 40},
{'label': 'Prioritas', 'value': 55},
];
final quickPresets = <Map<String, dynamic>>[];
final seenValues = <int>{};
for (final item in quickTargets) {
final mapped = _closestOption(options, item['value'] as int);
if (seenValues.add(mapped)) {
quickPresets.add({'label': item['label'], 'value': mapped});
}
}
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
@ -953,96 +1259,162 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey[200]!),
),
child: Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _categoryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, size: 20, color: _categoryColor),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.grey[800],
),
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _categoryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
const SizedBox(height: 2),
Row(
child: Icon(icon, size: 20, color: _categoryColor),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 1,
),
decoration: BoxDecoration(
color: isCost
? Colors.orange.withOpacity(0.15)
: Colors.green.withOpacity(0.15),
borderRadius: BorderRadius.circular(4),
),
child: Text(
tipe,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: isCost
? Colors.orange[800]
: Colors.green[800],
),
Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.grey[800],
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
tipeDesc,
style: TextStyle(fontSize: 10, color: Colors.grey[500]),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 1,
),
decoration: BoxDecoration(
color: isCost
? Colors.orange.withOpacity(0.15)
: Colors.green.withOpacity(0.15),
borderRadius: BorderRadius.circular(4),
),
child: Text(
isCost ? 'Minimalkan' : 'Utamakan',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: isCost
? Colors.orange[800]
: Colors.green[800],
),
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
tipeDesc,
style: TextStyle(
fontSize: 10,
color: Colors.grey[500],
),
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
],
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: _categoryColor.withOpacity(0.3)),
),
child: Text(
'$safeValue%',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: _categoryColor,
),
),
),
],
),
const SizedBox(height: 12),
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(safeValue)}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: priorityColor,
),
),
),
const Spacer(),
Text(
'${options.first}% - ${options.last}%',
style: TextStyle(fontSize: 11, color: Colors.grey[500]),
),
],
),
SliderTheme(
data: SliderTheme.of(context).copyWith(
activeTrackColor: _categoryColor,
inactiveTrackColor: _categoryColor.withOpacity(0.2),
thumbColor: _categoryColor,
overlayColor: _categoryColor.withOpacity(0.15),
trackHeight: 4,
),
child: Slider(
min: options.first.toDouble(),
max: options.last.toDouble(),
divisions: options.length > 1 ? options.length - 1 : null,
value: safeValue.toDouble(),
label: '$safeValue%',
onChanged: (double newValue) {
onChanged(_closestOption(options, newValue.round()));
},
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: _categoryColor.withOpacity(0.3)),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: safeValue,
isDense: true,
icon: Icon(Icons.arrow_drop_down, color: _categoryColor),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: _categoryColor,
Wrap(
spacing: 8,
runSpacing: 8,
children: quickPresets.map((preset) {
final presetValue = preset['value'] as int;
final selected = presetValue == safeValue;
return ChoiceChip(
label: Text('${preset['label']} ($presetValue%)'),
selected: selected,
onSelected: (_) => onChanged(presetValue),
selectedColor: _categoryColor.withOpacity(0.15),
side: BorderSide(
color: selected
? _categoryColor.withOpacity(0.5)
: Colors.grey[300]!,
),
items: options
.map(
(int val) => DropdownMenuItem<int>(
value: val,
child: Text('$val%'),
),
)
.toList(),
onChanged: onChanged,
),
),
labelStyle: TextStyle(
fontSize: 11,
color: selected ? _categoryColor : Colors.grey[700],
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
),
);
}).toList(),
),
],
),
@ -1199,7 +1571,7 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
),
SizedBox(width: 12),
Text(
'Menghitung SAW...',
'Menyiapkan rekomendasi...',
style: TextStyle(
color: Colors.white,
fontSize: 16,
@ -1325,13 +1697,13 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
const SizedBox(height: 8),
Row(
children: [
_buildBobotMiniChip('Harga', _bobotHarga, 'Cost'),
_buildBobotMiniChip('Harga', _bobotHarga, true),
const SizedBox(width: 6),
_buildBobotMiniChip('Jarak', _bobotJarak, 'Cost'),
_buildBobotMiniChip('Jarak', _bobotJarak, true),
const SizedBox(width: 6),
_buildBobotMiniChip(_kriteria3Label, _bobotKriteria3, 'Benefit'),
_buildBobotMiniChip(_kriteria3Label, _bobotKriteria3, false),
const SizedBox(width: 6),
_buildBobotMiniChip(_kriteria4Label, _bobotKriteria4, 'Benefit'),
_buildBobotMiniChip(_kriteria4Label, _bobotKriteria4, false),
],
),
],
@ -1339,8 +1711,7 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
);
}
Widget _buildBobotMiniChip(String label, int value, String tipe) {
final isCost = tipe == 'Cost';
Widget _buildBobotMiniChip(String label, int value, bool isCost) {
return Expanded(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4),
@ -1380,10 +1751,14 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
String _getJenisLayananLabel(String key) {
switch (key) {
case 'harian':
return 'Harian';
case 'jam':
return 'Jam';
case 'reguler':
return 'Reguler';
return 'Harian';
case 'express':
return 'Express';
return 'Jam';
default:
return key;
}
@ -1397,7 +1772,7 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Menghitung rekomendasi SAW...'),
Text('Sedang menyiapkan rekomendasi terbaik...'),
],
),
);
@ -1425,7 +1800,7 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
? (widget.category == 'kontrakan'
? 'Belum Ada Kontrakan Tersedia'
: 'Belum Ada Laundry Tersedia')
: 'Tidak Ada Hasil',
: 'Belum Ada Hasil yang Cocok',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
@ -1440,18 +1815,40 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
),
const SizedBox(height: 24),
if (!_noData)
ElevatedButton.icon(
onPressed: () => setState(() {
_hasCalculated = false;
_errorMessage = null;
_noData = false;
}),
icon: const Icon(Icons.tune),
label: const Text('Ubah Bobot Kriteria'),
style: ElevatedButton.styleFrom(
backgroundColor: _categoryColor,
foregroundColor: Colors.white,
),
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(

View File

@ -250,7 +250,11 @@ class _SearchScreenState extends State<SearchScreen> {
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF1565C0), Color(0xFF0D47A1)],
colors: [
Color(0xFF1565C0),
Color(0xFF0D47A1),
Color(0xFF0A2E73),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
@ -276,20 +280,36 @@ class _SearchScreenState extends State<SearchScreen> {
),
),
const SizedBox(width: 14),
const Text(
'Cari & Jelajahi',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: Colors.white,
letterSpacing: 0.3,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Cari & Jelajahi',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: Colors.white,
letterSpacing: 0.3,
),
),
Text(
'Temukan kontrakan dan laundry lebih cepat',
style: TextStyle(
fontSize: 12,
color: Colors.white.withOpacity(0.85),
),
),
],
),
),
const Spacer(),
Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.12),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.white.withOpacity(0.2),
),
),
child: IconButton(
icon: const Icon(
@ -314,104 +334,30 @@ class _SearchScreenState extends State<SearchScreen> {
child: Row(
children: [
Expanded(
child: GestureDetector(
child: _buildCategorySegment(
label: 'Kontrakan',
icon: Icons.home_work_rounded,
selected: _selectedCategory == 'Kontrakan',
activeColor: const Color(0xFF1565C0),
count: _filteredKontrakan.length,
onTap: () {
setState(() => _selectedCategory = 'Kontrakan');
_applyFilters();
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: _selectedCategory == 'Kontrakan'
? Colors.white
: Colors.transparent,
borderRadius: BorderRadius.circular(11),
boxShadow: _selectedCategory == 'Kontrakan'
? [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 4,
offset: const Offset(0, 2),
),
]
: null,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.home_work_rounded,
color: _selectedCategory == 'Kontrakan'
? const Color(0xFF1565C0)
: Colors.white.withOpacity(0.85),
size: 18,
),
const SizedBox(width: 8),
Text(
'Kontrakan',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: _selectedCategory == 'Kontrakan'
? const Color(0xFF1565C0)
: Colors.white.withOpacity(0.85),
),
),
],
),
),
),
),
const SizedBox(width: 4),
Expanded(
child: GestureDetector(
child: _buildCategorySegment(
label: 'Laundry',
icon: Icons.local_laundry_service_rounded,
selected: _selectedCategory == 'Laundry',
activeColor: const Color(0xFF00897B),
count: _filteredLaundry.length,
onTap: () {
setState(() => _selectedCategory = 'Laundry');
_applyLaundryFilters();
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: _selectedCategory == 'Laundry'
? Colors.white
: Colors.transparent,
borderRadius: BorderRadius.circular(11),
boxShadow: _selectedCategory == 'Laundry'
? [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 4,
offset: const Offset(0, 2),
),
]
: null,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.local_laundry_service_rounded,
color: _selectedCategory == 'Laundry'
? const Color(0xFF00897B)
: Colors.white.withOpacity(0.85),
size: 18,
),
const SizedBox(width: 8),
Text(
'Laundry',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: _selectedCategory == 'Laundry'
? const Color(0xFF00897B)
: Colors.white.withOpacity(0.85),
),
),
],
),
),
),
),
],
@ -482,27 +428,72 @@ class _SearchScreenState extends State<SearchScreen> {
// Results Count
if (!_isLoading)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
color: Colors.white,
child: Row(
children: [
Icon(Icons.filter_list, size: 18, color: Colors.grey[600]),
const SizedBox(width: 8),
Text(
_selectedCategory == 'Kontrakan'
? 'Ditemukan ${_filteredKontrakan.length} kontrakan'
: 'Ditemukan ${_filteredLaundry.length} laundry',
style: TextStyle(
fontSize: 14,
color: Colors.grey[700],
fontWeight: FontWeight.w600,
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 6),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFEAF0F6)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 8,
offset: const Offset(0, 3),
),
),
],
],
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(7),
decoration: BoxDecoration(
color: const Color(0xFF1565C0).withOpacity(0.1),
borderRadius: BorderRadius.circular(9),
),
child: const Icon(
Icons.filter_alt_rounded,
size: 16,
color: Color(0xFF1565C0),
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
_selectedCategory == 'Kontrakan'
? 'Ditemukan ${_filteredKontrakan.length} kontrakan'
: 'Ditemukan ${_filteredLaundry.length} laundry',
style: TextStyle(
fontSize: 14,
color: Colors.grey[700],
fontWeight: FontWeight.w600,
),
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
decoration: BoxDecoration(
color: const Color(0xFFF1F5FB),
borderRadius: BorderRadius.circular(20),
),
child: Text(
_selectedFilter,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Color(0xFF4B5A70),
),
),
),
],
),
),
),
@ -643,6 +634,62 @@ class _SearchScreenState extends State<SearchScreen> {
);
}
Widget _buildCategorySegment({
required String label,
required IconData icon,
required bool selected,
required Color activeColor,
required int count,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOut,
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
decoration: BoxDecoration(
color: selected ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(11),
boxShadow: selected
? [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 4,
offset: const Offset(0, 2),
),
]
: null,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
color: selected ? activeColor : Colors.white.withOpacity(0.85),
size: 18,
),
const SizedBox(width: 6),
Flexible(
child: Text(
'$label ($count)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
color: selected
? activeColor
: Colors.white.withOpacity(0.85),
),
),
),
],
),
),
);
}
Widget _buildKontrakanItem(Kontrakan kontrakan) {
return GestureDetector(
onTap: () {
@ -901,20 +948,43 @@ class _SearchScreenState extends State<SearchScreen> {
topLeft: Radius.circular(16),
bottomLeft: Radius.circular(16),
),
child: Container(
child: CachedNetworkImage(
imageUrl: laundry.primaryPhoto,
width: 120,
height: 140,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Colors.cyan[400]!, Colors.cyan[600]!],
fit: BoxFit.cover,
placeholder: (context, url) => Container(
width: 120,
height: 140,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Colors.cyan[400]!, Colors.cyan[600]!],
),
),
child: const Center(
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
),
),
child: const Icon(
Icons.local_laundry_service,
size: 50,
color: Colors.white,
errorWidget: (context, url, error) => Container(
width: 120,
height: 140,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Colors.cyan[400]!, Colors.cyan[600]!],
),
),
child: const Icon(
Icons.local_laundry_service,
size: 50,
color: Colors.white,
),
),
),
),

View File

@ -46,6 +46,16 @@ class AuthService {
_currentUser = null;
}
/// Tangani response 401 secara terpusat.
/// Token lokal dibersihkan agar app tidak terus memakai token invalid.
Future<bool> handleUnauthorized(int statusCode) async {
if (statusCode == 401) {
await clearToken();
return true;
}
return false;
}
// Register
Future<Map<String, dynamic>> register({
required String name,

View File

@ -29,6 +29,11 @@ class BookingService {
headers: _headers,
);
if (response.statusCode == 401) {
await _authService.handleUnauthorized(response.statusCode);
return [];
}
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['success'] == true) {
@ -83,6 +88,11 @@ class BookingService {
final response = await http.Response.fromStream(streamedResponse);
final data = jsonDecode(response.body);
if (response.statusCode == 401) {
await _authService.handleUnauthorized(response.statusCode);
return {'success': false, 'message': 'Sesi expired, silakan login ulang'};
}
if (response.statusCode == 201 && data['success'] == true) {
return {
'success': true,
@ -111,6 +121,11 @@ class BookingService {
final data = jsonDecode(response.body);
if (response.statusCode == 401) {
await _authService.handleUnauthorized(response.statusCode);
return {'success': false, 'message': 'Sesi expired, silakan login ulang'};
}
if (response.statusCode == 200 && data['success'] == true) {
return {'success': true, 'message': data['message']};
} else {
@ -132,6 +147,11 @@ class BookingService {
headers: _headers,
);
if (response.statusCode == 401) {
await _authService.handleUnauthorized(response.statusCode);
return null;
}
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['success'] == true) {
@ -173,6 +193,11 @@ class BookingService {
final response = await http.Response.fromStream(streamedResponse);
final data = jsonDecode(response.body);
if (response.statusCode == 401) {
await _authService.handleUnauthorized(response.statusCode);
return {'success': false, 'message': 'Sesi expired, silakan login ulang'};
}
if (response.statusCode == 200 && data['success'] == true) {
return {
'success': true,

View File

@ -50,6 +50,7 @@ class FavoriteService {
debugPrint('[FAV] GET Body: ${response.body.length > 300 ? response.body.substring(0, 300) : response.body}');
if (response.statusCode == 401) {
await _authService.handleUnauthorized(response.statusCode);
debugPrint('[FAV] ❌ 401 Unauthorized — token expired/invalid');
return {'success': false, 'message': 'Sesi expired, silakan login ulang', 'kontrakan': [], 'laundry': []};
}
@ -185,6 +186,7 @@ class FavoriteService {
debugPrint('[FAV] Toggle kontrakan → ${response.statusCode}: ${response.body}');
if (response.statusCode == 401) {
await _authService.handleUnauthorized(response.statusCode);
return {'success': false, 'message': 'Sesi expired, silakan login ulang'};
}
@ -226,6 +228,7 @@ class FavoriteService {
debugPrint('[FAV] Toggle laundry → ${response.statusCode}: ${response.body}');
if (response.statusCode == 401) {
await _authService.handleUnauthorized(response.statusCode);
return {'success': false, 'message': 'Sesi expired, silakan login ulang'};
}
@ -266,6 +269,11 @@ class FavoriteService {
final data = jsonDecode(response.body);
if (response.statusCode == 401) {
await _authService.handleUnauthorized(response.statusCode);
return {'success': false, 'message': 'Sesi expired, silakan login ulang'};
}
if (response.statusCode == 200 && data['success'] == true) {
return {
'success': true,

View File

@ -37,6 +37,11 @@ class ReviewService {
final data = jsonDecode(response.body);
if (response.statusCode == 401) {
await _authService.handleUnauthorized(response.statusCode);
return {'success': false, 'message': 'Sesi expired, silakan login ulang'};
}
if (response.statusCode == 201 && data['success'] == true) {
return {
'success': true,
@ -73,6 +78,11 @@ class ReviewService {
final data = jsonDecode(response.body);
if (response.statusCode == 401) {
await _authService.handleUnauthorized(response.statusCode);
return {'success': false, 'message': 'Sesi expired, silakan login ulang'};
}
if (response.statusCode == 201 && data['success'] == true) {
return {
'success': true,
@ -109,6 +119,11 @@ class ReviewService {
final data = jsonDecode(response.body);
if (response.statusCode == 401) {
await _authService.handleUnauthorized(response.statusCode);
return {'success': false, 'message': 'Sesi expired, silakan login ulang'};
}
if (response.statusCode == 200 && data['success'] == true) {
return {
'success': true,
@ -137,6 +152,11 @@ class ReviewService {
final data = jsonDecode(response.body);
if (response.statusCode == 401) {
await _authService.handleUnauthorized(response.statusCode);
return {'success': false, 'message': 'Sesi expired, silakan login ulang'};
}
if (response.statusCode == 200 && data['success'] == true) {
return {
'success': true,

View File

@ -147,4 +147,10 @@ class ServerDiscoveryService {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_cacheKey, serverUrl);
}
/// Hapus cache URL server agar discovery dimulai dari nol.
static Future<void> resetCache() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_cacheKey);
}
}