feat: modernize mobile UI and normalize laundry service handling
This commit is contained in:
parent
a8e72f1203
commit
864be692e8
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@
|
|||
|
|
||||
*/
|
||||
|
||||
'timezone' => 'UTC',
|
||||
'timezone' => env('APP_TIMEZONE', 'Asia/Jakarta'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
|
|
|
|||
|
|
@ -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**/ ?>
|
||||
|
|
@ -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**/ ?>
|
||||
|
|
@ -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**/ ?>
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
|
||||
|
||||
<?php $__env->startSection('title', 'Kelola Kriteria SAW'); ?>
|
||||
|
||||
<?php $__env->startSection('content'); ?>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -1,5 +1,3 @@
|
|||
|
||||
|
||||
<?php $__env->startSection('title', 'Kelola User'); ?>
|
||||
|
||||
<?php $__env->startSection('content'); ?>
|
||||
|
|
|
|||
|
|
@ -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**/ ?>
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
|
||||
|
||||
<?php $__env->startSection('title', 'Daftar Laundry'); ?>
|
||||
|
||||
<?php $__env->startSection('content'); ?>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue