diff --git a/FLUTTER_DEVELOPMENT_GUIDE.md b/FLUTTER_DEVELOPMENT_GUIDE.md new file mode 100644 index 0000000..ea58b4d --- /dev/null +++ b/FLUTTER_DEVELOPMENT_GUIDE.md @@ -0,0 +1,107 @@ +## 📋 FLUTTER DEVELOPMENT SETUP CHECKLIST + +Sebelum menjalankan `flutter run`, pastikan semua ini sudah dilakukan: + +### ✅ BACKEND SETUP + +- [ ] **Jalankan Backend dengan Network Access** + ```bash + # Pilih salah satu: + # Option 1: Pakai script (RECOMMENDED) + cd c:\laragon\www\TA\spk_kontrakan + start start-backend-network.bat + + # Option 2: Manual command + php artisan serve --host=0.0.0.0 --port=8000 + ``` + + Expected output: + ``` + Starting Laravel development server: http://0.0.0.0:8000 + ``` + +- [ ] **Verifikasi Port 8000 Running** + ```bash + netstat -ano | findstr :8000 + # Harusnya ada: TCP 0.0.0.0:8000 LISTENING + ``` + +### 🛡️ FIREWALL SETUP + +- [ ] **Allow Port 8000 di Windows Firewall** + 1. Buka "Windows Defender Firewall with Advanced Security" + 2. Klik "Inbound Rules" → "New Rule" + 3. Port → Specific port: 8000 → Allow + 4. Atau: Allow app "php.exe" melalui firewall + +### 📱 DEVICE SETUP + +- [ ] **Device Terhubung ke Network Yang Sama** + - Device USB atau Emulator terhubung ke WiFi/network sama dengan komputer + +- [ ] **Cek IP Komputer** + ```bash + ipconfig | findstr IPv4 + # Catat IP yang dimulai dengan 192.168.x.x + ``` + +### 🔧 FLUTTER CONFIG + +- [ ] **Update app_config.dart dengan IP yang Benar** + ```dart + // File: lib/config/app_config.dart + static const String baseUrl = 'http://[YOUR-IP]:8000/api'; + static const String storageUrl = 'http://[YOUR-IP]:8000/storage'; + ``` + +### 🚀 FLUTTER RUN + +- [ ] **Clean & Run** + ```bash + cd c:\laragon\www\TA\spk_mobile + flutter clean + flutter run + ``` + +--- + +## 🆘 TROUBLESHOOTING + +### ❌ "TimeoutException" - Tidak Bisa Terhubung ke Backend + +**Checklist:** +1. ✅ Apakah backend running? `netstat -ano | findstr :8000` +2. ✅ Apakah firewall allow port 8000? +3. ✅ Apakah device satu network? Ping ke IP: + ```bash + ping 192.168.18.16 + ``` +4. ✅ Apakah IP di app_config.dart sesuai? +5. ✅ Coba akses via browser dulu: `http://192.168.18.16:8000` + +### ✅ Akses OK di Browser tapi Flutter Gagal + +- Kemungkinan: CORS issu atau Android Network Security +- Solusi: Cek di backend apakah CORS sudah enabled + +### 🚫 "Connection Refused" + +- Backend tidak running +- Solusi: Jalankan `start-backend-network.bat` terlebih dahulu + +--- + +## 💡 BEST PRACTICES + +1. **Selalu Jalankan Backend Terlebih Dahulu** + - Backend harus ready sebelum Flutter app Connect + +2. **Simpan IP di Environment Variable (tidak hardcode)** + - Sudah tersedia di `environment.dart` + +3. **Gunakan Script start-backend-network.bat** + - Lebih mudah dari mengetik command panjang + +4. **Test dengan Browser Dulu** + - Akses `http://192.168.18.16:8000/api/kontrakan` + - Harus return JSON jika API berjalan diff --git a/spk_kontrakan/app/Http/Controllers/AdminAuthController.php b/spk_kontrakan/app/Http/Controllers/AdminAuthController.php index ad475b4..4e9a25a 100644 --- a/spk_kontrakan/app/Http/Controllers/AdminAuthController.php +++ b/spk_kontrakan/app/Http/Controllers/AdminAuthController.php @@ -25,6 +25,14 @@ public function login(Request $request) // FIXED: Hapus guard('admin'), pakai default Auth if (Auth::attempt($credentials)) { + // Cek role - hanya admin dan super_admin yang boleh login + $user = Auth::user(); + if (!in_array($user->role, ['admin', 'super_admin'])) { + Auth::logout(); + $request->session()->invalidate(); + return back()->with('error', 'Akses ditolak. Halaman ini khusus untuk pemilik bisnis.'); + } + $request->session()->regenerate(); return redirect()->intended(route('dashboard')); } diff --git a/spk_kontrakan/app/Http/Controllers/Api/FavoriteController.php b/spk_kontrakan/app/Http/Controllers/Api/FavoriteController.php index e38ce11..d5de920 100644 --- a/spk_kontrakan/app/Http/Controllers/Api/FavoriteController.php +++ b/spk_kontrakan/app/Http/Controllers/Api/FavoriteController.php @@ -11,18 +11,31 @@ class FavoriteController extends Controller { /** - * List favorites user + * List favorites user — separated by type */ public function index(Request $request) { - $favorites = Favorite::with(['favoritable']) - ->where('user_id', $request->user()->id) - ->orderBy('created_at', 'desc') - ->get(); + $userId = $request->user()->id; + + $kontrakanIds = Favorite::where('user_id', $userId) + ->where('type', 'kontrakan') + ->pluck('item_id') + ->toArray(); + + $laundryIds = Favorite::where('user_id', $userId) + ->where('type', 'laundry') + ->pluck('item_id') + ->toArray(); + + $kontrakanList = Kontrakan::whereIn('id', $kontrakanIds)->get(); + $laundryList = Laundry::whereIn('id', $laundryIds)->get(); return response()->json([ 'success' => true, - 'data' => $favorites + 'data' => [ + 'kontrakan' => $kontrakanList, + 'laundry' => $laundryList, + ], ], 200); } @@ -41,8 +54,8 @@ public function toggleKontrakan(Request $request, $id) } $favorite = Favorite::where('user_id', $request->user()->id) - ->where('favoritable_type', Kontrakan::class) - ->where('favoritable_id', $id) + ->where('type', 'kontrakan') + ->where('item_id', $id) ->first(); if ($favorite) { @@ -56,17 +69,16 @@ public function toggleKontrakan(Request $request, $id) ], 200); } else { // Add to favorites - $favorite = Favorite::create([ + Favorite::create([ 'user_id' => $request->user()->id, - 'favoritable_type' => Kontrakan::class, - 'favoritable_id' => $id, + 'type' => 'kontrakan', + 'item_id' => $id, ]); return response()->json([ 'success' => true, 'message' => 'Kontrakan ditambahkan ke favorit', 'is_favorited' => true, - 'data' => $favorite ], 201); } } @@ -86,8 +98,8 @@ public function toggleLaundry(Request $request, $id) } $favorite = Favorite::where('user_id', $request->user()->id) - ->where('favoritable_type', Laundry::class) - ->where('favoritable_id', $id) + ->where('type', 'laundry') + ->where('item_id', $id) ->first(); if ($favorite) { @@ -101,17 +113,16 @@ public function toggleLaundry(Request $request, $id) ], 200); } else { // Add to favorites - $favorite = Favorite::create([ + Favorite::create([ 'user_id' => $request->user()->id, - 'favoritable_type' => Laundry::class, - 'favoritable_id' => $id, + 'type' => 'laundry', + 'item_id' => $id, ]); return response()->json([ 'success' => true, 'message' => 'Laundry ditambahkan ke favorit', 'is_favorited' => true, - 'data' => $favorite ], 201); } } diff --git a/spk_kontrakan/app/Http/Controllers/BackupController.php b/spk_kontrakan/app/Http/Controllers/BackupController.php index 8c53295..de0473d 100644 --- a/spk_kontrakan/app/Http/Controllers/BackupController.php +++ b/spk_kontrakan/app/Http/Controllers/BackupController.php @@ -54,11 +54,11 @@ public function create(Request $request) $timestamp = now()->format('Y-m-d-H-i-s'); $backupFile = $this->backupPath . "/backup_{$timestamp}.sql"; - // Get database credentials - $database = env('DB_DATABASE'); - $username = env('DB_USERNAME'); - $password = env('DB_PASSWORD'); - $host = env('DB_HOST'); + // Get database credentials (use config() instead of env() for config:cache compatibility) + $database = config('database.connections.mysql.database'); + $username = config('database.connections.mysql.username'); + $password = config('database.connections.mysql.password'); + $host = config('database.connections.mysql.host'); // Create SQL dump (Windows MySQL) $command = sprintf( @@ -98,6 +98,8 @@ public function create(Request $request) public function download($backup) { + // Sanitize: prevent path traversal + $backup = basename($backup); $backupPath = $this->backupPath . '/' . $backup; if (!File::exists($backupPath)) { @@ -110,6 +112,8 @@ public function download($backup) public function delete($backup) { try { + // Sanitize: prevent path traversal + $backup = basename($backup); $backupPath = $this->backupPath . '/' . $backup; if (!File::exists($backupPath)) { @@ -127,6 +131,8 @@ public function delete($backup) public function restore(Request $request, $backup) { try { + // Sanitize: prevent path traversal + $backup = basename($backup); $backupPath = $this->backupPath . '/' . $backup; if (!File::exists($backupPath)) { @@ -154,11 +160,11 @@ public function restore(Request $request, $backup) $sqlFile = $backupPath; } - // Restore database - $database = env('DB_DATABASE'); - $username = env('DB_USERNAME'); - $password = env('DB_PASSWORD'); - $host = env('DB_HOST'); + // Restore database (use config() instead of env() for config:cache compatibility) + $database = config('database.connections.mysql.database'); + $username = config('database.connections.mysql.username'); + $password = config('database.connections.mysql.password'); + $host = config('database.connections.mysql.host'); $command = sprintf( 'mysql --user=%s --password=%s --host=%s %s < "%s"', diff --git a/spk_kontrakan/app/Http/Controllers/DashboardController.php b/spk_kontrakan/app/Http/Controllers/DashboardController.php index 1945cfd..02d5222 100644 --- a/spk_kontrakan/app/Http/Controllers/DashboardController.php +++ b/spk_kontrakan/app/Http/Controllers/DashboardController.php @@ -29,7 +29,7 @@ public function index() // ========== DATA TERBARU (No Cache) ========== $recentKontrakan = Kontrakan::latest() - ->select('id', 'nama', 'alamat', 'harga', 'jarak', 'luas', 'created_at') // Only needed columns + ->select('id', 'nama', 'alamat', 'harga', 'jarak', 'jumlah_kamar', 'created_at') // Only needed columns ->take(5) ->get(); @@ -86,15 +86,15 @@ public function index() ->selectRaw(' AVG(harga) as avg_harga, AVG(jarak) as avg_jarak, - AVG(luas) as avg_luas, + AVG(jumlah_kamar) as avg_kamar, MIN(harga) as min_harga, MAX(harga) as max_harga ') ->first(); - // 5. Top Kontrakan by Luas - $topKontrakan = Kontrakan::select('nama', 'harga', 'luas', 'jarak') - ->orderBy('luas', 'desc') + // 5. Top Kontrakan by Harga + $topKontrakan = Kontrakan::select('nama', 'harga', 'jumlah_kamar', 'jarak') + ->orderBy('harga', 'desc') ->take(5) ->get(); @@ -136,7 +136,7 @@ public function index() ], 'avgHargaKontrakan' => $kontrakanStats->avg_harga ?? 0, 'avgJarakKontrakan' => $kontrakanStats->avg_jarak ?? 0, - 'avgLuasKontrakan' => $kontrakanStats->avg_luas ?? 0, + 'avgKamarKontrakan' => $kontrakanStats->avg_kamar ?? 0, 'minHargaKontrakan' => $kontrakanStats->min_harga ?? 0, 'maxHargaKontrakan' => $kontrakanStats->max_harga ?? 0, 'topKontrakan' => $topKontrakan, diff --git a/spk_kontrakan/app/Http/Controllers/ExportController.php b/spk_kontrakan/app/Http/Controllers/ExportController.php index 99558b8..1cb238f 100644 --- a/spk_kontrakan/app/Http/Controllers/ExportController.php +++ b/spk_kontrakan/app/Http/Controllers/ExportController.php @@ -176,20 +176,20 @@ public function laundryExcel(Request $request) } // Filter harga - if ($request->filled('harga_min')) { + if ($request->filled('harga_min') || $request->filled('harga_max')) { $query->whereHas('layanan', function($q) use ($request) { - $q->where('harga', '>=', $request->harga_min); - }); - } - if ($request->filled('harga_max')) { - $query->whereHas('layanan', function($q) use ($request) { - $q->where('harga', '<=', $request->harga_max); + if ($request->filled('harga_min')) { + $q->where('harga', '>=', $request->harga_min); + } + if ($request->filled('harga_max')) { + $q->where('harga', '<=', $request->harga_max); + } }); } // Filter jarak - if ($request->filled('jarak')) { - $jarakMeter = $request->jarak * 1000; + if ($request->filled('jarak_max')) { + $jarakMeter = $request->jarak_max * 1000; $query->where('jarak', '<=', $jarakMeter); } @@ -254,12 +254,16 @@ public function laundryPDF(Request $request) }); } - // Filter harga - if ($request->filled('harga_min')) { - $query->where('harga', '>=', $request->harga_min); - } - if ($request->filled('harga_max')) { - $query->where('harga', '<=', $request->harga_max); + // Filter harga (harga ada di tabel layanan_laundry, bukan laundry) + if ($request->filled('harga_min') || $request->filled('harga_max')) { + $query->whereHas('layanan', function($q) use ($request) { + if ($request->filled('harga_min')) { + $q->where('harga', '>=', $request->harga_min); + } + if ($request->filled('harga_max')) { + $q->where('harga', '<=', $request->harga_max); + } + }); } $laundry = $query->get(); diff --git a/spk_kontrakan/app/Http/Controllers/GaleriController.php b/spk_kontrakan/app/Http/Controllers/GaleriController.php index b660f9e..2ab2719 100644 --- a/spk_kontrakan/app/Http/Controllers/GaleriController.php +++ b/spk_kontrakan/app/Http/Controllers/GaleriController.php @@ -106,6 +106,11 @@ public function setPrimaryKontrakan($id) { $galeri = Galeri::findOrFail($id); + // Pastikan galeri ini memang bertipe kontrakan + if ($galeri->type !== 'kontrakan') { + return back()->with('error', 'Foto ini bukan milik kontrakan'); + } + // Reset semua foto di kontrakan yang sama jadi bukan primary Galeri::where('type', 'kontrakan') ->where('item_id', $galeri->item_id) @@ -124,6 +129,11 @@ public function setPrimaryLaundry($id) { $galeri = Galeri::findOrFail($id); + // Pastikan galeri ini memang bertipe laundry + if ($galeri->type !== 'laundry') { + return back()->with('error', 'Foto ini bukan milik laundry'); + } + // Reset semua foto di laundry yang sama jadi bukan primary Galeri::where('type', 'laundry') ->where('item_id', $galeri->item_id) diff --git a/spk_kontrakan/app/Http/Controllers/KontrakanController.php b/spk_kontrakan/app/Http/Controllers/KontrakanController.php index 3b582cc..73356ea 100644 --- a/spk_kontrakan/app/Http/Controllers/KontrakanController.php +++ b/spk_kontrakan/app/Http/Controllers/KontrakanController.php @@ -223,7 +223,7 @@ public function store(Request $request) 'jarak' => $request->jarak, 'fasilitas' => $request->fasilitas, 'jumlah_kamar' => $request->jumlah_kamar, - 'luas' => $request->luas ?? 0, // Default 0 jika tidak diisi + 'bathroom_count' => $request->bathroom_count ?? 1, 'foto' => $filename, ]); @@ -333,7 +333,7 @@ public function update(Request $request, $id) 'jarak' => $request->jarak, 'fasilitas' => $request->fasilitas, 'jumlah_kamar' => $request->jumlah_kamar, - 'luas' => $request->luas ?? $kontrakan->luas ?? 0, // Gunakan nilai lama jika tidak diisi + 'bathroom_count' => $request->bathroom_count ?? $kontrakan->bathroom_count ?? 1, 'foto' => $filename, ]); diff --git a/spk_kontrakan/app/Http/Controllers/KriteriaController.php b/spk_kontrakan/app/Http/Controllers/KriteriaController.php index 162b141..09c5d46 100644 --- a/spk_kontrakan/app/Http/Controllers/KriteriaController.php +++ b/spk_kontrakan/app/Http/Controllers/KriteriaController.php @@ -112,7 +112,7 @@ public function store(Request $request) 'tipe' => 'required|in:Benefit,Cost', ]); - Kriteria::create($request->all()); + Kriteria::create($request->only(['tipe_bisnis', 'nama_kriteria', 'bobot', 'tipe'])); // Redirect dengan parameter filter return redirect() @@ -139,7 +139,7 @@ public function update(Request $request, Kriteria $kriterium) 'tipe' => 'required|in:Benefit,Cost', ]); - $kriterium->update($request->all()); + $kriterium->update($request->only(['tipe_bisnis', 'nama_kriteria', 'bobot', 'tipe'])); // Redirect dengan parameter filter return redirect() diff --git a/spk_kontrakan/app/Http/Controllers/LaundryController.php b/spk_kontrakan/app/Http/Controllers/LaundryController.php index 8250829..cffd321 100644 --- a/spk_kontrakan/app/Http/Controllers/LaundryController.php +++ b/spk_kontrakan/app/Http/Controllers/LaundryController.php @@ -36,15 +36,14 @@ public function index(Request $request) } // ========== FILTER: RANGE HARGA (Min & Max dengan Slider) ========== - if ($request->filled('harga_min')) { + if ($request->filled('harga_min') || $request->filled('harga_max')) { $query->whereHas('layanan', function($q) use ($request) { - $q->where('harga', '>=', $request->harga_min); - }); - } - - if ($request->filled('harga_max')) { - $query->whereHas('layanan', function($q) use ($request) { - $q->where('harga', '<=', $request->harga_max); + if ($request->filled('harga_min')) { + $q->where('harga', '>=', $request->harga_min); + } + if ($request->filled('harga_max')) { + $q->where('harga', '<=', $request->harga_max); + } }); } diff --git a/spk_kontrakan/app/Http/Controllers/ReviewController.php b/spk_kontrakan/app/Http/Controllers/ReviewController.php index 5c19268..4411110 100644 --- a/spk_kontrakan/app/Http/Controllers/ReviewController.php +++ b/spk_kontrakan/app/Http/Controllers/ReviewController.php @@ -149,8 +149,8 @@ public function destroy($id) { $review = Review::findOrFail($id); - // Cek apakah user adalah pemilik review atau admin - if ($review->user_id !== Auth::id() && Auth::user()->role !== 'admin') { + // Cek apakah user adalah pemilik review atau admin/super_admin + if ($review->user_id !== Auth::id() && !in_array(Auth::user()->role, ['admin', 'super_admin'])) { return back()->with('error', 'Anda tidak memiliki akses untuk menghapus review ini'); } diff --git a/spk_kontrakan/app/Http/Controllers/SAWController.php b/spk_kontrakan/app/Http/Controllers/SAWController.php index f47e083..7273c70 100644 --- a/spk_kontrakan/app/Http/Controllers/SAWController.php +++ b/spk_kontrakan/app/Http/Controllers/SAWController.php @@ -222,10 +222,7 @@ private function processData($data, $tipe, $jenisLayanan, $refLat, $refLng) } $item->harga_value = $layanan->harga ?? 0; - $item->kecepatan_value = $this->convertToHours( - $layanan->kecepatan ?? 0, - $layanan->satuan_kecepatan ?? 'jam' - ); + $item->kecepatan_value = $layanan->estimasi_selesai ?? 0; // sudah dalam satuan jam // LAUNDRY: Hitung jarak dari LOKASI USER if ($item->latitude && $item->longitude) { @@ -389,20 +386,4 @@ private function normalize($nilai, $maxMin, $tipe) } } - // Convert kecepatan ke jam - private function convertToHours($kecepatan, $satuan) - { - try { - $kecepatan = floatval($kecepatan); - - if ($satuan == 'hari') { - return $kecepatan * 24; - } - return $kecepatan; - - } catch (Exception $e) { - Log::error('Error convertToHours: ' . $e->getMessage()); - return 0; - } - } } \ No newline at end of file diff --git a/spk_kontrakan/app/Http/Controllers/UserManagementController.php b/spk_kontrakan/app/Http/Controllers/UserManagementController.php index 2b51d9b..68ed274 100644 --- a/spk_kontrakan/app/Http/Controllers/UserManagementController.php +++ b/spk_kontrakan/app/Http/Controllers/UserManagementController.php @@ -33,7 +33,7 @@ public function index(Request $request) if ($request->status === 'active') { $query->whereNull('deleted_at'); } else { - $query->whereNotNull('deleted_at'); + $query->onlyTrashed(); } } @@ -91,15 +91,17 @@ public function update(Request $request, User $user) $oldValues = $user->toArray(); - if ($request->filled('password')) { - $user->password = Hash::make($request->password); - } - - $user->update([ + $updateData = [ 'name' => $validated['name'], 'email' => $validated['email'], 'role' => $validated['role'], - ]); + ]; + + if ($request->filled('password')) { + $updateData['password'] = Hash::make($request->password); + } + + $user->update($updateData); ActivityLog::log('update', "Memperbarui user: {$user->name}", 'User', $user->id, $oldValues, $user->toArray()); diff --git a/spk_kontrakan/app/Http/Middleware/AdminAuth.php b/spk_kontrakan/app/Http/Middleware/AdminAuth.php index 8e420af..094fc43 100644 --- a/spk_kontrakan/app/Http/Middleware/AdminAuth.php +++ b/spk_kontrakan/app/Http/Middleware/AdminAuth.php @@ -9,7 +9,7 @@ class AdminAuth { public function handle($request, Closure $next) { - if (! Auth::guard('admin')->check()) { + if (! Auth::check()) { return redirect()->route('admin.login'); } diff --git a/spk_kontrakan/app/Models/ActivityLog.php b/spk_kontrakan/app/Models/ActivityLog.php index a62c2e5..c3e96b5 100644 --- a/spk_kontrakan/app/Models/ActivityLog.php +++ b/spk_kontrakan/app/Models/ActivityLog.php @@ -146,13 +146,13 @@ public static function archiveOldLogs($keepDays = 365) // Dashboard Statistics public static function getSummaryStats($userId = null) { - $query = $userId ? self::where('user_id', $userId) : self::query(); + $baseQuery = $userId ? self::where('user_id', $userId) : self::query(); return [ - 'total_actions' => $query->count(), - 'today_actions' => $query->whereDate('created_at', today())->count(), - 'this_week_actions' => $query->where('created_at', '>=', now()->startOfWeek())->count(), - 'this_month_actions' => $query->whereMonth('created_at', now()->month)->count(), + 'total_actions' => (clone $baseQuery)->count(), + 'today_actions' => (clone $baseQuery)->whereDate('created_at', today())->count(), + 'this_week_actions' => (clone $baseQuery)->where('created_at', '>=', now()->startOfWeek())->count(), + 'this_month_actions' => (clone $baseQuery)->whereMonth('created_at', now()->month)->count(), 'most_common_action' => self::select('action') ->groupBy('action') ->orderByRaw('count(*) desc') @@ -162,7 +162,7 @@ public static function getSummaryStats($userId = null) ->groupBy('action') ->orderByRaw('count(*) desc') ->pluck('count', 'action'), - 'recent_activity' => $query->with('user') + 'recent_activity' => (clone $baseQuery)->with('user') ->latest() ->limit(10) ->get() diff --git a/spk_kontrakan/app/Models/Galeri.php b/spk_kontrakan/app/Models/Galeri.php index 9d89306..4ce8fc9 100644 --- a/spk_kontrakan/app/Models/Galeri.php +++ b/spk_kontrakan/app/Models/Galeri.php @@ -30,7 +30,7 @@ class Galeri extends Model */ public function kontrakan() { - return $this->belongsTo(Kontrakan::class, 'item_id')->where('type', 'kontrakan'); + return $this->belongsTo(Kontrakan::class, 'item_id'); } /** @@ -38,7 +38,7 @@ public function kontrakan() */ public function laundry() { - return $this->belongsTo(Laundry::class, 'item_id')->where('type', 'laundry'); + return $this->belongsTo(Laundry::class, 'item_id'); } /** diff --git a/spk_kontrakan/app/Models/Review.php b/spk_kontrakan/app/Models/Review.php index 2ca0a04..8a46906 100644 --- a/spk_kontrakan/app/Models/Review.php +++ b/spk_kontrakan/app/Models/Review.php @@ -34,7 +34,7 @@ public function user() */ public function kontrakan() { - return $this->belongsTo(Kontrakan::class, 'item_id')->where('type', 'kontrakan'); + return $this->belongsTo(Kontrakan::class, 'item_id'); } /** @@ -42,7 +42,7 @@ public function kontrakan() */ public function laundry() { - return $this->belongsTo(Laundry::class, 'item_id')->where('type', 'laundry'); + return $this->belongsTo(Laundry::class, 'item_id'); } /** diff --git a/spk_kontrakan/app/Models/User.php b/spk_kontrakan/app/Models/User.php index 1597699..ec4c1cb 100644 --- a/spk_kontrakan/app/Models/User.php +++ b/spk_kontrakan/app/Models/User.php @@ -97,4 +97,28 @@ public function activityLogs() { return $this->hasMany(ActivityLog::class); } + + /** + * Relationship ke Booking + */ + public function bookings() + { + return $this->hasMany(Booking::class); + } + + /** + * Relationship ke Review + */ + public function reviews() + { + return $this->hasMany(Review::class); + } + + /** + * Relationship ke Favorite + */ + public function favorites() + { + return $this->hasMany(Favorite::class); + } } \ No newline at end of file diff --git a/spk_kontrakan/public/uploads/Kontrakan/1772135532_69a0a46c03fc2.png b/spk_kontrakan/public/uploads/Kontrakan/1772135532_69a0a46c03fc2.png new file mode 100644 index 0000000..615d2c2 Binary files /dev/null and b/spk_kontrakan/public/uploads/Kontrakan/1772135532_69a0a46c03fc2.png differ diff --git a/spk_kontrakan/public/uploads/Kontrakan/1772137717_69a0acf58a28e.png b/spk_kontrakan/public/uploads/Kontrakan/1772137717_69a0acf58a28e.png new file mode 100644 index 0000000..78e1526 Binary files /dev/null and b/spk_kontrakan/public/uploads/Kontrakan/1772137717_69a0acf58a28e.png differ diff --git a/spk_kontrakan/public/uploads/Kontrakan/1772137812_69a0ad54559b7.png b/spk_kontrakan/public/uploads/Kontrakan/1772137812_69a0ad54559b7.png new file mode 100644 index 0000000..78e1526 Binary files /dev/null and b/spk_kontrakan/public/uploads/Kontrakan/1772137812_69a0ad54559b7.png differ diff --git a/spk_kontrakan/public/uploads/galeri/kontrakan/1772137680_69a0acd03b536.png b/spk_kontrakan/public/uploads/galeri/kontrakan/1772137680_69a0acd03b536.png new file mode 100644 index 0000000..e0b1a5d Binary files /dev/null and b/spk_kontrakan/public/uploads/galeri/kontrakan/1772137680_69a0acd03b536.png differ diff --git a/spk_kontrakan/resources/views/Kontrakan/create.blade.php b/spk_kontrakan/resources/views/Kontrakan/create.blade.php index 7b1e091..b1f879b 100644 --- a/spk_kontrakan/resources/views/Kontrakan/create.blade.php +++ b/spk_kontrakan/resources/views/Kontrakan/create.blade.php @@ -416,10 +416,6 @@ class="form-control border-start-0 @error('jumlah_kamar') is-invalid @enderror" Jumlah kamar tidur yang tersedia - - Total kamar mandi (sistem akan otomatis menghitung kenyamanan) - -
diff --git a/spk_kontrakan/resources/views/Kontrakan/show.blade.php b/spk_kontrakan/resources/views/Kontrakan/show.blade.php index c1fc907..e3659ac 100644 --- a/spk_kontrakan/resources/views/Kontrakan/show.blade.php +++ b/spk_kontrakan/resources/views/Kontrakan/show.blade.php @@ -568,21 +568,6 @@ class="btn btn-outline-secondary btn-sm"
- -
-
-
- -
-
- Luas Bangunan -
- {{ $kontrakan->luas }} m² -
-
-
-
-
@@ -728,8 +713,8 @@ class="btn btn-light w-100 fw-semibold" Rp {{ number_format($kontrakan->harga / 12, 0, ',', '.') }}
- Harga/m² - Rp {{ number_format($kontrakan->harga / $kontrakan->jumlah_kamar, 0, ',', '.') }} + Harga/Kamar + Rp {{ number_format($kontrakan->jumlah_kamar > 0 ? $kontrakan->harga / $kontrakan->jumlah_kamar : 0, 0, ',', '.') }}
Status diff --git a/spk_kontrakan/resources/views/Laundry/edit.blade.php b/spk_kontrakan/resources/views/Laundry/edit.blade.php index ee0a056..f942da0 100644 --- a/spk_kontrakan/resources/views/Laundry/edit.blade.php +++ b/spk_kontrakan/resources/views/Laundry/edit.blade.php @@ -512,22 +512,41 @@ class="form-control"
-
- - -
+ +
+ +
+ + +
+ +
+ +
diff --git a/spk_kontrakan/resources/views/Laundry/show.blade.php b/spk_kontrakan/resources/views/Laundry/show.blade.php index 3abea36..577b17a 100644 --- a/spk_kontrakan/resources/views/Laundry/show.blade.php +++ b/spk_kontrakan/resources/views/Laundry/show.blade.php @@ -113,7 +113,7 @@
{{ $laundry->nama }} - - - + + + @@ -242,7 +242,7 @@ class="btn btn-sm btn-danger"> @@ -318,7 +318,7 @@ class="btn btn-sm btn-danger"> @endif
Status - Buka + {{ ($laundry->status ?? 'buka') === 'buka' ? 'Buka' : 'Tutup' }}
Kategori diff --git a/spk_kontrakan/resources/views/Layouts/admin.blade.php b/spk_kontrakan/resources/views/Layouts/admin.blade.php index 0e796e2..7794d8b 100644 --- a/spk_kontrakan/resources/views/Layouts/admin.blade.php +++ b/spk_kontrakan/resources/views/Layouts/admin.blade.php @@ -54,6 +54,21 @@ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + + /* Shared color variables for views */ + --primary-color: #667eea; + --primary-color-rgb: 102, 126, 234; + --secondary-color: #764ba2; + --secondary-color-rgb: 118, 75, 162; + --info-color: #17a2b8; + --info-color-rgb: 23, 162, 184; + --success-color: #28a745; + --success-color-rgb: 40, 167, 69; + --warning-color: #ffc107; + --warning-color-rgb: 255, 193, 7; + --danger-color: #dc3545; + --danger-color-rgb: 220, 53, 69; + --text-muted: #6c757d; } * { diff --git a/spk_kontrakan/resources/views/SAW/hasil.blade.php b/spk_kontrakan/resources/views/SAW/hasil.blade.php index 38fe2a0..86b3f91 100644 --- a/spk_kontrakan/resources/views/SAW/hasil.blade.php +++ b/spk_kontrakan/resources/views/SAW/hasil.blade.php @@ -1036,7 +1036,6 @@ class="btn btn-sm btn-success rounded-pill px-3 py-1"
Rekomendasi Terbaik
-
@if(count($hasil) > 0) @if(isset($hasil[0]['foto']) && $hasil[0]['foto']) diff --git a/spk_kontrakan/resources/views/auth/admin-login.blade.php b/spk_kontrakan/resources/views/auth/admin-login.blade.php index ba014f1..5141d76 100644 --- a/spk_kontrakan/resources/views/auth/admin-login.blade.php +++ b/spk_kontrakan/resources/views/auth/admin-login.blade.php @@ -173,7 +173,6 @@
diff --git a/spk_kontrakan/resources/views/auth/admin-register.blade.php b/spk_kontrakan/resources/views/auth/admin-register.blade.php index f5a96e5..a6cdd03 100644 --- a/spk_kontrakan/resources/views/auth/admin-register.blade.php +++ b/spk_kontrakan/resources/views/auth/admin-register.blade.php @@ -31,7 +31,7 @@ } .auth-header { - background: linear-gradient(135deg, #28a745 0%, #20c997 100%); + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px 20px; text-align: center; @@ -197,7 +197,6 @@ diff --git a/spk_kontrakan/resources/views/favorites/index.blade.php b/spk_kontrakan/resources/views/favorites/index.blade.php index 0c72280..1429b75 100644 --- a/spk_kontrakan/resources/views/favorites/index.blade.php +++ b/spk_kontrakan/resources/views/favorites/index.blade.php @@ -567,7 +567,7 @@ @endif @else @if($item->foto) - {{ $item->nama }} + {{ $item->nama }} @else {{ $item->nama }} @endif diff --git a/spk_kontrakan/routes/web.php b/spk_kontrakan/routes/web.php index 319a55d..02e4bd6 100644 --- a/spk_kontrakan/routes/web.php +++ b/spk_kontrakan/routes/web.php @@ -17,35 +17,15 @@ use App\Http\Controllers\BookingController; // ← BOOKING SYSTEM (ADMIN) // ------------------------------------------------- -// LANDING PAGE - Redirect ke Admin Portal +// LANDING PAGE - Langsung ke Login // ------------------------------------------------- Route::get('/', function () { - return redirect()->route('admin.portal'); + return redirect()->route('admin.login'); })->name('welcome'); -// ------------------------------------------------- -// ADMIN PORTAL - Landing page khusus untuk pemilik/admin -// ------------------------------------------------- -Route::get('/admin-portal', function () { - // Hitung statistik real-time - $totalAdmins = \App\Models\User::where('role', 'admin')->count(); - $totalKontrakan = \App\Models\Kontrakan::count(); - $totalLaundry = \App\Models\Laundry::count(); - $totalProperti = $totalKontrakan + $totalLaundry; - $totalBookings = \App\Models\Booking::where('status', 'confirmed')->count(); - $avgRating = \App\Models\Review::avg('rating') ?? 4.8; - - return view('admin-portal', [ - 'totalAdmins' => $totalAdmins, - 'totalProperti' => $totalProperti, - 'totalBookings' => $totalBookings, - 'avgRating' => round($avgRating, 1) - ]); -})->name('admin.portal'); - // Shortcut URL - lebih mudah diingat Route::get('/pemilik', function () { - return redirect()->route('admin.portal'); + return redirect()->route('admin.login'); }); // ------------------------------------------------- @@ -85,15 +65,15 @@ // ========== 🆕 ACTIVITY LOG ROUTES ========== Route::prefix('admin')->name('admin.')->group(function () { - Route::resource('activity-logs', ActivityLogController::class)->only(['index', 'show']); Route::get('/activity-logs/export', [ActivityLogController::class, 'export'])->name('activity-logs.export'); Route::post('/activity-logs/clear', [ActivityLogController::class, 'clear'])->name('activity-logs.clear'); + Route::resource('activity-logs', ActivityLogController::class)->only(['index', 'show']); }); // ========== 🆕 USER MANAGEMENT ROUTES ========== Route::prefix('admin')->name('admin.')->group(function () { Route::resource('users', UserManagementController::class); - Route::post('/users/{user}/restore', [UserManagementController::class, 'restore'])->name('users.restore'); + Route::post('/users/{user}/restore', [UserManagementController::class, 'restore'])->name('users.restore')->withTrashed(); Route::post('/users/bulk-delete', [UserManagementController::class, 'bulkDelete'])->name('users.bulk-delete'); }); @@ -186,7 +166,7 @@ }); // Legacy favorite routes (untuk kompatibilitas) - Route::post('/favorite', [FavoriteController::class, 'toggleOld'])->name('favorite.toggle'); + Route::post('/favorite/{type}/{id}', [FavoriteController::class, 'toggle'])->name('favorite.toggle'); Route::post('/admin/logout', [AdminAuthController::class, 'logout'])->name('admin.logout'); }); \ No newline at end of file diff --git a/spk_kontrakan/start-backend-network.bat b/spk_kontrakan/start-backend-network.bat new file mode 100644 index 0000000..d29a7e5 --- /dev/null +++ b/spk_kontrakan/start-backend-network.bat @@ -0,0 +1,42 @@ +@echo off +REM ============================================================================ +REM Script untuk memulai Laravel Backend dengan Network Access +REM Tujuan: Agar Flutter Mobile Device bisa terhubung dari network +REM ============================================================================ + +cls +echo. +echo ╔════════════════════════════════════════════════════════╗ +echo ║ 🚀 SPK Backend - Network Accessible Mode ║ +echo ╚════════════════════════════════════════════════════════╝ +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: =% + +if "%IP%"=="" ( + echo ❌ ERROR: Tidak bisa menemukan IP Address! + echo Pastikan komputer terhubung ke network. + pause + exit /b 1 +) + +echo ✅ IP Address terdeteksi: %IP% +echo. +echo 📱 Backend akan accessible dari: +echo • Local: http://localhost:8000 +echo • Network: http://%IP%:8000 +echo • API: http://%IP%:8000/api +echo. +echo ⚙️ Starting Laravel Backend... +echo. + +REM Pindah ke directory project +cd /d "%~dp0" + +REM Jalankan Laravel serve dengan host 0.0.0.0 +php artisan serve --host=0.0.0.0 --port=8000 + +pause diff --git a/spk_kontrakan/storage/framework/views/0549a80dc062eecdcc8efbf8312e4fd5.php b/spk_kontrakan/storage/framework/views/0549a80dc062eecdcc8efbf8312e4fd5.php deleted file mode 100644 index bf4c0e6..0000000 --- a/spk_kontrakan/storage/framework/views/0549a80dc062eecdcc8efbf8312e4fd5.php +++ /dev/null @@ -1,1297 +0,0 @@ - - -startSection('title', 'Detail Kontrakan'); ?> - -startSection('content'); ?> -
- - - - - - -
-
-
-

- nama); ?> - - status_label); ?> -

-

Informasi lengkap dan detil kontrakan

-
- -
-
- - - - - - - -
-
-
-
-
-
Update Status Cepat
- Tandai status ketersediaan kontrakan -
-
-
-
- - - - -
- - - - -
- - - - -
- - - - -
-
-
-
-
-
- -
- -
- - foto): ?> -
-
- Foto Kontrakan -
-
-
- <?php echo e($kontrakan->nama); ?> -
- - - Klik foto untuk melihat ukuran penuh - -
-
-
-
- -
-
- Foto Kontrakan -
-
-
-
-
- -
-
- -
-
Foto Tidak Tersedia
-

- - Belum ada foto yang diupload untuk kontrakan ini -

-
-
-
-
- - - -
-
- - Galeri Foto - - -
-
- galeri->count() > 0): ?> -
- galeri; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $foto): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> -
-
- Foto <?php echo e($kontrakan->nama); ?> - - is_primary): ?> - - Utama - - - -
-
- is_primary): ?> -
- - - - - -
- - - - -
-
- - - #urutan); ?> - - -
-
- popLoop(); $loop = $__env->getLastLoop(); ?> -
- -
- -
Belum ada foto di galeri
-

Upload foto untuk menampilkan galeri

- -
- -
-
- - - - - -
-
-
-
- - Review & Rating - -
-
- average_rating); ?> -
- - average_rating): ?> - - - - - -
-
- (total_reviews); ?> review) -
-
- -
-
-
- reviews->count() > 0): ?> -
- reviews; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $review): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> -
-
-
-
- user->name, 0, 1))); ?> - -
-
-
user->name); ?>
- created_at->diffForHumans()); ?> -
-
-
-
- - rating): ?> - - - - - -
- id() === $review->user_id): ?> -
- -
- - - - -
- -
-
- review): ?> -

review); ?>

- -
- popLoop(); $loop = $__env->getLastLoop(); ?> -
- -
- -
Belum ada review
-

Jadilah yang pertama memberikan review!

-
- -
-
- - - - - - latitude && $kontrakan->longitude): ?> -
-
- - Lokasi di Peta - - - Google Maps - -
-
-
-
-
- - - -
-
- Informasi Utama -
-
-
- -
-
-
- -
-
- Nama Kontrakan -
nama); ?>
-
-
-
- - -
-
-
- -
-
- Alamat Lengkap -

alamat); ?>

-
-
-
- - - no_whatsapp): ?> -
-
-
- -
-
- Kontak WhatsApp -
no_whatsapp); ?>
-
- - Chat WhatsApp - - - Telepon - - -
-
-
-
- - - -
-
-
- -
-
- Harga Sewa/Tahun -
- Rp harga, 0, ',', '.')); ?> - -
-
-
-
- - -
-
-
- -
-
- Luas Bangunan -
- luas); ?> m² -
-
-
-
- - -
-
-
- -
-
- Jumlah Kamar Tidur -
- jumlah_kamar ?? 1); ?> kamar -
-
-
-
- - -
-
-
- -
-
- Jumlah Kamar Mandi -
- bathroom_count ?? 1); ?> kamar -
-
-
-
- - -
-
-
- -
-
- Jarak ke Kampus -
- jarak, 0, ',', '.')); ?> meter -
- jarak / 1000, 2)); ?> km -
-
-
- - -
-
-
- -
-
- Fasilitas - fasilitas): ?> -
- fasilitas); $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $fasilitas): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> - - - - - popLoop(); $loop = $__env->getLastLoop(); ?> -
- -

-

- -
-
-
-
-
-
-
- - -
- - no_whatsapp): ?> -
-
-
-
- -
-
Hubungi Pemilik
-

Tanya langsung via WhatsApp

-
-
- Nomor WhatsApp -

no_whatsapp); ?>

-
- - Chat Sekarang - -
-
- - - -
-
-
- Simpan Favorit -
-

- Simpan kontrakan ini ke daftar favorit Anda untuk akses cepat -

- - check() && $kontrakan->isFavoritedBy(auth()->id()); - ?> - -
- - - - - - - total_favorites); ?> orang menyukai ini - -
-
- - -
-
-
- Ringkasan -
-
- Harga/Bulan - Rp harga / 12, 0, ',', '.')); ?> -
-
- Harga/m² - Rp harga / $kontrakan->jumlah_kamar, 0, ',', '.')); ?> -
-
- Status - Tersedia -
-
- Kategori - Kontrakan -
-
-
- - -
-
-
- Riwayat -
-
- Dibuat - created_at->format('d M Y, H:i')); ?> WIB -
- updated_at != $kontrakan->created_at): ?> -
- Terakhir Diupdate - updated_at->format('d M Y, H:i')); ?> WIB - - (updated_at->diffForHumans()); ?>) - -
- -
-
- - -
-
-
- Aksi -
-
- - Edit Data - - - user()->role == 'super_admin'): ?> -
- - - - - - - -
-
-
-
-
-
- - - - - - - - - - -stopSection(); ?> - -make('layouts.admin', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?> \ No newline at end of file diff --git a/spk_kontrakan/storage/framework/views/0bb244dbd77bcee4d52a59bb59147721.php b/spk_kontrakan/storage/framework/views/0bb244dbd77bcee4d52a59bb59147721.php deleted file mode 100644 index 6398fcc..0000000 --- a/spk_kontrakan/storage/framework/views/0bb244dbd77bcee4d52a59bb59147721.php +++ /dev/null @@ -1,117 +0,0 @@ -hasPages()): ?> - - - \ No newline at end of file diff --git a/spk_kontrakan/storage/framework/views/15adb941f057c688df2ceb4a9c21ef52.php b/spk_kontrakan/storage/framework/views/15adb941f057c688df2ceb4a9c21ef52.php deleted file mode 100644 index 2c2727c..0000000 --- a/spk_kontrakan/storage/framework/views/15adb941f057c688df2ceb4a9c21ef52.php +++ /dev/null @@ -1,163 +0,0 @@ - - -startSection('title', 'Activity Log'); ?> - -startSection('content'); ?> -
- - - -
-

- Activity Log -

-

Riwayat semua aktivitas user dalam sistem

-
- - -
-
-
-
- - -
- -
- - -
- -
- - -
- -
- - - Reset - -
- -
-
- - -
-
-
-
- Daftar Aktivitas (total()); ?>) -
- - Export CSV - -
-
- -
- isEmpty()): ?> -
- -
Tidak ada aktivitas ditemukan
-
- -
-
Jenis LayananHarga/kgKecepatanNama PaketHargaEstimasi Selesai
- {{ ucfirst($layanan->jenis_layanan) }} + {{ $layanan->nama_paket ?? ucfirst($layanan->jenis_layanan ?? '-') }} @@ -252,7 +252,7 @@ class="btn btn-sm btn-danger"> - {{ $layanan->kecepatan }} {{ $layanan->satuan_kecepatan }} + {{ $layanan->estimasi_selesai ? $layanan->estimasi_selesai . ' jam' : '-' }}
- - - - - - - - - - - - addLoop($__currentLoopData); foreach($__currentLoopData as $log): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> - - - - - - - - - popLoop(); $loop = $__env->getLastLoop(); ?> - -
WaktuUserActionDeskripsiModelIP Address
- created_at->format('d M Y H:i')); ?> - - user->name ?? 'Unknown'); ?>
- user->email ?? ''); ?> -
- - action)); ?> - - - - description); ?> - - - model_type): ?> - - model_type); ?> - - model_id): ?> - #model_id); ?> - - - - - - - - - ip_address); ?> -
-
- - - - - -
- links()); ?> - -
- -stopSection(); ?> - -make('layouts.admin', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?> \ No newline at end of file diff --git a/spk_kontrakan/storage/framework/views/2487b2a216139ea38383fd97d51e2280.php b/spk_kontrakan/storage/framework/views/2487b2a216139ea38383fd97d51e2280.php deleted file mode 100644 index 6d90947..0000000 --- a/spk_kontrakan/storage/framework/views/2487b2a216139ea38383fd97d51e2280.php +++ /dev/null @@ -1,231 +0,0 @@ - - -startSection('title', 'Kelola Booking'); ?> - -startSection('content'); ?> -
- -
-
-

- Kelola Booking -

- -
- - Booking Baru - -
- - - - - - - - - - - -
-
-
-
- - -
-
- - -
-
- - - - -
-
-
-
- - -
- 'pending', 'label' => 'Menunggu', 'color' => 'warning', 'icon' => 'hourglass-split'], - ['status' => 'confirmed', 'label' => 'Dikonfirmasi', 'color' => 'info', 'icon' => 'check-circle'], - ['status' => 'checked_in', 'label' => 'Ditempati', 'color' => 'success', 'icon' => 'house-door'], - ['status' => 'completed', 'label' => 'Selesai', 'color' => 'secondary', 'icon' => 'check-all'], - ]; - ?> - addLoop($__currentLoopData); foreach($__currentLoopData as $stat): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> -
-
-
- -

- count()); ?> - -

- -
-
-
- popLoop(); $loop = $__env->getLastLoop(); ?> -
- - -
-
-
- - - - - - - - - - - - - - - addLoop($__currentLoopData); foreach($__currentLoopData as $booking): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); $__empty_1 = false; ?> - - - - - - - - - - - popLoop(); $loop = $__env->getLastLoop(); if ($__empty_1): ?> - - - - - -
IDKontrakanPenyewaPeriodeBiayaStatusPembayaranAksi
- #id); ?> - - - kontrakan->nama ?? '-'); ?> - - - -
tenant_name); ?>
- tenant_phone); ?> -
-
start_date->format('d M Y')); ?>
- s/d end_date->format('d M Y')); ?> -
- duration_days); ?> hari -
- Rp amount, 0, ',', '.')); ?> - - - status_label); ?> - - - - - payment_status_label); ?> - - - payment_proof): ?> - - - - - booking_source == 'user'): ?> - - - - - -
- - - - status == 'pending'): ?> -
- - -
- - status == 'confirmed'): ?> -
- - -
- - status == 'checked_in'): ?> -
- - -
- - user()->role == 'super_admin' || $booking->status == 'pending'): ?> -
- - - -
- -
-
- - Belum ada data booking -
-
-
- hasPages()): ?> - - -
-
-stopSection(); ?> - -make('layouts.admin', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?> \ No newline at end of file diff --git a/spk_kontrakan/storage/framework/views/2d5c12915a62c546cdaee770c4190ebb.php b/spk_kontrakan/storage/framework/views/2d5c12915a62c546cdaee770c4190ebb.php deleted file mode 100644 index 1e5b340..0000000 --- a/spk_kontrakan/storage/framework/views/2d5c12915a62c546cdaee770c4190ebb.php +++ /dev/null @@ -1,1495 +0,0 @@ - - -startSection('title', 'Tambah Kontrakan'); ?> - -startSection('content'); ?> -
- - - - - - - - - -
-
-
-
-
- - - -
-
- Informasi Dasar -
- -
- -
- -
- - - - - getBag($__errorArgs[1] ?? 'default'); -if ($__bag->has($__errorArgs[0])) : -if (isset($message)) { $__messageOriginal = $message; } -$message = $__bag->first($__errorArgs[0]); ?> -
- -
- Masukkan nama kontrakan yang mudah dikenali -
- - -
- -
- - - - - getBag($__errorArgs[1] ?? 'default'); -if ($__bag->has($__errorArgs[0])) : -if (isset($message)) { $__messageOriginal = $message; } -$message = $__bag->first($__errorArgs[0]); ?> -
- -
- Alamat lengkap beserta RT/RW jika ada. Alamat akan auto-update saat klik peta. -
- - -
- -
- - - - - getBag($__errorArgs[1] ?? 'default'); -if ($__bag->has($__errorArgs[0])) : -if (isset($message)) { $__messageOriginal = $message; } -$message = $__bag->first($__errorArgs[0]); ?> -
- -
- - - Format: 08xxx atau 628xxx (untuk kemudahan calon penyewa menghubungi) - -
-
-
- - -
-
- Lokasi & Koordinat -
- - -
-
- -
- ⭐ Cara Menentukan Lokasi (PILIH SALAH SATU): -
    -
  • - 🎯 REKOMENDASI #1: Klik di Peta -
    - Klik pada peta di lokasi kontrakan → Alamat & koordinat otomatis terisi
    - Paling Akurat & Mudah! -
    -
  • -
  • - 📍 Deteksi GPS -
    - Klik "Deteksi Lokasi Saya" HANYA jika Anda sedang berada di lokasi kontrakan -
    -
  • -
  • - ⌨️ Input Manual Koordinat -
    - Ketik latitude & longitude dari Google Maps → Marker otomatis pindah -
    -
  • -
  • - 🔍 Cari dari Alamat -
    - ⚠️ Hanya untuk alamat umum (contoh: "Sumbersari, Jember")
    - ❌ TIDAK untuk nama perumahan/kontrakan spesifik -
    -
  • -
-
- - 💡 Tips: Jika alamat berisi nama perumahan/gang (contoh: "Perumahan Melati, Gang Mawar"), - gunakan cara KLIK PETA atau INPUT KOORDINAT manual, - jangan gunakan tombol "Cari dari Alamat" - -
-
-
-
- - -
-
- - - - Hanya untuk alamat umum (Kota/Kecamatan), bukan nama perumahan! - -
-
- - - Gunakan GPS Anda (harus di lokasi) - -
-
- -
- -
- -
- - - - - getBag($__errorArgs[1] ?? 'default'); -if ($__bag->has($__errorArgs[0])) : -if (isset($message)) { $__messageOriginal = $message; } -$message = $__bag->first($__errorArgs[0]); ?> -
- -
- Range: -90 sampai 90 -
- - -
- -
- - - - - getBag($__errorArgs[1] ?? 'default'); -if ($__bag->has($__errorArgs[0])) : -if (isset($message)) { $__messageOriginal = $message; } -$message = $__bag->first($__errorArgs[0]); ?> -
- -
- Range: -180 sampai 180 -
- - -
-
- - Klik pada peta → alamat & koordinat otomatis terisi | Drag marker untuk ubah posisi - -
-
-
- - -
-
- Detail Properti -
- -
- -
- -
- - - - - getBag($__errorArgs[1] ?? 'default'); -if ($__bag->has($__errorArgs[0])) : -if (isset($message)) { $__messageOriginal = $message; } -$message = $__bag->first($__errorArgs[0]); ?> -
- -
- Harga dalam Rupiah per tahun - -
- - -
- -
- - - - - kamar - getBag($__errorArgs[1] ?? 'default'); -if ($__bag->has($__errorArgs[0])) : -if (isset($message)) { $__messageOriginal = $message; } -$message = $__bag->first($__errorArgs[0]); ?> -
- -
- Jumlah kamar tidur yang tersedia -
- - Total kamar mandi (sistem akan otomatis menghitung kenyamanan) - -
- - -
- -
- - - - - meter - getBag($__errorArgs[1] ?? 'default'); -if ($__bag->has($__errorArgs[0])) : -if (isset($message)) { $__messageOriginal = $message; } -$message = $__bag->first($__errorArgs[0]); ?> -
- -
- - - Jarak otomatis dihitung dari koordinat kontrakan ke kampus - -
- - -
- -
- - - - - getBag($__errorArgs[1] ?? 'default'); -if ($__bag->has($__errorArgs[0])) : -if (isset($message)) { $__messageOriginal = $message; } -$message = $__bag->first($__errorArgs[0]); ?> -
- -
- Pisahkan dengan koma (,) untuk fasilitas lebih dari satu -
-
-
- - -
-
- Upload Foto -
- -
- -
- -
- - - - - getBag($__errorArgs[1] ?? 'default'); -if ($__bag->has($__errorArgs[0])) : -if (isset($message)) { $__messageOriginal = $message; } -$message = $__bag->first($__errorArgs[0]); ?> -
- -
- Format: JPG, PNG, JPEG. Maksimal 2MB -
- - -
- -
-
-
- - - - - -
- - Batal - - -
- -
-
- - -
-
-
- -
-
-
Tips Pengisian Data:
-
    -
  • Pastikan semua data yang ditandai (*) wajib diisi
  • -
  • CARA TERBAIK: Klik langsung pada peta untuk menentukan lokasi! Alamat akan otomatis terisi.
  • -
  • PENTING: Tombol "Cari dari Alamat" HANYA untuk alamat umum seperti "Jember" atau "Sumbersari", BUKAN untuk nama perumahan spesifik.
  • -
  • Alternatif: Copy koordinat dari Google Maps, lalu ketik manual di form (marker otomatis pindah)
  • -
  • Koordinat bisa input manual, marker otomatis pindah!
  • -
  • Jarak ke kampus akan otomatis dihitung setelah koordinat terisi
  • -
  • Nomor WhatsApp memudahkan calon penyewa untuk menghubungi
  • -
-
-
-
-
-
- - - - - - - - - - -stopSection(); ?> - -make('layouts.admin', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?> \ No newline at end of file diff --git a/spk_kontrakan/storage/framework/views/3e2a756e698ea84296e208d7b9a94af0.php b/spk_kontrakan/storage/framework/views/3e2a756e698ea84296e208d7b9a94af0.php deleted file mode 100644 index 0eafb89..0000000 --- a/spk_kontrakan/storage/framework/views/3e2a756e698ea84296e208d7b9a94af0.php +++ /dev/null @@ -1,427 +0,0 @@ - - -startSection('title', 'Portal Pemilik - Kelola Kontrakan & Laundry Anda'); ?> - -startSection('content'); ?> - - - -
-
- - 🏢 Portal Khusus Pemilik - -

- Kelola Properti Anda
dengan Mudah & Efisien -

-

- Platform manajemen kontrakan dan laundry yang profesional. Pantau bisnis Anda, kelola pemesanan, dan tingkatkan pendapatan dengan sistem yang terintegrasi. -

- -
-
- -
- -
-
-

- Fitur Lengkap untuk Pemilik -

-

- Semua yang Anda butuhkan untuk mengelola bisnis properti Anda -

-
- -
-
-
🏠
-

Manajemen Kontrakan

-

- Kelola data kontrakan, upload foto, atur harga, dan update informasi dengan mudah. Sistem lengkap dengan galeri foto. -

-
- -
-
👕
-

Kelola Laundry

-

- Tambahkan layanan laundry, atur harga per layanan, dan kelola pesanan pelanggan secara real-time. -

-
- -
-
📅
-

Sistem Booking

-

- Pantau pemesanan masuk, konfirmasi booking, dan kelola jadwal ketersediaan properti Anda. -

-
- -
-
-

Review & Rating

-

- Lihat ulasan pelanggan, tingkatkan kualitas layanan, dan bangun reputasi bisnis yang lebih baik. -

-
- -
-
📊
-

Dashboard Analytics

-

- Pantau performa bisnis dengan statistik lengkap, grafik penjualan, dan laporan yang detail. -

-
- -
-
📱
-

Responsive Design

-

- Kelola bisnis dari mana saja, kapan saja. Akses dashboard dari smartphone, tablet, atau komputer. -

-
-
-
- - -
-
-

- Platform Terpercaya -

-

- Bergabunglah dengan pemilik lainnya yang sudah merasakan kemudahan -

-
- -
-
-
+
-
Pemilik Terdaftar
-
-
-
+
-
Properti Dikelola
-
-
-
+
-
Booking Sukses
-
-
-
-
Rating Rata-rata
-
-
-
-
- - -
-
-

Siap Memulai?

-

- Daftar sekarang dan dapatkan akses penuh ke dashboard manajemen properti -

- - Daftar Gratis Sekarang - -
-
- - - -stopSection(); ?> - -make('layouts.app', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?> \ No newline at end of file diff --git a/spk_kontrakan/storage/framework/views/585ad336c34df32b9792eabb80ba9a63.php b/spk_kontrakan/storage/framework/views/585ad336c34df32b9792eabb80ba9a63.php deleted file mode 100644 index 43a35e3..0000000 --- a/spk_kontrakan/storage/framework/views/585ad336c34df32b9792eabb80ba9a63.php +++ /dev/null @@ -1,1680 +0,0 @@ - - -startSection('title', 'Edit Kontrakan'); ?> - -startSection('content'); ?> -
- - - - - - - - - -
-
-
-
-
- - - - -
-
- Informasi Dasar -
- -
- -
- -
- - - - - getBag($__errorArgs[1] ?? 'default'); -if ($__bag->has($__errorArgs[0])) : -if (isset($message)) { $__messageOriginal = $message; } -$message = $__bag->first($__errorArgs[0]); ?> -
- -
- Masukkan nama kontrakan yang mudah dikenali -
- - -
- -
- - - - - getBag($__errorArgs[1] ?? 'default'); -if ($__bag->has($__errorArgs[0])) : -if (isset($message)) { $__messageOriginal = $message; } -$message = $__bag->first($__errorArgs[0]); ?> -
- -
- Alamat lengkap beserta RT/RW jika ada. Alamat akan auto-update saat klik peta. -
- - -
- -
- - - - - getBag($__errorArgs[1] ?? 'default'); -if ($__bag->has($__errorArgs[0])) : -if (isset($message)) { $__messageOriginal = $message; } -$message = $__bag->first($__errorArgs[0]); ?> -
- -
- - - Format: 08xxx atau 628xxx (untuk kemudahan calon penyewa menghubungi) - -
-
-
- - -
-
- Lokasi & Koordinat -
- - -
-
- -
- ⭐ Cara Menentukan Lokasi (PILIH SALAH SATU): -
    -
  • - 🎯 REKOMENDASI #1: Klik di Peta -
    - Klik pada peta di lokasi kontrakan → Alamat & koordinat otomatis terisi
    - Paling Akurat & Mudah! -
    -
  • -
  • - 📍 Deteksi GPS -
    - Klik "Deteksi Lokasi Saya" HANYA jika Anda sedang berada di lokasi kontrakan -
    -
  • -
  • - ⌨️ Input Manual Koordinat -
    - Ketik latitude & longitude dari Google Maps → Marker otomatis pindah -
    -
  • -
  • - 🔍 Cari dari Alamat -
    - ⚠️ Hanya untuk alamat umum (contoh: "Sumbersari, Jember")
    - ❌ TIDAK untuk nama perumahan/kontrakan spesifik -
    -
  • -
-
- - 💡 Tips: Jika alamat berisi nama perumahan/gang (contoh: "Perumahan Melati, Gang Mawar"), - gunakan cara KLIK PETA atau INPUT KOORDINAT manual, - jangan gunakan tombol "Cari dari Alamat" - -
-
-
-
- - -
-
- - - - Hanya untuk alamat umum (Kota/Kecamatan), bukan nama perumahan! - -
-
- - - Gunakan GPS Anda (harus di lokasi) - -
-
- -
- -
- -
- - - - - getBag($__errorArgs[1] ?? 'default'); -if ($__bag->has($__errorArgs[0])) : -if (isset($message)) { $__messageOriginal = $message; } -$message = $__bag->first($__errorArgs[0]); ?> -
- -
- Range: -90 sampai 90 -
- - -
- -
- - - - - getBag($__errorArgs[1] ?? 'default'); -if ($__bag->has($__errorArgs[0])) : -if (isset($message)) { $__messageOriginal = $message; } -$message = $__bag->first($__errorArgs[0]); ?> -
- -
- Range: -180 sampai 180 -
- - -
-
- - Klik pada peta → alamat & koordinat otomatis terisi | Drag marker untuk ubah posisi - -
-
-
- - -
-
- Detail Properti -
- -
- -
- -
- - - - - getBag($__errorArgs[1] ?? 'default'); -if ($__bag->has($__errorArgs[0])) : -if (isset($message)) { $__messageOriginal = $message; } -$message = $__bag->first($__errorArgs[0]); ?> -
- -
- Harga dalam Rupiah per tahun - -
- - -
- -
- - - - - kamar - getBag($__errorArgs[1] ?? 'default'); -if ($__bag->has($__errorArgs[0])) : -if (isset($message)) { $__messageOriginal = $message; } -$message = $__bag->first($__errorArgs[0]); ?> -
- -
- Jumlah kamar tidur yang tersedia -
- - -
- -
- - - - - kamar - getBag($__errorArgs[1] ?? 'default'); -if ($__bag->has($__errorArgs[0])) : -if (isset($message)) { $__messageOriginal = $message; } -$message = $__bag->first($__errorArgs[0]); ?> -
- -
- Informasi kamar mandi untuk detail kontrakan -
- - -
- -
- - - - - meter - getBag($__errorArgs[1] ?? 'default'); -if ($__bag->has($__errorArgs[0])) : -if (isset($message)) { $__messageOriginal = $message; } -$message = $__bag->first($__errorArgs[0]); ?> -
- -
- - - Jarak otomatis dihitung dari koordinat kontrakan ke kampus - -
- - -
- -
- - - - - getBag($__errorArgs[1] ?? 'default'); -if ($__bag->has($__errorArgs[0])) : -if (isset($message)) { $__messageOriginal = $message; } -$message = $__bag->first($__errorArgs[0]); ?> -
- -
- Pisahkan dengan koma (,) untuk fasilitas lebih dari satu -
-
-
- - -
-
- Foto Kontrakan -
- -
-
-
-
- -
- foto): ?> - <?php echo e($kontrakan->nama); ?> - -
- -

Belum ada foto

-
- - - -
- - -
- - - Format: JPG, PNG, JPEG (Max 2MB) - getBag($__errorArgs[1] ?? 'default'); -if ($__bag->has($__errorArgs[0])) : -if (isset($message)) { $__messageOriginal = $message; } -$message = $__bag->first($__errorArgs[0]); ?> -
- - - foto): ?> -
- - -
- -
-
-
-
-
-
- - -
-
- Info Perubahan -
-

- Anda sedang mengedit data kontrakan nama); ?>. - Pastikan semua perubahan sudah benar sebelum menyimpan. -

-
- - -
- - Batal - - -
-
-
-
- - -
-
-
- -
-
-
Riwayat Data:
-

- Dibuat: created_at->format('d M Y, H:i')); ?> WIB -

- updated_at != $kontrakan->created_at): ?> -

- Terakhir diupdate: updated_at->format('d M Y, H:i')); ?> WIB -

- -
-
-
-
-
-
- - - - - - - - - -stopSection(); ?> - -make('layouts.admin', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?> \ No newline at end of file diff --git a/spk_kontrakan/storage/framework/views/62b47ba91414543ff7e2fb59fd5405ad.php b/spk_kontrakan/storage/framework/views/62b47ba91414543ff7e2fb59fd5405ad.php index 05055c5..dd6fbb1 100644 --- a/spk_kontrakan/storage/framework/views/62b47ba91414543ff7e2fb59fd5405ad.php +++ b/spk_kontrakan/storage/framework/views/62b47ba91414543ff7e2fb59fd5405ad.php @@ -762,7 +762,7 @@ addLoop($__currentLoopData); foreach($__currentLoopData as $l): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
-
+
nama); ?>

alamat ?? '-', 25)); ?> diff --git a/spk_kontrakan/storage/framework/views/6b304950f45ce77007f7b02d78cabc42.php b/spk_kontrakan/storage/framework/views/6b304950f45ce77007f7b02d78cabc42.php deleted file mode 100644 index 1ab3ff8..0000000 --- a/spk_kontrakan/storage/framework/views/6b304950f45ce77007f7b02d78cabc42.php +++ /dev/null @@ -1,176 +0,0 @@ - - -startSection('title', 'Backup & Restore Database'); ?> - -startSection('content'); ?> -

- - - -
-
-
-

- Backup & Restore Database -

-

Kelola backup database untuk keamanan data

-
-
- - -
-
-
- - - - - - - - - - - -
- - Tips: Lakukan backup secara berkala untuk mengamankan data. Backup dibuat dalam format ZIP dan dapat di-download. -
- - -
-
-
- Daftar Backup () -
-
- -
- -
- -
Tidak ada backup ditemukan
-

Klik tombol "Buat Backup Baru" untuk membuat backup pertama Anda

-
- -
- addLoop($__currentLoopData); foreach($__currentLoopData as $backup): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> -
-
-
-
-
-
-
- -
-
-
- - - - - - - MB - -
-
-
- -
-
- - Download - - -
- - -
- -
- - - -
-
-
-
-
-
-
- popLoop(); $loop = $__env->getLastLoop(); ?> -
- -
-
- - -
-
-
-
-
- Backup Information -
- - Total Backups: - - - - Total Size: MB - - 0): ?> - - Latest: - - - -
-
-
- -
-
-
-
- Reminder -
- - ⚠️ Backup database secara rutin (minimal 1x sehari) untuk mencegah kehilangan data yang tidak dapat dipulihkan. - -
-
-
-
-
-stopSection(); ?> - -make('layouts.admin', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?> \ No newline at end of file diff --git a/spk_kontrakan/storage/framework/views/750ed3e845a2f0e93cc431f7924de6f1.php b/spk_kontrakan/storage/framework/views/750ed3e845a2f0e93cc431f7924de6f1.php deleted file mode 100644 index 7a13def..0000000 --- a/spk_kontrakan/storage/framework/views/750ed3e845a2f0e93cc431f7924de6f1.php +++ /dev/null @@ -1,1383 +0,0 @@ - - -startSection('title', 'Kelola Kriteria SAW'); ?> - -startSection('content'); ?> -
- - - - -
-
-
- - -
-
-
-

- - 📊 Kelola Kriteria SAW - 📊 Kriteria -

-

- Atur dan kelola kriteria penilaian untuk metode Simple Additive Weighting - Kelola kriteria SAW -

-
- -
-
- - - - - - - - - - - - - - - - - -
- -
-
- - - - -
- -
- -
- - - - -
-
- - -
- - -
- - -
-
- - 🏠 Kontrakan: where('tipe_bisnis', 'kontrakan')->count() : 0); ?> - - - 👕 Laundry: where('tipe_bisnis', 'laundry')->count() : 0); ?> - -
-
-
-
-
- - -
- isEmpty()): ?> - -
-
-
- -
-
-
Belum ada kriteria
-

Mulai tambahkan kriteria untuk penilaian SAW (Simple Additive Weighting) agar dapat melakukan perhitungan rekomendasi yang akurat.

- - Tambah Kriteria Pertama - -
- - -
-
- addLoop($__currentLoopData); foreach($__currentLoopData as $index => $item): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> -
-
- -
-
-
- tipe_bisnis == 'kontrakan'): ?> - - 🏠 Kontrakan - - - - 👕 Laundry - - -
-
- - -
nama_kriteria); ?>
- - -
-
-
- BOBOT -
- bobot); ?> - (bobot * 100, 1)); ?>%) -
-
-
-
-
- TIPE - tipe) == 'benefit'): ?> - - 📈 tipe)); ?> - - - - - 📉 tipe)); ?> - - - -
-
-
- - - keterangan): ?> -
- KETERANGAN -

keterangan); ?>

-
- - - -
- - Edit - - -
-
-
- popLoop(); $loop = $__env->getLastLoop(); ?> -
-
- - -
-
- - - - - - - - - - - - - - addLoop($__currentLoopData); foreach($__currentLoopData as $index => $item): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> - - - - - - - - - - popLoop(); $loop = $__env->getLastLoop(); ?> - - - - - - - - - -
- No - - - Tipe Bisnis - - - - - Nama Kriteria - - - - - Bobot - - - - - Tipe - - - - Keterangan - - Aksi -
-
-
- tipe_bisnis == 'kontrakan'): ?> - - 🏠 Kontrakan - - - - 👕 Laundry - - - -
-
- -
-
-
nama_kriteria); ?>
- Kriteria -
-
-
-
- bobot); ?> - (bobot * 100, 1)); ?>%) -
-
- tipe) == 'benefit'): ?> - - 📈 Benefit - - - - 📉 Cost - - - -
- - keterangan ? Str::limit($item->keterangan, 60) : '-'); ?> - - -
-
-
- - - - - user()->role == 'super_admin'): ?> - - - - -
-
-
-
📊 Validasi Total Bobot
-
- - - 🏠 - Kontrakan: - - - - - - 👕 - Laundry: - - -
-
-
-
-
- - - Catatan: Total bobot untuk setiap tipe bisnis harus = 1.00 (100%)
- agar perhitungan SAW (Simple Additive Weighting) memberikan hasil yang akurat. -
-
-
-
-
-
- Total Bobot Kontrakan: - - - - - - - - - - - - - - - - Total Bobot Laundry: - - - - - - - - - - - - -
- - - - Total: sum('bobot'), 2)); ?> - - - - - - - -
-
- - - count() > 0): ?> -
-
-
-
-
-
- -
-
📋 Informasi Kriteria SAW
-
-
-
    -
  • 📈 Benefit: Semakin tinggi nilai, semakin baik
    (contoh: Fasilitas, Kualitas Layanan)
  • -
  • 📉 Cost: Semakin rendah nilai, semakin baik
    (contoh: Harga, Jarak, Biaya)
  • -
  • ⚖️ Total Bobot: Harus = 1.00 (100%) untuk setiap tipe bisnis
    agar perhitungan SAW akurat dan valid
  • -
-
-
-
-
-
-
-
-
-
- -
-
📊 Statistik Kriteria
-
-
-
-
-
count()); ?>
- Total Kriteria -
-
-
-
-
- Benefit -
-
-
-
-
- Cost -
-
-
-
-
-
-
- -
- - - - - - - -stopSection(); ?> - -make('layouts.admin', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?> \ No newline at end of file diff --git a/spk_kontrakan/storage/framework/views/81aca8525ebe1474a20ec791c32c9770.php b/spk_kontrakan/storage/framework/views/81aca8525ebe1474a20ec791c32c9770.php index 09cd931..2006fca 100644 --- a/spk_kontrakan/storage/framework/views/81aca8525ebe1474a20ec791c32c9770.php +++ b/spk_kontrakan/storage/framework/views/81aca8525ebe1474a20ec791c32c9770.php @@ -54,6 +54,21 @@ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + + /* Shared color variables for views */ + --primary-color: #667eea; + --primary-color-rgb: 102, 126, 234; + --secondary-color: #764ba2; + --secondary-color-rgb: 118, 75, 162; + --info-color: #17a2b8; + --info-color-rgb: 23, 162, 184; + --success-color: #28a745; + --success-color-rgb: 40, 167, 69; + --warning-color: #ffc107; + --warning-color-rgb: 255, 193, 7; + --danger-color: #dc3545; + --danger-color-rgb: 220, 53, 69; + --text-muted: #6c757d; } * { diff --git a/spk_kontrakan/storage/framework/views/84dd4efe61f3c815daff24c589049140.php b/spk_kontrakan/storage/framework/views/84dd4efe61f3c815daff24c589049140.php deleted file mode 100644 index 1ab64ab..0000000 --- a/spk_kontrakan/storage/framework/views/84dd4efe61f3c815daff24c589049140.php +++ /dev/null @@ -1,2309 +0,0 @@ - - -startSection('title', 'Daftar Kontrakan'); ?> - -startSection('content'); ?> -
- - - -
-
-
- - -
-
-
-

- Kelola KontrakanKontrakan -

-

Informasi lengkap dan manajemen data kontrakan yang tersediaManajemen data kontrakan

-
- -
-
- - -
-
-
- -
-
total() ?? 0); ?>
-
Total Kontrakan
-
-
-
- -
-
-
Harga Terendah
-
-
-
- -
-
-
Jarak Terjauh
-
-
-
- -
-
-
Kamar Terbanyak
-
-
- - - - - - - - - - - - -
-
-
-
-
- - Pencarian & Filter Kontrakan -
-
-
-
- - -
-
-
-
-
-
-
- -
- -
-
- - - - - - - - -
- -
- -
- -
-
- - - 💡 Ketik "WiFi", "Dapur", "AC" atau kata kunci lainnya untuk hasil lebih spesifik - 💡 Coba "WiFi", "Dapur", "AC" - -
- - -
-
-
- - Filter Lanjutan -
- -
- -
- -
- -
-
-
-
- Min - -
-
-
-
- Max - -
-
-
-
-
- - Geser untuk minimum -
-
- - Geser untuk maksimum -
-
-
-
- - Range tersedia: Rp - Rp - Rp - -
-
- - -
- -
-
- km -
- - Geser untuk menyesuaikan -
-
- - Jarak terjauh tersedia: km - Max: km -
-
- - -
- -
-
-
-
- Min - kmr -
-
-
-
- Max - kmr -
-
-
-
-
- - Minimum kamar -
-
- - Maksimum kamar -
-
-
-
- - Range: - kamar -
-
- - - count() > 0): ?> -
- -
-
-
- addLoop($__currentLoopData); foreach($__currentLoopData as $fasilitas): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> -
-
- - - style="accent-color: var(--primary-color);" - > - -
-
- popLoop(); $loop = $__env->getLastLoop(); ?> -
-
-
- - - Pilih satu atau lebih fasilitas (hasil akan menampilkan kontrakan yang memiliki SEMUA fasilitas terpilih) - Pilih fasilitas yang diinginkan - -
- - - -
- -
-
- > - -
-
- > - -
-
- > - -
-
- > - -
-
-
-
- - -
- - - - Reset Filter - Reset - -
- -
-
-
- - -
-
-
-
- - - - - - dari - - kontrakan - - - - - - - "" - 🔍 - - - - - - - - Rp - Rp - - - 💰 - - - - - - - Max km - 📍km - - - - - - - - kamar - 🏠- - - - - - - - fasilitas - - - -
- - - 0): ?> - query(); - $queryString = !empty($queryParams) ? '?' . http_build_query($queryParams) : ''; - } catch (\Exception $e) { - $queryString = ''; - } - ?> - - -
-
-
- -
- -
-
-
- - - count()); ?> kontrakan - - - - user()->role == 'super_admin' && $kontrakan->count() > 0): ?> - - -
- - - - total()); ?> Data - -
-
- -
- isEmpty()): ?> - -
-
- -
-
- Tidak ada data kontrakan ditemukan - Data tidak ditemukan -
-

- hasAny(['search', 'harga_min', 'harga_max', 'jarak_max', 'luas_min', 'luas_max', 'fasilitas_filter'])): ?> - Tidak ada kontrakan yang sesuai dengan kriteria filter Anda.
Coba ubah atau reset filter untuk melihat hasil lain.
- Filter tidak menemukan hasil. Coba ubah atau reset filter. - - Belum ada data kontrakan. Mulai tambahkan data kontrakan pertama Anda! - Belum ada data. Tambahkan kontrakan pertama! - -

-
- hasAny(['search', 'harga_min', 'harga_max', 'jarak_max', 'luas_min', 'luas_max', 'fasilitas_filter'])): ?> - - - Reset Filter - Reset - - - - - Tambah Kontrakan - Tambah - -
-
- - -
- addLoop($__currentLoopData); foreach($__currentLoopData as $index => $item): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> -
-
-
-
- user()->role == 'super_admin'): ?> - - -
-
- - nama); ?> - - status_label); ?> -
-

- alamat, 45)); ?> - -

-
-
- - #firstItem() + $index); ?> - - -
- - -
-
- 💰 Harga/Tahun - - Rp harga, 0, ',', '.')); ?> - - -
-
- 📏 Jarak - - jarak / 1000, 1)); ?> km - -
-
- 🚪 Kamar - - jumlah_kamar); ?> Kamar - -
-
- - - fasilitas): ?> -
- ✨ Fasilitas: -
- fasilitas)), 0, 3); $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $f): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> - - popLoop(); $loop = $__env->getLastLoop(); ?> - fasilitas)) > 3): ?> - +fasilitas)) - 3); ?> - -
-
- - - - hasWhatsapp()): ?> - - Hubungi Pemilik via WhatsApp - - - - -
- latitude && $item->longitude): ?> - - - - - - - - - - - - user()->role == 'super_admin'): ?> - -
- - -
- -
-
-
- popLoop(); $loop = $__env->getLastLoop(); ?> -
- - -
- - - - user()->role == 'super_admin'): ?> - - - - - - - - - - - - - - - addLoop($__currentLoopData); foreach($__currentLoopData as $index => $item): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> - - user()->role == 'super_admin'): ?> - - - - - - - - - - - - - popLoop(); $loop = $__env->getLastLoop(); ?> - -
-
- -
-
- No - - Nama Kontrakan - - Alamat - - Harga/Tahun - - Jarak - - Fasilitas - - Kamar - - Aksi -
- - - firstItem() + $index); ?> - -
-
- -
-
-
- nama); ?> - - status_label); ?> -
- hasWhatsapp()): ?> - - Ada kontak WhatsApp - - -
-
-
- - alamat, 50)); ?> - - - - - - Rp harga, 0, ',', '.')); ?> - - - - - jarak / 1000, 1)); ?> km - - - fasilitas): ?> -
- fasilitas)), 0, 2); $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $f): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> - - popLoop(); $loop = $__env->getLastLoop(); ?> - fasilitas)) > 2): ?> - +fasilitas)) - 2); ?> - -
- - - - -
- - jumlah_kamar); ?> Kamar - - -
- latitude && $item->longitude): ?> - - - - - - - - - - - - user()->role == 'super_admin'): ?> - - -
- - -
- - - -
-
-
- -
- - - hasPages()): ?> - - -
-
- - -
-
-
-
- - - - - -
- - -
- - - - - -stopSection(); ?> - -startSection('scripts'); ?> - -stopSection(); ?> - -make('layouts.admin', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?> \ No newline at end of file diff --git a/spk_kontrakan/storage/framework/views/997e6940f3b97384fb2e68c445e4a212.php b/spk_kontrakan/storage/framework/views/997e6940f3b97384fb2e68c445e4a212.php deleted file mode 100644 index 22c7ef2..0000000 --- a/spk_kontrakan/storage/framework/views/997e6940f3b97384fb2e68c445e4a212.php +++ /dev/null @@ -1,214 +0,0 @@ - - -startSection('title', 'Kelola User'); ?> - -startSection('content'); ?> -
- - - -
-
-
-

- Kelola User Administrator -

-

Manage user accounts dan permissions

-
- -
-
- - - - - - - -
-
-
-
- - -
- -
- - -
- -
- - -
- -
- - - Reset - -
-
-
-
- - -
-
-
-
- Daftar User (total()); ?>) -
-
-
- -
- isEmpty()): ?> -
- -
Tidak ada user ditemukan
-
- -
- - - - - - - - - - - - - addLoop($__currentLoopData); foreach($__currentLoopData as $user): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> - - - - - - - - - popLoop(); $loop = $__env->getLastLoop(); ?> - -
NamaEmailRoleStatusDibuatAksi
-
-
- name, 0, 1))); ?> - -
- name); ?> -
-
- email); ?> - - role === 'super_admin'): ?> - Super Admin - - Admin - - - deleted_at): ?> - Tidak Aktif - - Aktif - - - created_at->format('d M Y')); ?> - -
- deleted_at): ?> -
- - -
- - - - - - - id !== auth()->id()): ?> -
- - - -
- -
-
-
- -
-
- - -
- links()); ?> - -
-
-stopSection(); ?> - -make('layouts.admin', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?> \ No newline at end of file diff --git a/spk_kontrakan/storage/framework/views/b013a8c110c7b48f95c189a44c72a9f6.php b/spk_kontrakan/storage/framework/views/b013a8c110c7b48f95c189a44c72a9f6.php deleted file mode 100644 index a26911e..0000000 --- a/spk_kontrakan/storage/framework/views/b013a8c110c7b48f95c189a44c72a9f6.php +++ /dev/null @@ -1,1763 +0,0 @@ - - -startSection('title', 'Daftar Laundry'); ?> - -startSection('content'); ?> -
- - - - -
-
-
- - -
-
-
-

- - 🧺 Daftar Laundry - 🧺 Laundry -

-

- Kelola dan pantau data layanan laundry yang tersedia -

-
- -
-
- - - - - - - - - - - - -
-
-
-
-
- - Pencarian & Filter Laundry -
-
-
- -
-
-
- -
-
-
- -
- -
- - - - - -
- - - 💡 Coba "WiFi", "Antar Jemput", "24 Jam" untuk hasil lebih spesifik - 💡 Coba "WiFi", "Antar Jemput" - -
- - -
-
-
- - Filter Lanjutan -
- -
- -
- -
- -
-
-
-
- Min - -
-
-
-
- Max - -
-
-
-
-
- - Geser untuk minimum -
-
- - Geser untuk maksimum -
-
-
-
- - Range: Rp - Rp - Rp - -
-
- - -
- -
-
- km -
- - Geser untuk menyesuaikan -
-
- - Jarak terjauh: km - Max: km -
-
- - -
- -
-
-
- addLoop($__currentLoopData); foreach($__currentLoopData as $jenis): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> -
-
- - - style="accent-color: #667eea;" - > - -
-
- popLoop(); $loop = $__env->getLastLoop(); ?> -
-
-
-
- - - count() > 0): ?> -
- -
-
-
- addLoop($__currentLoopData); foreach($__currentLoopData as $fasilitas): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> -
-
- - - style="accent-color: #ffc107;" - > - -
-
- popLoop(); $loop = $__env->getLastLoop(); ?> -
-
-
- - - Pilih fasilitas (hasil akan menampilkan laundry yang memiliki SEMUA fasilitas terpilih) - Pilih fasilitas yang diinginkan - -
- - - -
- -
-
- > - -
-
- > - -
-
- > - -
-
- > - -
-
-
-
- - -
- - - - Reset Filter - Reset - -
-
-
-
-
-
- - -
-
-
-
- - - - - - dari - - laundry - - - - - - - "" - 🔍 - - - - - - - - Rp - Rp - - - 💰 - - - - - - - Max km - 📍km - - - - - - - layanan - - - - - - - - fasilitas - - - -
- - - 0): ?> - -
-
-
- - - -
- -
-
-
- - - count()); ?> laundry - - - user()->role == 'super_admin' && $laundry->count() > 0): ?> - - -
- - - count()); ?> Data - -
-
- -
- isEmpty()): ?> - -
-
- -
-
- Tidak ada data laundry ditemukan - Data tidak ditemukan -
-

- hasAny(['search', 'harga_min', 'harga_max', 'jarak', 'jenis_layanan'])): ?> - Tidak ada laundry yang sesuai dengan kriteria filter. Coba ubah atau reset filter. - Filter tidak menemukan hasil. Coba ubah filter. - - Belum ada data laundry. Mulai tambahkan layanan laundry pertama Anda! - Belum ada data. Tambahkan laundry pertama! - -

-
- hasAny(['search', 'harga_min', 'harga_max', 'jarak', 'jenis_layanan'])): ?> - - - Reset Filter - Reset - - - - - Tambah Laundry - Tambah - - -
-
- - -
- addLoop($__currentLoopData); foreach($__currentLoopData as $index => $item): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> - layanan; - if (!empty($filters['jenis_layanan'])) { - $filteredLayanan = $item->layanan->where('jenis_layanan', $filters['jenis_layanan']); - } - ?> -
-
-
-
- user()->role == 'super_admin'): ?> - - -
-
- - nama); ?> - -
-

- alamat, 45)); ?> - -

-
-
- - # - - -
- - - isNotEmpty()): ?> -
-
- addLoop($__currentLoopData); foreach($__currentLoopData as $layanan): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> - -
-
- Harga -
- Rp harga, 0, ',', '.')); ?> - -
-
-
- - -
-
- Jenis Layanan -
- jenis_layanan)); ?> - -
-
-
- - -
-
- Estimasi Selesai -
- estimasi_selesai); ?> Jam -
-
-
- popLoop(); $loop = $__env->getLastLoop(); ?> -
-
- - - -
-
-
-
- Jarak dari Kampus -
- jarak / 1000, 1)); ?> km -
-
-
-
-
- - -
- latitude && $item->longitude): ?> - - - - - - - - - - - - user()->role == 'super_admin'): ?> - -
- - -
- -
-
-
- popLoop(); $loop = $__env->getLastLoop(); ?> -
- - -
- - - - user()->role == 'super_admin'): ?> - - - - - - - - - - - - - - - addLoop($__currentLoopData); foreach($__currentLoopData as $index => $item): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> - layanan; - if (!empty($filters['jenis_layanan'])) { - $filteredLayanan = $item->layanan->where('jenis_layanan', $filters['jenis_layanan']); - } - ?> - - user()->role == 'super_admin'): ?> - - - - - - - - - - - - - - - - - - - - - - popLoop(); $loop = $__env->getLastLoop(); ?> - -
-
- -
-
- No - - Nama Laundry - - Alamat - - Harga - - Jarak - - Jenis Layanan - - Estimasi - - Aksi -
- - - firstItem() + $index); ?> - -
-
- -
-
-
nama); ?>
-
-
-
- - alamat, 50)); ?> - - - - isNotEmpty()): ?> -
- addLoop($__currentLoopData); foreach($__currentLoopData as $layanan): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> - - Rp harga, 0, ',', '.')); ?> - - - popLoop(); $loop = $__env->getLastLoop(); ?> -
- - - - -
- - jarak / 1000, 1)); ?> km - - - isNotEmpty()): ?> -
- addLoop($__currentLoopData); foreach($__currentLoopData as $layanan): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> - - jenis_layanan)); ?> - - - popLoop(); $loop = $__env->getLastLoop(); ?> -
- - - - -
- isNotEmpty()): ?> -
- addLoop($__currentLoopData); foreach($__currentLoopData as $layanan): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> - - estimasi_selesai); ?> jam - - popLoop(); $loop = $__env->getLastLoop(); ?> -
- - - - -
-
- latitude && $item->longitude): ?> - - - - - - - - - - - - user()->role == 'super_admin'): ?> - -
- - -
- -
-
-
- -
- - - hasPages()): ?> - - -
-
- - - - - - -
- - -
- - - - - - - -stopSection(); ?> - -make('layouts.admin', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?> \ No newline at end of file diff --git a/spk_kontrakan/storage/framework/views/ee70804036bd06aaaf7aa7c82c92bb1c.php b/spk_kontrakan/storage/framework/views/ee70804036bd06aaaf7aa7c82c92bb1c.php deleted file mode 100644 index 89ae979..0000000 --- a/spk_kontrakan/storage/framework/views/ee70804036bd06aaaf7aa7c82c92bb1c.php +++ /dev/null @@ -1,821 +0,0 @@ - - -startSection('title', 'Metode SAW'); ?> - -startSection('content'); ?> -
- - - - - - -
-

🧮 Metode SAW

-

Sistem Pendukung Keputusan untuk rekomendasi terbaik

-
- - - - - - - -
-
-
- -
-
-
Tentang Metode SAW
-

- Metode SAW digunakan untuk menentukan ranking alternatif terbaik berdasarkan kriteria yang telah ditentukan. - Sistem akan menghitung nilai normalisasi dan bobot untuk setiap kriteria, kemudian menghasilkan peringkat rekomendasi. -

-
-
-
- - -
-
- -
-
-
- Pilih Jenis Pencarian -
- -
- - - - - - -
- -
- - -
- - -
- - -
-
- - -
-
- -
- Jarak Kontrakan -

- Jarak dihitung otomatis dari Kampus Polije. - Sistem akan merekomendasikan kontrakan terdekat dari kampus. -

-
-
-
- - - - - - - - -
-
-
- Kriteria yang Digunakan -
-
- - - - - - - - - - count() > 0): ?> - addLoop($__currentLoopData); foreach($__currentLoopData as $k): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> - - - - - - popLoop(); $loop = $__env->getLastLoop(); ?> - - - - - - - - - - - - - -
KriteriaBobotTipe
nama_kriteria ?? 'N/A'); ?> - bobot ?? 0); ?> - - tipe) && strtolower($k->tipe) == 'benefit'): ?> - - Benefit - - - - Cost - - -
Tidak ada kriteria yang tersedia
Total Bobot - - where('tipe_bisnis', 'kontrakan')->sum('bobot') : 0); ?> - - -
-
-
-
- - -
- -
- - - -
-
-
- - -
-
-
-
-
- Kelebihan SAW -
-
    -
  • Mudah dipahami
  • -
  • Perhitungan sederhana
  • -
  • Hasil objektif
  • -
  • Jarak dihitung otomatis dari GPS
  • -
-
-
-
-
-
-
-
- Cara Kerja -
-
    -
  1. Kontrakan: Jarak dari Kampus Polije
  2. -
  3. Laundry: Jarak dari Kampus atau Lokasi Anda
  4. -
  5. Normalisasi nilai kriteria
  6. -
  7. Kalikan dengan bobot
  8. -
  9. Urutkan berdasarkan nilai tertinggi
  10. -
-
-
-
-
-
-
-
- - -
- - -stopSection(); ?> - -make('layouts.admin', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?> \ No newline at end of file diff --git a/spk_kontrakan/storage/framework/views/f9330509a01e8be5e3e2318d14daa2d7.php b/spk_kontrakan/storage/framework/views/f9330509a01e8be5e3e2318d14daa2d7.php index d6a471c..3b2c4d8 100644 --- a/spk_kontrakan/storage/framework/views/f9330509a01e8be5e3e2318d14daa2d7.php +++ b/spk_kontrakan/storage/framework/views/f9330509a01e8be5e3e2318d14daa2d7.php @@ -174,7 +174,6 @@ diff --git a/spk_kontrakan/storage/framework/views/f9a1a87513eabf56a496d6aacefe5611.php b/spk_kontrakan/storage/framework/views/f9a1a87513eabf56a496d6aacefe5611.php deleted file mode 100644 index c32b73c..0000000 --- a/spk_kontrakan/storage/framework/views/f9a1a87513eabf56a496d6aacefe5611.php +++ /dev/null @@ -1,109 +0,0 @@ -hasPages()): ?> - - - \ No newline at end of file diff --git a/spk_kontrakan/storage/framework/views/fb61136dff9b1102c61099d21d6a923f.php b/spk_kontrakan/storage/framework/views/fb61136dff9b1102c61099d21d6a923f.php deleted file mode 100644 index 58fb098..0000000 --- a/spk_kontrakan/storage/framework/views/fb61136dff9b1102c61099d21d6a923f.php +++ /dev/null @@ -1,957 +0,0 @@ - - -startSection('title', 'Tambah Laundry'); ?> - -startSection('content'); ?> -
- - - - - - - - - -
-
-
-
-
- - - -
-
- Informasi Dasar -
- -
- -
- -
- - - - - getBag($__errorArgs[1] ?? 'default'); -if ($__bag->has($__errorArgs[0])) : -if (isset($message)) { $__messageOriginal = $message; } -$message = $__bag->first($__errorArgs[0]); ?> -
- -
- Masukkan nama laundry yang mudah dikenali -
- - -
- -
- - - - - getBag($__errorArgs[1] ?? 'default'); -if ($__bag->has($__errorArgs[0])) : -if (isset($message)) { $__messageOriginal = $message; } -$message = $__bag->first($__errorArgs[0]); ?> -
- -
- Alamat lengkap beserta patokan jika ada -
- - -
-
- -
- Perhitungan Jarak Otomatis -

- Jarak akan dihitung otomatis dari Kampus Polije berdasarkan koordinat GPS yang Anda tentukan. -

-
-
-
- - -
- -
- - - - - meter - getBag($__errorArgs[1] ?? 'default'); -if ($__bag->has($__errorArgs[0])) : -if (isset($message)) { $__messageOriginal = $message; } -$message = $__bag->first($__errorArgs[0]); ?> -
- -
- - - Jarak otomatis dihitung dari koordinat laundry ke kampus - -
- - -
- -
- - - - - getBag($__errorArgs[1] ?? 'default'); -if ($__bag->has($__errorArgs[0])) : -if (isset($message)) { $__messageOriginal = $message; } -$message = $__bag->first($__errorArgs[0]); ?> -
- -
- Fasilitas yang disediakan laundry ini -
-
-
- - -
-
- Lokasi & Koordinat -
- -
- - Tips: Klik pada peta untuk menentukan lokasi, atau gunakan tombol "Deteksi Lokasi Saya" untuk mendapatkan koordinat otomatis. -
- -
- -
- -
- - - - - getBag($__errorArgs[1] ?? 'default'); -if ($__bag->has($__errorArgs[0])) : -if (isset($message)) { $__messageOriginal = $message; } -$message = $__bag->first($__errorArgs[0]); ?> -
- -
-
- - -
- -
- - - - - getBag($__errorArgs[1] ?? 'default'); -if ($__bag->has($__errorArgs[0])) : -if (isset($message)) { $__messageOriginal = $message; } -$message = $__bag->first($__errorArgs[0]); ?> -
- -
-
- - -
- - Atau klik langsung pada peta di bawah -
- - -
-
-
-
-
- - -
-
-
- Jenis Layanan -
- -
- -
- -
-
-
-
Layanan #1
- -
- -
- -
- - -
- - -
- - -
- - -
- -
- Rp - -
-
- - -
- - -
- - -
- - -
- - -
- - -
-
-
-
-
- - getBag($__errorArgs[1] ?? 'default'); -if ($__bag->has($__errorArgs[0])) : -if (isset($message)) { $__messageOriginal = $message; } -$message = $__bag->first($__errorArgs[0]); ?> -
- -
- - -
-
- Upload Foto -
- -
-
- - - Format: JPG, PNG, JPEG (Maksimal 2MB). Kosongkan jika tidak ingin upload foto. - getBag($__errorArgs[1] ?? 'default'); -if ($__bag->has($__errorArgs[0])) : -if (isset($message)) { $__messageOriginal = $message; } -$message = $__bag->first($__errorArgs[0]); ?> -
- -
- - -
- -
-
-
- - -
- - Batal - - -
-
-
-
- - -
-
-
-
- -
-
-
Tips Pengisian:
-
    -
  • Minimal harus ada 1 jenis layanan
  • -
  • Klik pada peta untuk menentukan lokasi laundry
  • -
  • Koordinat akan terisi otomatis saat klik peta
  • -
  • Reguler: Layanan normal dengan harga standar
  • -
  • Express: Layanan cepat dengan harga lebih tinggi
  • -
  • Kilat: Layanan super cepat dengan harga premium
  • -
-
-
-
-
-
-
-
- - - - - - - - - - - -stopSection(); ?> -make('layouts.admin', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?> \ No newline at end of file diff --git a/spk_mobile/lib/config/app_config.dart b/spk_mobile/lib/config/app_config.dart index f8c5398..3169946 100644 --- a/spk_mobile/lib/config/app_config.dart +++ b/spk_mobile/lib/config/app_config.dart @@ -1,14 +1,27 @@ class AppConfig { - // Base URL - Ganti sesuai environment - // Windows Desktop: http://localhost:8000 - // Android Emulator: http://10.0.2.2:8000 - // iOS Simulator: http://localhost:8000 - // Real Device: http://192.168.18.16:8000 (IP komputer Anda) + // ============================================================================ + // 🔧 BASE URL CONFIGURATION - AUTO-DETECTED AT RUNTIME + // ============================================================================ + // URL ini otomatis dideteksi saat app startup via ServerDiscoveryService. + // Fallback default: IP terakhir yang berhasil terhubung. + // + // ✅ Tidak perlu update manual lagi! + // ============================================================================ - // CATATAN: Untuk real device, ganti IP sesuai dengan IP komputer Anda - // Cek IP dengan: ipconfig (di Windows) atau ifconfig (di Linux/Mac) - static const String baseUrl = 'http://192.168.18.16:8000/api'; - static const String storageUrl = 'http://192.168.18.16:8000/storage'; + // Default fallback (dipakai jika auto-detect gagal) + static const String _defaultServer = 'http://192.168.1.154:8000'; + + // Runtime values — diupdate otomatis oleh ServerDiscoveryService + static String _serverUrl = _defaultServer; + + static String get serverUrl => _serverUrl; + static String get baseUrl => '$_serverUrl/api'; + static String get storageUrl => '$_serverUrl/storage'; + + /// Dipanggil oleh ServerDiscoveryService setelah server ditemukan + static void setServerUrl(String url) { + _serverUrl = url; + } // Timeouts static const Duration connectionTimeout = Duration(seconds: 10); diff --git a/spk_mobile/lib/config/environment.dart b/spk_mobile/lib/config/environment.dart new file mode 100644 index 0000000..b11fb9e --- /dev/null +++ b/spk_mobile/lib/config/environment.dart @@ -0,0 +1,24 @@ +// Environment configuration yang dapat berubah runtime +class Environment { + // 💻 For Development + // Android Emulator = http://10.0.2.2:8000 + // iOS Simulator = http://localhost:8000 + // Real Device = Ganti dengan IP komputer Anda + + // Pilih sesuai platform dan environment saat development + static const String apiBaseUrl = String.fromEnvironment( + 'API_BASE_URL', + defaultValue: 'http://192.168.1.154:8000', // Default IP lokal + ); + + static const String storageBaseUrl = String.fromEnvironment( + 'STORAGE_BASE_URL', + defaultValue: 'http://192.168.1.154:8000/storage', + ); + + // Mode debugging + static const bool isDebugMode = true; +} + +// Catatan: Untuk production: +// flutter run --dart-define=API_BASE_URL=https://api.production.com diff --git a/spk_mobile/lib/login.dart b/spk_mobile/lib/login.dart index f434062..2179814 100644 --- a/spk_mobile/lib/login.dart +++ b/spk_mobile/lib/login.dart @@ -210,24 +210,34 @@ class _LoginScreenState extends State { setState(() => _isLoading = true); - final result = await _authService.login( - email: _emailController.text.trim(), - password: _passwordController.text, - ); - - setState(() => _isLoading = false); - - if (!mounted) return; - - if (result['success']) { - Navigator.pushReplacement( - context, - MaterialPageRoute(builder: (context) => const ImprovedHomeScreen()), + try { + final result = await _authService.login( + email: _emailController.text.trim(), + password: _passwordController.text, ); - } else { + + if (!mounted) return; + setState(() => _isLoading = false); + + if (result['success']) { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => const ImprovedHomeScreen()), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(result['message'] ?? 'Login gagal'), + backgroundColor: Colors.red, + ), + ); + } + } catch (e) { + if (!mounted) return; + setState(() => _isLoading = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(result['message'] ?? 'Login gagal'), + content: Text('Terjadi kesalahan: $e'), backgroundColor: Colors.red, ), ); diff --git a/spk_mobile/lib/main.dart b/spk_mobile/lib/main.dart index 5a8c357..d6196b8 100644 --- a/spk_mobile/lib/main.dart +++ b/spk_mobile/lib/main.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'login.dart'; import 'screens/improved_home_screen.dart'; import 'services/auth_service.dart'; +import 'services/server_discovery_service.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); @@ -110,6 +111,7 @@ class _SplashScreenState extends State late AnimationController _animController; late Animation _fadeAnim; late Animation _scaleAnim; + String _statusText = 'Memulai aplikasi...'; @override void initState() { @@ -135,8 +137,16 @@ class _SplashScreenState extends State } Future _checkAuth() async { + // 🔍 Auto-detect server IP + await ServerDiscoveryService.discover( + onStatus: (status) { + if (mounted) setState(() => _statusText = status); + }, + ); + + if (mounted) setState(() => _statusText = 'Memeriksa sesi...'); await _authService.loadToken(); - await Future.delayed(const Duration(milliseconds: 1600)); + await Future.delayed(const Duration(milliseconds: 400)); if (!mounted) return; @@ -228,6 +238,14 @@ class _SplashScreenState extends State ), ), ), + const SizedBox(height: 16), + Text( + _statusText, + style: TextStyle( + fontSize: 13, + color: Colors.white.withOpacity(0.7), + ), + ), ], ), ), diff --git a/spk_mobile/lib/models/kontrakan.dart b/spk_mobile/lib/models/kontrakan.dart index 7f4219a..87b047c 100644 --- a/spk_mobile/lib/models/kontrakan.dart +++ b/spk_mobile/lib/models/kontrakan.dart @@ -1,3 +1,5 @@ +import '../config/app_config.dart'; + class Kontrakan { final int id; final String nama; @@ -108,8 +110,7 @@ class Kontrakan { if (foto!.startsWith('http')) { return foto!; } - const String baseUrl = 'http://192.168.18.16:8000'; - return '$baseUrl/uploads/Kontrakan/$foto'; + return '${AppConfig.serverUrl}/uploads/Kontrakan/$foto'; } // 2. Fall back to galeri items if foto is not set if (galeri.isNotEmpty) { @@ -121,8 +122,7 @@ class Kontrakan { return primary.foto; } if (primary.foto.isNotEmpty) { - const String baseUrl = 'http://192.168.18.16:8000'; - return '$baseUrl/uploads/Kontrakan/${primary.foto}'; + return '${AppConfig.serverUrl}/uploads/Kontrakan/${primary.foto}'; } } return 'https://via.placeholder.com/300'; @@ -168,7 +168,6 @@ class Galeri { return foto; } // Build full URL from uploads - const String baseUrl = 'http://192.168.18.16:8000'; - return '$baseUrl/uploads/Kontrakan/$foto'; + return '${AppConfig.serverUrl}/uploads/Kontrakan/$foto'; } } diff --git a/spk_mobile/lib/models/laundry.dart b/spk_mobile/lib/models/laundry.dart index 8fcaae0..3e9ce8e 100644 --- a/spk_mobile/lib/models/laundry.dart +++ b/spk_mobile/lib/models/laundry.dart @@ -1,3 +1,5 @@ +import '../config/app_config.dart'; + class Laundry { final int id; final String nama; @@ -127,8 +129,7 @@ class Laundry { if (foto!.startsWith('http')) { return foto!; } - const String baseUrl = 'http://192.168.18.16:8000'; - return '$baseUrl/uploads/Laundry/$foto'; + return '${AppConfig.serverUrl}/uploads/Laundry/$foto'; } // 2. Fall back to galeri items if foto is not set if (galeri.isNotEmpty) { @@ -140,8 +141,7 @@ class Laundry { return primary.foto; } if (primary.foto.isNotEmpty) { - const String baseUrl = 'http://192.168.18.16:8000'; - return '$baseUrl/uploads/Laundry/${primary.foto}'; + return '${AppConfig.serverUrl}/uploads/Laundry/${primary.foto}'; } } return 'https://via.placeholder.com/300'; @@ -209,7 +209,6 @@ class GaleriLaundry { return foto; } // Build full URL from uploads - const String baseUrl = 'http://192.168.18.16:8000'; - return '$baseUrl/uploads/Laundry/$foto'; + return '${AppConfig.serverUrl}/uploads/Laundry/$foto'; } } diff --git a/spk_mobile/lib/register.dart b/spk_mobile/lib/register.dart index 79cf475..bcdb3fe 100644 --- a/spk_mobile/lib/register.dart +++ b/spk_mobile/lib/register.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter_spinkit/flutter_spinkit.dart'; import 'login.dart'; -import 'screens/mobile_home_screen.dart'; +import 'screens/improved_home_screen.dart'; import 'services/auth_service.dart'; class RegisterScreen extends StatefulWidget { @@ -11,6 +12,7 @@ class RegisterScreen extends StatefulWidget { } class _RegisterScreenState extends State { + final _formKey = GlobalKey(); final _nameController = TextEditingController(); final _emailController = TextEditingController(); final _passwordController = TextEditingController(); @@ -35,6 +37,7 @@ class _RegisterScreenState extends State { backgroundColor: Colors.white, body: Column( children: [ + // Header Container( width: double.infinity, padding: const EdgeInsets.fromLTRB(8, 0, 16, 24), @@ -53,15 +56,13 @@ class _RegisterScreenState extends State { bottom: false, child: Column( children: [ - Row( - children: [ - IconButton( - icon: const Icon(Icons.arrow_back_rounded, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - ], + Align( + alignment: Alignment.centerLeft, + child: IconButton( + icon: const Icon(Icons.arrow_back_rounded, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), ), - const SizedBox(height: 8), Container( padding: const EdgeInsets.all(18), decoration: BoxDecoration( @@ -103,25 +104,38 @@ class _RegisterScreenState extends State { ), ), ), + + // Form Expanded( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(24, 28, 24, 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ _buildTextField( label: 'Nama', controller: _nameController, prefixIcon: Icons.person_outline, + validator: (v) { + if (v == null || v.trim().isEmpty) return 'Nama tidak boleh kosong'; + return null; + }, ), - const SizedBox(height: 16), + const SizedBox(height: 18), _buildTextField( label: 'Email', controller: _emailController, keyboardType: TextInputType.emailAddress, prefixIcon: Icons.email_outlined, + validator: (v) { + if (v == null || v.trim().isEmpty) return 'Email tidak boleh kosong'; + if (!v.contains('@')) return 'Email tidak valid'; + return null; + }, ), - const SizedBox(height: 16), + const SizedBox(height: 18), _buildTextField( label: 'Password', controller: _passwordController, @@ -134,14 +148,15 @@ class _RegisterScreenState extends State { : Icons.visibility_off_outlined, color: const Color(0xFF1565C0), ), - onPressed: () { - setState(() { - _obscurePassword = !_obscurePassword; - }); - }, + onPressed: () => setState(() => _obscurePassword = !_obscurePassword), ), + validator: (v) { + if (v == null || v.isEmpty) return 'Password tidak boleh kosong'; + if (v.length < 6) return 'Password minimal 6 karakter'; + return null; + }, ), - const SizedBox(height: 16), + const SizedBox(height: 18), _buildTextField( label: 'Konfirmasi Password', controller: _confirmPasswordController, @@ -154,48 +169,32 @@ class _RegisterScreenState extends State { : Icons.visibility_off_outlined, color: const Color(0xFF1565C0), ), - onPressed: () { - setState(() { - _obscureConfirmPassword = !_obscureConfirmPassword; - }); - }, + onPressed: () => setState(() => _obscureConfirmPassword = !_obscureConfirmPassword), ), + validator: (v) { + if (v != _passwordController.text) return 'Password tidak cocok'; + return null; + }, ), const SizedBox(height: 28), + + // Tombol Daftar SizedBox( width: double.infinity, - child: ElevatedButton( + child: _buildActionButton( + label: 'DAFTAR', onPressed: _isLoading ? null : _handleRegister, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF1565C0), - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - elevation: 0, - ), - child: _isLoading - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - valueColor: - AlwaysStoppedAnimation(Colors.white), - strokeWidth: 2, - ), - ) - : const Text( - 'DAFTAR', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - letterSpacing: 0.5, - ), - ), ), ), + + const SizedBox(height: 24), + + // Info card + _buildInfoCard(), + const SizedBox(height: 20), + + // Link ke login Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -206,9 +205,7 @@ class _RegisterScreenState extends State { GestureDetector( onTap: () => Navigator.pushReplacement( context, - MaterialPageRoute( - builder: (context) => const LoginScreen(), - ), + MaterialPageRoute(builder: (_) => const LoginScreen()), ), child: const Text( 'Masuk', @@ -226,9 +223,10 @@ class _RegisterScreenState extends State { ), ), ), - ], - ), - ); + ), + ], + ), + ); } Widget _buildTextField({ @@ -238,6 +236,7 @@ class _RegisterScreenState extends State { bool obscureText = false, IconData? prefixIcon, Widget? suffixIcon, + String? Function(String?)? validator, }) { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -251,10 +250,11 @@ class _RegisterScreenState extends State { ), ), const SizedBox(height: 8), - TextField( + TextFormField( controller: controller, keyboardType: keyboardType, obscureText: obscureText, + validator: validator, decoration: InputDecoration( prefixIcon: prefixIcon != null ? Icon(prefixIcon, color: const Color(0xFF1565C0), size: 20) @@ -274,6 +274,14 @@ class _RegisterScreenState extends State { borderRadius: BorderRadius.circular(14), borderSide: const BorderSide(color: Color(0xFF1565C0), width: 1.5), ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: Color(0xFFEF5350)), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: Color(0xFFEF5350), width: 1.5), + ), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15), ), ), @@ -281,107 +289,187 @@ class _RegisterScreenState extends State { ); } + Widget _buildActionButton({ + required String label, + required VoidCallback? onPressed, + }) { + return ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF1565C0), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + elevation: 0, + disabledBackgroundColor: Colors.grey[300], + shadowColor: const Color(0xFF1565C0).withOpacity(0.3), + ), + child: _isLoading + ? const SpinKitThreeBounce(color: Colors.white, size: 20) + : Text( + label, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + ), + ), + ); + } + + Widget _buildInfoCard() { + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: const Color(0xFFF7F8FC), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFE0E0E0)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFF1565C0).withOpacity(0.08), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.school_rounded, color: Color(0xFF1565C0), size: 22), + ), + const SizedBox(width: 12), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Untuk Mahasiswa', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: Color(0xFF1A1A2E), + ), + ), + Text( + 'Politeknik Negeri Jember', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Color(0xFF1565C0), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + _buildInfoItem(Icons.home_rounded, 'Temukan Kontrakan Terbaik', + 'Rekomendasi kontrakan terdekat dari kampus'), + const SizedBox(height: 10), + _buildInfoItem(Icons.local_laundry_service_rounded, 'Cari Laundry Terpercaya', + 'Temukan laundry dengan kualitas terbaik'), + const SizedBox(height: 10), + _buildInfoItem(Icons.analytics_rounded, 'Metode SAW', + 'Rekomendasi berdasarkan preferensi Anda'), + ], + ), + ); + } + + Widget _buildInfoItem(IconData icon, String title, String description) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: const Color(0xFF1565C0).withOpacity(0.6), size: 18), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: const TextStyle( + fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF1A1A2E))), + const SizedBox(height: 2), + Text(description, + style: TextStyle(fontSize: 12, color: Colors.grey[500], height: 1.3)), + ], + ), + ), + ], + ); + } + Future _handleRegister() async { - final name = _nameController.text.trim(); - final email = _emailController.text.trim(); - final password = _passwordController.text; - final confirmPassword = _confirmPasswordController.text; + if (!_formKey.currentState!.validate()) return; - if (name.isEmpty) { - _showError('Nama tidak boleh kosong'); - return; - } - if (email.isEmpty) { - _showError('Email tidak boleh kosong'); - return; - } - if (password.isEmpty) { - _showError('Password tidak boleh kosong'); - return; - } - if (password.length < 6) { - _showError('Password minimal 6 karakter'); - return; - } - if (password != confirmPassword) { - _showError('Password tidak cocok'); - return; - } - - setState(() { - _isLoading = true; - }); + setState(() => _isLoading = true); try { final result = await _authService.register( - name: name, - email: email, - password: password, - passwordConfirmation: confirmPassword, + name: _nameController.text.trim(), + email: _emailController.text.trim(), + password: _passwordController.text, + passwordConfirmation: _confirmPasswordController.text, ); if (!mounted) return; if (result['success'] == true) { - _showSuccess('Registrasi berhasil! Selamat datang!'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Row( + children: [ + Icon(Icons.check_circle_rounded, color: Colors.white, size: 20), + SizedBox(width: 10), + Text('Registrasi berhasil! Selamat datang!'), + ], + ), + backgroundColor: const Color(0xFF2E7D32), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + margin: const EdgeInsets.all(16), + duration: const Duration(seconds: 2), + ), + ); await Future.delayed(const Duration(seconds: 1)); if (mounted) { Navigator.pushReplacement( context, - MaterialPageRoute(builder: (context) => const MobileHomeScreen()), + MaterialPageRoute(builder: (_) => const ImprovedHomeScreen()), ); } } else { - final message = result['message'] ?? 'Registrasi gagal'; - _showError(message); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.error_outline_rounded, color: Colors.white, size: 20), + const SizedBox(width: 10), + Expanded(child: Text(result['message'] ?? 'Registrasi gagal')), + ], + ), + backgroundColor: const Color(0xFFC62828), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + margin: const EdgeInsets.all(16), + duration: const Duration(seconds: 3), + ), + ); } } catch (e) { - _showError('Error: $e'); - } finally { if (mounted) { - setState(() { - _isLoading = false; - }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error: $e'), + backgroundColor: const Color(0xFFC62828), + ), + ); } + } finally { + if (mounted) setState(() => _isLoading = false); } } - - void _showError(String message) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Row( - children: [ - const Icon(Icons.error_outline_rounded, color: Colors.white, size: 20), - const SizedBox(width: 10), - Expanded(child: Text(message)), - ], - ), - backgroundColor: const Color(0xFFC62828), - behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - margin: const EdgeInsets.all(16), - duration: const Duration(seconds: 3), - ), - ); - } - - void _showSuccess(String message) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Row( - children: [ - const Icon(Icons.check_circle_rounded, color: Colors.white, size: 20), - const SizedBox(width: 10), - Expanded(child: Text(message)), - ], - ), - backgroundColor: const Color(0xFF2E7D32), - behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - margin: const EdgeInsets.all(16), - duration: const Duration(seconds: 2), - ), - ); - } } diff --git a/spk_mobile/lib/screens/booking_form_screen.dart b/spk_mobile/lib/screens/booking_form_screen.dart index 017d226..45c03bc 100644 --- a/spk_mobile/lib/screens/booking_form_screen.dart +++ b/spk_mobile/lib/screens/booking_form_screen.dart @@ -72,7 +72,7 @@ class _BookingFormScreenState extends State { }, ); - if (picked != null) { + if (picked != null && mounted) { setState(() => _tanggalMulai = picked); } } @@ -118,15 +118,25 @@ class _BookingFormScreenState extends State { if (source == null) return; - final picked = await _imagePicker.pickImage( - source: source, - maxWidth: 1920, - maxHeight: 1920, - imageQuality: 85, - ); + try { + final picked = await _imagePicker.pickImage( + source: source, + maxWidth: 1920, + maxHeight: 1920, + imageQuality: 85, + ); - if (picked != null) { - setState(() => _paymentProofImage = File(picked.path)); + if (picked != null && mounted) { + setState(() => _paymentProofImage = File(picked.path)); + } + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Gagal memilih gambar: $e'), + backgroundColor: Colors.red, + ), + ); } } @@ -218,25 +228,25 @@ class _BookingFormScreenState extends State { ), ); - if (confirmed != true) return; + if (confirmed != true || !mounted) return; setState(() => _isSubmitting = true); - final result = await _bookingService.createBooking( - kontrakanId: widget.kontrakan.id, - tanggalMulai: _tanggalMulai!, - durasiBulan: _durasiBulan, - catatan: _catatanController.text.isNotEmpty - ? _catatanController.text - : null, - paymentProof: _paymentProofImage, - ); + try { + final result = await _bookingService.createBooking( + kontrakanId: widget.kontrakan.id, + tanggalMulai: _tanggalMulai!, + durasiBulan: _durasiBulan, + catatan: _catatanController.text.isNotEmpty + ? _catatanController.text + : null, + paymentProof: _paymentProofImage, + ); - setState(() => _isSubmitting = false); + if (!mounted) return; + setState(() => _isSubmitting = false); - if (!mounted) return; - - if (result['success'] == true) { + if (result['success'] == true) { showDialog( context: context, barrierDismissible: false, @@ -304,6 +314,16 @@ class _BookingFormScreenState extends State { ), ); } + } catch (e) { + if (!mounted) return; + setState(() => _isSubmitting = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } } Widget _buildConfirmRow(String label, String value) { diff --git a/spk_mobile/lib/screens/booking_history_screen.dart b/spk_mobile/lib/screens/booking_history_screen.dart index 94a34ec..ce9bd0e 100644 --- a/spk_mobile/lib/screens/booking_history_screen.dart +++ b/spk_mobile/lib/screens/booking_history_screen.dart @@ -38,16 +38,32 @@ class _BookingHistoryScreenState extends State Future _loadBookings() async { setState(() => _isLoading = true); - final bookings = await _bookingService.getBookingHistory(); - setState(() { - _activeBookings = bookings - .where((b) => b.status == 'confirmed' || b.status == 'pending') - .toList(); - _pastBookings = bookings - .where((b) => b.status == 'completed' || b.status == 'cancelled') - .toList(); - _isLoading = false; - }); + try { + final bookings = await _bookingService.getBookingHistory(); + if (!mounted) return; + setState(() { + _activeBookings = bookings + .where((b) => b.status == 'confirmed' || b.status == 'pending') + .toList(); + _pastBookings = bookings + .where((b) => b.status == 'completed' || b.status == 'cancelled') + .toList(); + _isLoading = false; + }); + } catch (e) { + debugPrint('Load bookings error: $e'); + if (!mounted) return; + setState(() => _isLoading = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Gagal memuat riwayat booking'), + backgroundColor: Colors.red[700], + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + margin: const EdgeInsets.all(16), + ), + ); + } } Future _cancelBooking(int bookingId) async { @@ -91,8 +107,9 @@ class _BookingHistoryScreenState extends State ), ); if (confirm != true) return; - final result = await _bookingService.cancelBooking(bookingId); - if (mounted) { + try { + final result = await _bookingService.cancelBooking(bookingId); + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Row( @@ -119,6 +136,17 @@ class _BookingHistoryScreenState extends State ), ); if (result['success'] == true) _loadBookings(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Gagal membatalkan booking: $e'), + backgroundColor: const Color(0xFFC62828), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + margin: const EdgeInsets.all(16), + ), + ); } } @@ -216,12 +244,14 @@ class _BookingHistoryScreenState extends State ); if (picked == null) return; + if (!mounted) return; setState(() => _uploadingBookingId = booking.id); - final result = await _bookingService.uploadPaymentProof( - booking.id, - File(picked.path), - ); - if (mounted) { + try { + final result = await _bookingService.uploadPaymentProof( + booking.id, + File(picked.path), + ); + if (!mounted) return; setState(() => _uploadingBookingId = null); ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -250,6 +280,18 @@ class _BookingHistoryScreenState extends State ), ); if (result['success'] == true) _loadBookings(); + } catch (e) { + if (!mounted) return; + setState(() => _uploadingBookingId = null); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Gagal upload bukti pembayaran: $e'), + backgroundColor: const Color(0xFFC62828), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + margin: const EdgeInsets.all(16), + ), + ); } } diff --git a/spk_mobile/lib/screens/change_password_screen.dart b/spk_mobile/lib/screens/change_password_screen.dart index 06d7fe1..f77de84 100644 --- a/spk_mobile/lib/screens/change_password_screen.dart +++ b/spk_mobile/lib/screens/change_password_screen.dart @@ -29,27 +29,37 @@ class _ChangePasswordScreenState extends State { setState(() => _isLoading = true); - final result = await _authService.changePassword( - password: _passwordController.text, - passwordConfirmation: _confirmController.text, - ); - - setState(() => _isLoading = false); - - if (!mounted) return; - - if (result['success'] == true) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Password berhasil diubah'), - backgroundColor: Colors.green, - ), + try { + final result = await _authService.changePassword( + password: _passwordController.text, + passwordConfirmation: _confirmController.text, ); - Navigator.pop(context); - } else { + + if (!mounted) return; + setState(() => _isLoading = false); + + if (result['success'] == true) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Password berhasil diubah'), + backgroundColor: Colors.green, + ), + ); + Navigator.pop(context); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(result['message'] ?? 'Gagal mengubah password'), + backgroundColor: Colors.red, + ), + ); + } + } catch (e) { + if (!mounted) return; + setState(() => _isLoading = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(result['message'] ?? 'Gagal mengubah password'), + content: Text('Terjadi kesalahan: $e'), backgroundColor: Colors.red, ), ); diff --git a/spk_mobile/lib/screens/edit_profile_screen.dart b/spk_mobile/lib/screens/edit_profile_screen.dart index 770d1f2..a12ec63 100644 --- a/spk_mobile/lib/screens/edit_profile_screen.dart +++ b/spk_mobile/lib/screens/edit_profile_screen.dart @@ -39,30 +39,40 @@ class _EditProfileScreenState extends State { setState(() => _isLoading = true); - final result = await _authService.updateProfile( - name: _nameController.text.trim(), - email: _emailController.text.trim(), - phone: _phoneController.text.trim().isEmpty - ? null - : _phoneController.text.trim(), - ); - - setState(() => _isLoading = false); - - if (!mounted) return; - - if (result['success'] == true) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Profil berhasil diperbarui'), - backgroundColor: Colors.green, - ), + try { + final result = await _authService.updateProfile( + name: _nameController.text.trim(), + email: _emailController.text.trim(), + phone: _phoneController.text.trim().isEmpty + ? null + : _phoneController.text.trim(), ); - Navigator.pop(context, true); - } else { + + if (!mounted) return; + setState(() => _isLoading = false); + + if (result['success'] == true) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Profil berhasil diperbarui'), + backgroundColor: Colors.green, + ), + ); + Navigator.pop(context, true); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(result['message'] ?? 'Gagal memperbarui profil'), + backgroundColor: Colors.red, + ), + ); + } + } catch (e) { + if (!mounted) return; + setState(() => _isLoading = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(result['message'] ?? 'Gagal memperbarui profil'), + content: Text('Terjadi kesalahan: $e'), backgroundColor: Colors.red, ), ); @@ -109,7 +119,9 @@ class _EditProfileScreenState extends State { radius: 45, backgroundColor: Colors.white, child: Text( - widget.user.name.substring(0, 1).toUpperCase(), + widget.user.name.isNotEmpty + ? widget.user.name.substring(0, 1).toUpperCase() + : 'U', style: const TextStyle( fontSize: 32, fontWeight: FontWeight.bold, diff --git a/spk_mobile/lib/screens/favorites_screen.dart b/spk_mobile/lib/screens/favorites_screen.dart index 689aa60..0cf2f45 100644 --- a/spk_mobile/lib/screens/favorites_screen.dart +++ b/spk_mobile/lib/screens/favorites_screen.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import '../models/kontrakan.dart'; import '../models/laundry.dart'; import '../services/favorite_service.dart'; @@ -36,23 +36,46 @@ class _FavoritesScreenState extends State super.dispose(); } + String? _errorMessage; + Future _loadFavorites() async { - setState(() => _isLoading = true); - // Clear image cache agar foto terbaru selalu dimuat - await DefaultCacheManager().emptyCache(); - PaintingBinding.instance.imageCache.clear(); - PaintingBinding.instance.imageCache.clearLiveImages(); + if (!mounted) return; + setState(() { + _isLoading = true; + _errorMessage = null; + }); try { final result = await _favoriteService.getFavoritesWithModels(); + if (!mounted) return; + + if (result['success'] == true) { + setState(() { + _kontrakanFavorites = result['kontrakan'] is List + ? result['kontrakan'] as List + : []; + _laundryFavorites = result['laundry'] is List + ? result['laundry'] as List + : []; + _isLoading = false; + }); + debugPrint('[FAV_SCREEN] Loaded ${_kontrakanFavorites.length} kontrakan, ${_laundryFavorites.length} laundry'); + } else { + setState(() { + _kontrakanFavorites = []; + _laundryFavorites = []; + _errorMessage = result['message']?.toString() ?? 'Gagal memuat favorit'; + _isLoading = false; + }); + debugPrint('[FAV_SCREEN] ❌ Load failed: $_errorMessage'); + } + } catch (e) { + debugPrint('[FAV_SCREEN] ❌ Exception: $e'); if (mounted) { setState(() { - _kontrakanFavorites = (result['kontrakan'] as List?) ?? []; - _laundryFavorites = (result['laundry'] as List?) ?? []; + _errorMessage = 'Terjadi kesalahan: $e'; _isLoading = false; }); } - } catch (e) { - if (mounted) setState(() => _isLoading = false); } } @@ -93,15 +116,18 @@ class _FavoritesScreenState extends State ), ); if (confirm != true) return; + if (!mounted) return; - Map result; - if (type == 'kontrakan') { - result = await _favoriteService.toggleKontrakanFavorite(itemId); - } else { - result = await _favoriteService.toggleLaundryFavorite(itemId); - } + try { + Map result; + if (type == 'kontrakan') { + result = await _favoriteService.toggleKontrakanFavorite(itemId); + } else { + result = await _favoriteService.toggleLaundryFavorite(itemId); + } - if (mounted && result['success'] == true) { + if (!mounted) return; + if (result['success'] == true) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Row( @@ -123,7 +149,19 @@ class _FavoritesScreenState extends State margin: const EdgeInsets.all(16), ), ); - _loadFavorites(); + _loadFavorites(); + } + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Gagal menghapus favorit'), + backgroundColor: Colors.red.shade600, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + margin: const EdgeInsets.all(16), + ), + ); } } @@ -262,10 +300,45 @@ class _FavoritesScreenState extends State color: Color(0xFF1565C0), ), ) - : TabBarView( - controller: _tabController, - children: [_buildKontrakanList(), _buildLaundryList()], - ), + : _errorMessage != null + ? Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline_rounded, + size: 56, color: Colors.red.shade300), + const SizedBox(height: 16), + Text( + _errorMessage!, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 15, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 20), + ElevatedButton.icon( + onPressed: _loadFavorites, + icon: const Icon(Icons.refresh_rounded), + label: const Text('Coba Lagi'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF1565C0), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ], + ), + ), + ) + : TabBarView( + controller: _tabController, + children: [_buildKontrakanList(), _buildLaundryList()], + ), ), ], ), diff --git a/spk_mobile/lib/screens/improved_home_screen.dart b/spk_mobile/lib/screens/improved_home_screen.dart index 56b33c8..72a592c 100644 --- a/spk_mobile/lib/screens/improved_home_screen.dart +++ b/spk_mobile/lib/screens/improved_home_screen.dart @@ -42,13 +42,16 @@ class _ImprovedHomeScreenState extends State { _loadData(); } - Future _loadData() async { + Future _loadData({bool clearCache = false}) async { setState(() => _isLoading = true); - // Clear image cache agar foto terbaru dari server selalu dimuat - await DefaultCacheManager().emptyCache(); - PaintingBinding.instance.imageCache.clear(); - PaintingBinding.instance.imageCache.clearLiveImages(); + // Clear image cache hanya saat user eksplisit refresh (pull-to-refresh) + if (clearCache) { + await DefaultCacheManager().emptyCache(); + PaintingBinding.instance.imageCache.clear(); + PaintingBinding.instance.imageCache.clearLiveImages(); + } await Future.wait([_loadKontrakan(), _loadLaundry(), _loadFavoriteIds()]); + if (!mounted) return; setState(() => _isLoading = false); } @@ -56,6 +59,7 @@ class _ImprovedHomeScreenState extends State { try { if (!_authService.isAuthenticated) return; final ids = await _favoriteService.getFavoriteIds(); + if (!mounted) return; setState(() { _favKontrakanIds = (ids['kontrakan'] ?? []).toSet(); _favLaundryIds = (ids['laundry'] ?? []).toSet(); @@ -66,6 +70,14 @@ class _ImprovedHomeScreenState extends State { } Future _toggleKontrakanFav(int id) async { + if (!_authService.isAuthenticated) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Silakan login terlebih dahulu'), backgroundColor: Colors.orange), + ); + } + return; + } final wasFav = _favKontrakanIds.contains(id); setState(() { if (wasFav) { @@ -74,19 +86,44 @@ class _ImprovedHomeScreenState extends State { _favKontrakanIds.add(id); } }); - final result = await _favoriteService.toggleKontrakanFavorite(id); - if (result['success'] != true && mounted) { - setState(() { - if (wasFav) { - _favKontrakanIds.add(id); - } else { - _favKontrakanIds.remove(id); - } - }); + try { + final result = await _favoriteService.toggleKontrakanFavorite(id); + if (result['success'] != true && mounted) { + setState(() { + if (wasFav) { + _favKontrakanIds.add(id); + } else { + _favKontrakanIds.remove(id); + } + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(result['message'] ?? 'Gagal mengubah favorit'), + backgroundColor: Colors.red.shade600, + ), + ); + } + } catch (e) { + if (mounted) { + setState(() { + if (wasFav) _favKontrakanIds.add(id); else _favKontrakanIds.remove(id); + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red.shade600), + ); + } } } Future _toggleLaundryFav(int id) async { + if (!_authService.isAuthenticated) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Silakan login terlebih dahulu'), backgroundColor: Colors.orange), + ); + } + return; + } final wasFav = _favLaundryIds.contains(id); setState(() { if (wasFav) { @@ -95,21 +132,39 @@ class _ImprovedHomeScreenState extends State { _favLaundryIds.add(id); } }); - final result = await _favoriteService.toggleLaundryFavorite(id); - if (result['success'] != true && mounted) { - setState(() { - if (wasFav) { - _favLaundryIds.add(id); - } else { - _favLaundryIds.remove(id); - } - }); + try { + final result = await _favoriteService.toggleLaundryFavorite(id); + if (result['success'] != true && mounted) { + setState(() { + if (wasFav) { + _favLaundryIds.add(id); + } else { + _favLaundryIds.remove(id); + } + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(result['message'] ?? 'Gagal mengubah favorit'), + backgroundColor: Colors.red.shade600, + ), + ); + } + } catch (e) { + if (mounted) { + setState(() { + if (wasFav) _favLaundryIds.add(id); else _favLaundryIds.remove(id); + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red.shade600), + ); + } } } Future _loadKontrakan() async { try { final list = await _kontrakanService.getKontrakan(); + if (!mounted) return; setState(() => _kontrakanList = list.take(6).toList()); } catch (e) { // Error loading kontrakan silently @@ -119,6 +174,7 @@ class _ImprovedHomeScreenState extends State { Future _loadLaundry() async { try { final list = await _laundryService.getLaundry(); + if (!mounted) return; setState(() => _laundryList = list.take(6).toList()); } catch (e) { // Error loading laundry silently @@ -154,7 +210,7 @@ class _ImprovedHomeScreenState extends State { // ────────────── HOME CONTENT ────────────── Widget _buildHomeContent() { return RefreshIndicator( - onRefresh: _loadData, + onRefresh: () => _loadData(clearCache: true), child: CustomScrollView( slivers: [ // ── Gradient Header ── @@ -716,63 +772,6 @@ class _ImprovedHomeScreenState extends State { ); } - // ────────────── SERVICE TILE ────────────── - Widget _buildServiceTile({ - required IconData icon, - required String label, - required Color color, - required Color bgColor, - required VoidCallback onTap, - }) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: color.withOpacity(0.12)), - boxShadow: [ - BoxShadow( - color: color.withOpacity(0.08), - blurRadius: 12, - offset: const Offset(0, 4), - ), - ], - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(12), - ), - child: Icon(icon, color: color, size: 22), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - label, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, - color: color, - height: 1.3, - ), - ), - ), - Icon( - Icons.arrow_forward_ios_rounded, - size: 14, - color: color.withOpacity(0.5), - ), - ], - ), - ), - ); - } - // ────────────── SERVICE TILE INTEGRATED ────────────── Widget _buildServiceTileIntegrated({ required IconData icon, diff --git a/spk_mobile/lib/screens/kontrakan_detail_screen.dart b/spk_mobile/lib/screens/kontrakan_detail_screen.dart index 7e0d074..8f0a3bf 100644 --- a/spk_mobile/lib/screens/kontrakan_detail_screen.dart +++ b/spk_mobile/lib/screens/kontrakan_detail_screen.dart @@ -7,6 +7,10 @@ import '../services/auth_service.dart'; import '../services/favorite_service.dart'; import 'booking_form_screen.dart'; +// Koordinat resmi Kampus Polije (Politeknik Negeri Jember) +const double _polije_lat = -8.1599551; +const double _polije_lng = 113.7230733; + class KontrakanDetailScreen extends StatefulWidget { final Kontrakan kontrakan; @@ -17,11 +21,7 @@ class KontrakanDetailScreen extends StatefulWidget { } class _KontrakanDetailScreenState extends State { - double? userLat; - double? userLng; - double? distance; - bool isLoadingLocation = false; - String? locationError; + double? distance; // jarak dari kampus Polije ke kontrakan ini bool _isFavorite = false; bool _isFavLoading = false; final _favoriteService = FavoriteService(); @@ -30,53 +30,96 @@ class _KontrakanDetailScreenState extends State { void initState() { super.initState(); _checkFavorite(); + _calcDistanceFromPolije(); + } + + /// Hitung jarak dari Kampus Polije ke kontrakan ini secara langsung. + void _calcDistanceFromPolije() { + if (widget.kontrakan.latitude == null || widget.kontrakan.longitude == null) return; + final dist = LocationService.calculateDistance( + _polije_lat, + _polije_lng, + widget.kontrakan.latitude!, + widget.kontrakan.longitude!, + ); + setState(() => distance = dist); } Future _checkFavorite() async { - final result = await _favoriteService.isKontrakanFavorite( - widget.kontrakan.id, - ); - if (mounted) setState(() => _isFavorite = result); + try { + final result = await _favoriteService.isKontrakanFavorite( + widget.kontrakan.id, + ); + if (mounted) setState(() => _isFavorite = result); + } catch (e) { + debugPrint('Check favorite error: $e'); + } } Future _toggleFavorite() async { setState(() => _isFavLoading = true); - final result = await _favoriteService.toggleKontrakanFavorite( - widget.kontrakan.id, - ); - if (mounted) { - setState(() { - _isFavLoading = false; - if (result['success'] == true) { + try { + final result = await _favoriteService.toggleKontrakanFavorite( + widget.kontrakan.id, + ); + if (!mounted) return; + + if (result['success'] == true) { + setState(() { _isFavorite = result['isFavorite'] ?? !_isFavorite; - } - }); + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + Icon( + _isFavorite + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + color: Colors.white, + size: 20, + ), + const SizedBox(width: 10), + Text(result['message'] ?? 'Status favorit diubah'), + ], + ), + backgroundColor: _isFavorite + ? const Color(0xFF1565C0) + : Colors.grey[700], + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + margin: const EdgeInsets.all(16), + duration: const Duration(seconds: 2), + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(result['message'] ?? 'Gagal mengubah favorit'), + backgroundColor: Colors.red[700], + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + margin: const EdgeInsets.all(16), + ), + ); + } + } catch (e) { + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Row( - children: [ - Icon( - _isFavorite - ? Icons.favorite_rounded - : Icons.favorite_border_rounded, - color: Colors.white, - size: 20, - ), - const SizedBox(width: 10), - Text(result['message'] ?? 'Status favorit diubah'), - ], - ), - backgroundColor: _isFavorite - ? const Color(0xFF1565C0) - : Colors.grey[700], + content: Text('Gagal: $e'), + backgroundColor: Colors.red[700], behavior: SnackBarBehavior.floating, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), margin: const EdgeInsets.all(16), - duration: const Duration(seconds: 2), ), ); + } finally { + if (mounted) setState(() => _isFavLoading = false); } } @@ -423,10 +466,10 @@ class _KontrakanDetailScreenState extends State { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.purple.withValues(alpha: 0.1), + color: const Color(0xFF1565C0).withValues(alpha: 0.08), borderRadius: BorderRadius.circular(12), border: Border.all( - color: Colors.purple.withValues(alpha: 0.3), + color: const Color(0xFF1565C0).withValues(alpha: 0.3), width: 1, ), ), @@ -434,114 +477,83 @@ class _KontrakanDetailScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - children: [ - Icon(Icons.location_on, color: Colors.purple, size: 24), - const SizedBox(width: 12), - Text( - 'Deteksi Lokasi Saya', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.black87, - ), - ), - ], - ), - if (isLoadingLocation) - SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.purple), - ), + const Icon(Icons.school_rounded, color: Color(0xFF1565C0), size: 22), + const SizedBox(width: 10), + const Text( + 'Jarak dari Kampus Polije', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Color(0xFF1565C0), ), + ), ], ), const SizedBox(height: 12), - if (distance != null) ...[ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Icon(Icons.check_circle, color: Colors.green, size: 20), - const SizedBox(width: 10), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Jarak dari Lokasi Saya', - style: TextStyle(fontSize: 12, color: Colors.grey[600]), - ), - Text( - distance! < 1 - ? '${(distance! * 1000).toStringAsFixed(0)} m' - : '${distance!.toStringAsFixed(2)} km', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.green, - ), - ), - ], - ), - ], - ), - ), - ] else if (locationError != null) ...[ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.red.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Icon(Icons.error, color: Colors.red, size: 20), - const SizedBox(width: 10), - Expanded( - child: Text( - locationError!, - style: const TextStyle(fontSize: 13, color: Colors.red), - ), - ), - ], - ), - ), - ], - const SizedBox(height: 12), - SizedBox( + Container( width: double.infinity, - height: 44, - child: ElevatedButton.icon( - onPressed: isLoadingLocation ? null : _detectLocation, - icon: Icon( - isLoadingLocation ? Icons.hourglass_bottom : Icons.my_location, - size: 20, - ), - label: Text( - isLoadingLocation ? 'Mendeteksi...' : 'Deteksi Lokasi Saya', - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - ), - ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.purple, - foregroundColor: Colors.white, - disabledBackgroundColor: Colors.grey, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: const Color(0xFF1565C0).withValues(alpha: 0.15), ), ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFF1565C0).withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.directions_walk_rounded, + color: Color(0xFF1565C0), + size: 20, + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Politeknik Negeri Jember', + style: TextStyle( + fontSize: 12, + color: Colors.grey, + ), + ), + const SizedBox(height: 2), + Text( + distance == null + ? '-' + : distance! < 1 + ? '${(distance! * 1000).toStringAsFixed(0)} meter' + : '${distance!.toStringAsFixed(2)} km', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w800, + color: Color(0xFF1565C0), + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Icon(Icons.location_pin, size: 13, color: Colors.grey[500]), + const SizedBox(width: 4), + Text( + 'Jl. Mastrip No.164, Sumbersari, Jember', + style: TextStyle(fontSize: 11, color: Colors.grey[500]), + ), + ], ), ], ), @@ -577,51 +589,4 @@ class _KontrakanDetailScreenState extends State { } } - Future _detectLocation() async { - setState(() { - isLoadingLocation = true; - locationError = null; - }); - - try { - final locationService = LocationService(); - final isEnabled = await locationService.isLocationServiceEnabled(); - - if (!isEnabled) { - setState(() { - locationError = 'Layanan lokasi tidak aktif'; - isLoadingLocation = false; - }); - return; - } - - final position = await locationService.getCurrentLocation(); - - if (position != null) { - final dist = LocationService.calculateDistance( - position.latitude, - position.longitude, - widget.kontrakan.latitude!, - widget.kontrakan.longitude!, - ); - - setState(() { - userLat = position.latitude; - userLng = position.longitude; - distance = dist; - isLoadingLocation = false; - }); - } else { - setState(() { - locationError = 'Gagal mendapatkan lokasi Anda'; - isLoadingLocation = false; - }); - } - } catch (e) { - setState(() { - locationError = 'Error: ${e.toString()}'; - isLoadingLocation = false; - }); - } - } } diff --git a/spk_mobile/lib/screens/laundry_detail_screen.dart b/spk_mobile/lib/screens/laundry_detail_screen.dart index d02eef0..2951c9f 100644 --- a/spk_mobile/lib/screens/laundry_detail_screen.dart +++ b/spk_mobile/lib/screens/laundry_detail_screen.dart @@ -30,48 +30,78 @@ class _LaundryDetailScreenState extends State { } Future _checkFavorite() async { - final result = await _favoriteService.isLaundryFavorite(widget.laundry.id); - if (mounted) setState(() => _isFavorite = result); + try { + final result = await _favoriteService.isLaundryFavorite(widget.laundry.id); + if (mounted) setState(() => _isFavorite = result); + } catch (e) { + debugPrint('Check favorite error: $e'); + } } Future _toggleFavorite() async { setState(() => _isFavLoading = true); - final result = await _favoriteService.toggleLaundryFavorite( - widget.laundry.id, - ); - if (mounted) { - setState(() { - _isFavLoading = false; - if (result['success'] == true) { + try { + final result = await _favoriteService.toggleLaundryFavorite( + widget.laundry.id, + ); + if (!mounted) return; + + if (result['success'] == true) { + setState(() { _isFavorite = result['isFavorite'] ?? !_isFavorite; - } - }); + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + Icon( + _isFavorite + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + color: Colors.white, + size: 20, + ), + const SizedBox(width: 10), + Text(result['message'] ?? 'Status favorit diubah'), + ], + ), + backgroundColor: _isFavorite + ? const Color(0xFF00897B) + : Colors.grey[700], + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + margin: const EdgeInsets.all(16), + duration: const Duration(seconds: 2), + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(result['message'] ?? 'Gagal mengubah favorit'), + backgroundColor: Colors.red[700], + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + margin: const EdgeInsets.all(16), + ), + ); + } + } catch (e) { + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Row( - children: [ - Icon( - _isFavorite - ? Icons.favorite_rounded - : Icons.favorite_border_rounded, - color: Colors.white, - size: 20, - ), - const SizedBox(width: 10), - Text(result['message'] ?? 'Status favorit diubah'), - ], - ), - backgroundColor: _isFavorite - ? const Color(0xFF00897B) - : Colors.grey[700], + content: Text('Gagal: $e'), + backgroundColor: Colors.red[700], behavior: SnackBarBehavior.floating, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), margin: const EdgeInsets.all(16), - duration: const Duration(seconds: 2), ), ); + } finally { + if (mounted) setState(() => _isFavLoading = false); } } @@ -519,6 +549,7 @@ class _LaundryDetailScreenState extends State { final locationService = LocationService(); final isEnabled = await locationService.isLocationServiceEnabled(); + if (!mounted) return; if (!isEnabled) { setState(() { locationError = 'Layanan lokasi tidak aktif'; @@ -529,6 +560,7 @@ class _LaundryDetailScreenState extends State { final position = await locationService.getCurrentLocation(); + if (!mounted) return; if (position != null) { final dist = LocationService.calculateDistance( position.latitude, @@ -550,6 +582,7 @@ class _LaundryDetailScreenState extends State { }); } } catch (e) { + if (!mounted) return; setState(() { locationError = 'Error: ${e.toString()}'; isLoadingLocation = false; @@ -700,7 +733,9 @@ class _LaundryDetailScreenState extends State { } Future _launchWhatsApp(String phone) async { - final url = 'https://wa.me/$phone'; + final cleaned = phone.replaceAll(RegExp(r'[^0-9]'), ''); + final formatted = cleaned.startsWith('0') ? '62${cleaned.substring(1)}' : cleaned; + final url = 'https://wa.me/$formatted'; if (await canLaunchUrl(Uri.parse(url))) { await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); } diff --git a/spk_mobile/lib/screens/laundry_list_screen.dart b/spk_mobile/lib/screens/laundry_list_screen.dart index 4595ee5..f34b0dd 100644 --- a/spk_mobile/lib/screens/laundry_list_screen.dart +++ b/spk_mobile/lib/screens/laundry_list_screen.dart @@ -33,38 +33,54 @@ class _LaundryListScreenState extends State { Future _loadLaundry() async { setState(() => _isLoading = true); - final list = await _laundryService.getLaundry(); - setState(() { - _laundryList = list; - _filteredList = list; - _isLoading = false; - }); + try { + final list = await _laundryService.getLaundry(); + if (!mounted) return; + setState(() { + _laundryList = list; + _filteredList = List.from(list); + _applySortInPlace(_sortBy); // apply current sort + _isLoading = false; + }); + } catch (e) { + debugPrint('Load laundry error: $e'); + if (!mounted) return; + setState(() => _isLoading = false); + } } void _filterList(String query) { setState(() { if (query.isEmpty) { - _filteredList = _laundryList; + _filteredList = List.from(_laundryList); } else { _filteredList = _laundryList.where((laundry) { return laundry.nama.toLowerCase().contains(query.toLowerCase()) || laundry.alamat.toLowerCase().contains(query.toLowerCase()); }).toList(); } + _applySortInPlace(_sortBy); }); } void _sortList(String sortBy) { setState(() { - _sortBy = sortBy; - if (sortBy == 'rating') { - _filteredList.sort((a, b) => b.rating.compareTo(a.rating)); - } else if (sortBy == 'harga') { - _filteredList.sort((a, b) => a.hargaKiloan.compareTo(b.hargaKiloan)); - } + _applySortInPlace(sortBy); }); } + /// Internal sort helper — call within setState only + void _applySortInPlace(String sortBy) { + _sortBy = sortBy; + if (sortBy == 'rating') { + _filteredList.sort((a, b) => b.rating.compareTo(a.rating)); + } else if (sortBy == 'harga') { + _filteredList.sort((a, b) => a.hargaKiloan.compareTo(b.hargaKiloan)); + } else if (sortBy == 'jarak') { + _filteredList.sort((a, b) => a.jarakKampus.compareTo(b.jarakKampus)); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -187,7 +203,7 @@ class _LaundryListScreenState extends State { ], ), Text( - 'Urut: ${_sortBy == "rating" ? "Rating" : "Harga"}', + 'Urut: ${_sortBy == "rating" ? "Rating" : _sortBy == "harga" ? "Harga" : "Jarak"}', style: TextStyle( fontSize: 13, color: Colors.grey[600], diff --git a/spk_mobile/lib/screens/profile_screen.dart b/spk_mobile/lib/screens/profile_screen.dart index 97f147f..38d4edc 100644 --- a/spk_mobile/lib/screens/profile_screen.dart +++ b/spk_mobile/lib/screens/profile_screen.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; import '../services/auth_service.dart'; +import '../services/favorite_service.dart'; +import '../services/booking_service.dart'; import '../models/user.dart'; import '../login.dart'; import 'edit_profile_screen.dart'; import 'change_password_screen.dart'; import 'booking_history_screen.dart'; +import 'favorites_screen.dart'; class ProfileScreen extends StatefulWidget { const ProfileScreen({super.key}); @@ -13,323 +16,612 @@ class ProfileScreen extends StatefulWidget { State createState() => _ProfileScreenState(); } -class _ProfileScreenState extends State { +class _ProfileScreenState extends State + with SingleTickerProviderStateMixin { final _authService = AuthService(); + final _favoriteService = FavoriteService(); + final _bookingService = BookingService(); User? _currentUser; bool _isLoading = true; + int _totalFavorites = 0; + int _totalBookings = 0; + + late AnimationController _animController; + late Animation _fadeAnim; @override void initState() { super.initState(); - _loadUser(); + _animController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 600), + ); + _fadeAnim = CurvedAnimation(parent: _animController, curve: Curves.easeOut); + _loadAll(); + } + + @override + void dispose() { + _animController.dispose(); + super.dispose(); + } + + Future _loadAll() async { + setState(() => _isLoading = true); + try { + await Future.wait([_loadUser(), _loadStats()]); + } catch (_) {} + if (!mounted) return; + setState(() => _isLoading = false); + _animController.forward(from: 0); } Future _loadUser() async { - setState(() => _isLoading = true); - final user = await _authService.getCurrentUser(); - setState(() { - _currentUser = user; - _isLoading = false; - }); + try { + final user = await _authService.getCurrentUser(); + if (!mounted) return; + setState(() => _currentUser = user); + } catch (e) { + debugPrint('Load user error: $e'); + } + } + + Future _loadStats() async { + try { + final favResult = await _favoriteService.getFavoriteIds(); + final bookings = await _bookingService.getBookingHistory(); + if (!mounted) return; + setState(() { + _totalFavorites = (favResult['kontrakan']?.length ?? 0) + + (favResult['laundry']?.length ?? 0); + _totalBookings = bookings.length; + }); + } catch (e) { + debugPrint('Load stats error: $e'); + } + } + + String _getInitials(String name) { + if (name.isEmpty) return 'U'; + final parts = name.trim().split(' '); + if (parts.length >= 2) { + return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); + } + return parts[0][0].toUpperCase(); + } + + String _getMemberSince(DateTime? date) { + if (date == null) return 'Baru bergabung'; + const months = [ + '', + 'Januari', + 'Februari', + 'Maret', + 'April', + 'Mei', + 'Juni', + 'Juli', + 'Agustus', + 'September', + 'Oktober', + 'November', + 'Desember' + ]; + return '${months[date.month]} ${date.year}'; } @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: const Color(0xFFF8F9FA), + backgroundColor: const Color(0xFFF5F6FA), body: _isLoading ? const Center( child: CircularProgressIndicator(color: Color(0xFF1565C0))) : RefreshIndicator( - onRefresh: _loadUser, + onRefresh: _loadAll, color: const Color(0xFF1565C0), - child: CustomScrollView( - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - // --- Profile Header --- - SliverToBoxAdapter( - child: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [Color(0xFF0D47A1), Color(0xFF1976D2)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.only( - bottomLeft: Radius.circular(32), - bottomRight: Radius.circular(32), - ), - ), - child: SafeArea( - bottom: false, - child: Padding( - padding: const EdgeInsets.fromLTRB(24, 20, 24, 32), - child: Column( - children: [ - // Title - const Row( - children: [ - Text( - 'Profil Saya', - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - ], - ), - const SizedBox(height: 24), - // Avatar & Info - Row( - children: [ - Container( - padding: const EdgeInsets.all(3), - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: Colors.white, width: 2.5), - ), - child: CircleAvatar( - radius: 38, - backgroundColor: - Colors.white.withOpacity(0.95), - child: Text( - _currentUser?.name - .substring(0, 1) - .toUpperCase() ?? - 'U', - style: const TextStyle( - fontSize: 30, - fontWeight: FontWeight.bold, - color: Color(0xFF0D47A1), - ), - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - _currentUser?.name ?? 'User', - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Row( - children: [ - Icon(Icons.email_outlined, - size: 14, - color: Colors.white - .withOpacity(0.8)), - const SizedBox(width: 6), - Expanded( - child: Text( - _currentUser?.email ?? '', - style: TextStyle( - fontSize: 13, - color: Colors.white - .withOpacity(0.85), - ), - maxLines: 1, - overflow: - TextOverflow.ellipsis, - ), - ), - ], - ), - if (_currentUser?.phone != null && - _currentUser!.phone!.isNotEmpty) ...[ - const SizedBox(height: 2), - Row( - children: [ - Icon(Icons.phone_outlined, - size: 14, - color: Colors.white - .withOpacity(0.8)), - const SizedBox(width: 6), - Text( - _currentUser!.phone!, - style: TextStyle( - fontSize: 13, - color: Colors.white - .withOpacity(0.85), - ), - ), - ], - ), - ], - ], - ), - ), - ], - ), - ], - ), - ), + child: FadeTransition( + opacity: _fadeAnim, + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + // ==================== HEADER ==================== + SliverToBoxAdapter(child: _buildHeader()), + + // ==================== STATS ==================== + SliverToBoxAdapter( + child: Transform.translate( + offset: const Offset(0, -30), + child: _buildStatsRow(), ), ), - ), - // --- Menu Sections --- - SliverPadding( - padding: const EdgeInsets.fromLTRB(16, 24, 16, 16), - sliver: SliverList( - delegate: SliverChildListDelegate([ - // Akun section - _sectionLabel('Akun'), - const SizedBox(height: 8), - _menuCard([ - _menuTile( - icon: Icons.person_outline, - title: 'Edit Profil', - subtitle: 'Ubah nama, email, dan no. HP', - onTap: () async { - if (_currentUser == null) return; - final result = await Navigator.push( - context, - MaterialPageRoute( - builder: (_) => - EditProfileScreen(user: _currentUser!), - ), - ); - if (result == true) _loadUser(); - }, - ), - _divider(), - _menuTile( - icon: Icons.lock_outline, - title: 'Ubah Password', - subtitle: 'Ganti password akun Anda', - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => - const ChangePasswordScreen(), - ), - ); - }, - ), - ]), - - const SizedBox(height: 20), - - // Aktivitas section - _sectionLabel('Aktivitas'), - const SizedBox(height: 8), - _menuCard([ - _menuTile( - icon: Icons.receipt_long_outlined, - title: 'Booking Saya', - subtitle: 'Lihat riwayat booking Anda', - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => - const BookingHistoryScreen(), - ), - ); - }, - ), - ]), - - const SizedBox(height: 20), - - // Informasi section - _sectionLabel('Informasi'), - const SizedBox(height: 8), - _menuCard([ - _menuTile( - icon: Icons.info_outline, - title: 'Tentang Aplikasi', - subtitle: 'SPK Kontrakan v1.0.0', - onTap: _showAboutDialog, - ), - ]), - - const SizedBox(height: 28), - - // Logout - SizedBox( - width: double.infinity, - height: 52, - child: OutlinedButton.icon( - onPressed: _handleLogout, - icon: const Icon(Icons.logout, size: 20), - label: const Text( - 'Keluar dari Akun', - style: TextStyle( - fontSize: 15, fontWeight: FontWeight.w600), + // ==================== MENU SECTIONS ==================== + SliverPadding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + sliver: SliverList( + delegate: SliverChildListDelegate([ + // --- Akun --- + _buildSectionTitle('Akun', Icons.person_rounded), + const SizedBox(height: 10), + _buildMenuCard([ + _buildMenuItem( + icon: Icons.edit_rounded, + iconColor: const Color(0xFF1565C0), + iconBg: const Color(0xFF1565C0), + title: 'Edit Profil', + subtitle: 'Ubah nama, email, dan no. HP', + onTap: () async { + if (_currentUser == null) return; + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => EditProfileScreen( + user: _currentUser!), + ), + ); + if (result == true) _loadAll(); + }, ), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.red.shade600, - side: BorderSide(color: Colors.red.shade300), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), + _buildMenuItem( + icon: Icons.lock_rounded, + iconColor: const Color(0xFF6A1B9A), + iconBg: const Color(0xFF6A1B9A), + title: 'Ubah Password', + subtitle: 'Ganti password akun Anda', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + const ChangePasswordScreen(), + ), + ); + }, ), - ), - ), + ]), - const SizedBox(height: 32), - ]), + const SizedBox(height: 24), + + // --- Aktivitas --- + _buildSectionTitle( + 'Aktivitas', Icons.timeline_rounded), + const SizedBox(height: 10), + _buildMenuCard([ + _buildMenuItem( + icon: Icons.receipt_long_rounded, + iconColor: const Color(0xFF00897B), + iconBg: const Color(0xFF00897B), + title: 'Booking Saya', + subtitle: '$_totalBookings booking tercatat', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + const BookingHistoryScreen(), + ), + ); + }, + ), + _buildMenuItem( + icon: Icons.favorite_rounded, + iconColor: const Color(0xFFE53935), + iconBg: const Color(0xFFE53935), + title: 'Favorit Saya', + subtitle: '$_totalFavorites item difavoritkan', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const FavoritesScreen(), + ), + ); + }, + ), + ]), + + const SizedBox(height: 24), + + // --- Lainnya --- + _buildSectionTitle( + 'Lainnya', Icons.more_horiz_rounded), + const SizedBox(height: 10), + _buildMenuCard([ + _buildMenuItem( + icon: Icons.info_rounded, + iconColor: const Color(0xFFF57C00), + iconBg: const Color(0xFFF57C00), + title: 'Tentang Aplikasi', + subtitle: 'Informasi & versi aplikasi', + onTap: _showAboutDialog, + ), + ]), + + const SizedBox(height: 32), + + // --- Logout --- + _buildLogoutButton(), + + const SizedBox(height: 40), + ]), + ), ), - ), - ], + ], + ), ), ), ); } - // --- Helper Widgets --- + // ==================== HEADER ==================== + Widget _buildHeader() { + return Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [Color(0xFF0D47A1), Color(0xFF1976D2), Color(0xFF42A5F5)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(36), + bottomRight: Radius.circular(36), + ), + ), + child: Stack( + children: [ + // Decorative circles + Positioned( + top: -40, + right: -30, + child: Container( + width: 150, + height: 150, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.06), + ), + ), + ), + Positioned( + bottom: 20, + left: -20, + child: Container( + width: 100, + height: 100, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.04), + ), + ), + ), + Positioned( + top: 30, + left: 50, + child: Container( + width: 60, + height: 60, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.03), + ), + ), + ), - Widget _sectionLabel(String text) { + // Content + SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 56), + child: Column( + children: [ + // Title bar + const Row( + children: [ + Icon(Icons.person_rounded, + color: Colors.white, size: 24), + SizedBox(width: 10), + Text( + 'Profil Saya', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w800, + color: Colors.white, + letterSpacing: 0.3, + ), + ), + ], + ), + + const SizedBox(height: 28), + + // Avatar + Info + Row( + children: [ + // Avatar with shadow and gradient ring + Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ], + ), + child: Container( + padding: const EdgeInsets.all(3), + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + Colors.white, + Color(0xFFBBDEFB), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: CircleAvatar( + radius: 40, + backgroundColor: const Color(0xFF0D47A1), + child: Text( + _getInitials(_currentUser?.name ?? ''), + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w800, + color: Colors.white, + letterSpacing: 1, + ), + ), + ), + ), + ), + const SizedBox(width: 18), + + // Name, email, phone, member since + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _currentUser?.name ?? 'User', + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w800, + color: Colors.white, + letterSpacing: 0.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 6), + _headerInfoRow( + Icons.email_outlined, + _currentUser?.email ?? '-', + ), + if (_currentUser?.phone != null && + _currentUser!.phone!.isNotEmpty) ...[ + const SizedBox(height: 4), + _headerInfoRow( + Icons.phone_outlined, + _currentUser!.phone!, + ), + ], + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.calendar_month_rounded, + size: 13, + color: Colors.white + .withValues(alpha: 0.85)), + const SizedBox(width: 5), + Text( + 'Bergabung ${_getMemberSince(_currentUser?.createdAt)}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.white + .withValues(alpha: 0.9), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _headerInfoRow(IconData icon, String text) { + return Row( + children: [ + Icon(icon, + size: 14, color: Colors.white.withValues(alpha: 0.75)), + const SizedBox(width: 6), + Expanded( + child: Text( + text, + style: TextStyle( + fontSize: 13, + color: Colors.white.withValues(alpha: 0.85), + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } + + // ==================== STATS ROW ==================== + Widget _buildStatsRow() { return Padding( - padding: const EdgeInsets.only(left: 4), - child: Text( - text.toUpperCase(), - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - color: Colors.grey.shade500, - letterSpacing: 1.2, + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: const Color(0xFF1565C0).withValues(alpha: 0.10), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: Row( + children: [ + _buildStatItem( + icon: Icons.favorite_rounded, + color: const Color(0xFFE53935), + value: '$_totalFavorites', + label: 'Favorit', + ), + _buildStatDivider(), + _buildStatItem( + icon: Icons.receipt_long_rounded, + color: const Color(0xFF00897B), + value: '$_totalBookings', + label: 'Booking', + ), + _buildStatDivider(), + _buildStatItem( + icon: Icons.verified_user_rounded, + color: const Color(0xFF1565C0), + value: _currentUser?.role == 'admin' ? 'Admin' : 'User', + label: 'Status', + ), + ], ), ), ); } - Widget _menuCard(List children) { - return Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.04), - blurRadius: 10, - offset: const Offset(0, 2), + Widget _buildStatItem({ + required IconData icon, + required Color color, + required String value, + required String label, + }) { + return Expanded( + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Icon(icon, color: color, size: 22), + ), + const SizedBox(height: 8), + Text( + value, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w800, + color: Color(0xFF1A1A2E), + ), + ), + const SizedBox(height: 2), + Text( + label, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade500, + fontWeight: FontWeight.w500, + ), ), ], ), - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Column(children: children), - ), ); } - Widget _menuTile({ + Widget _buildStatDivider() { + return Container( + width: 1, + height: 45, + color: Colors.grey.shade200, + ); + } + + // ==================== SECTION TITLE ==================== + Widget _buildSectionTitle(String text, IconData icon) { + return Row( + children: [ + Icon(icon, size: 18, color: Colors.grey.shade600), + const SizedBox(width: 8), + Text( + text.toUpperCase(), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w800, + color: Colors.grey.shade600, + letterSpacing: 1.3, + ), + ), + ], + ); + } + + // ==================== MENU CARD ==================== + Widget _buildMenuCard(List items) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 12, + offset: const Offset(0, 3), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(18), + child: Column( + children: List.generate(items.length * 2 - 1, (index) { + if (index.isOdd) { + return Divider( + height: 1, + thickness: 0.5, + indent: 72, + color: Colors.grey.shade100, + ); + } + return items[index ~/ 2]; + }), + ), + ), + ); + } + + Widget _buildMenuItem({ required IconData icon, + required Color iconColor, + required Color iconBg, required String title, required String subtitle, required VoidCallback onTap, @@ -339,17 +631,24 @@ class _ProfileScreenState extends State { child: InkWell( onTap: onTap, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15), child: Row( children: [ Container( - width: 42, - height: 42, + width: 44, + height: 44, decoration: BoxDecoration( - color: const Color(0xFF1565C0).withOpacity(0.08), - borderRadius: BorderRadius.circular(12), + gradient: LinearGradient( + colors: [ + iconBg.withValues(alpha: 0.15), + iconBg.withValues(alpha: 0.08), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(13), ), - child: Icon(icon, color: const Color(0xFF1565C0), size: 22), + child: Icon(icon, color: iconColor, size: 22), ), const SizedBox(width: 14), Expanded( @@ -360,22 +659,31 @@ class _ProfileScreenState extends State { title, style: const TextStyle( fontSize: 15, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: Color(0xFF1A1A2E), ), ), - const SizedBox(height: 2), + const SizedBox(height: 3), Text( subtitle, style: TextStyle( fontSize: 12, color: Colors.grey.shade500, + fontWeight: FontWeight.w500, ), ), ], ), ), - Icon(Icons.chevron_right, color: Colors.grey.shade400, size: 22), + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.chevron_right_rounded, + color: Colors.grey.shade400, size: 20), + ), ], ), ), @@ -383,80 +691,179 @@ class _ProfileScreenState extends State { ); } - Widget _divider() { - return Divider( - height: 1, thickness: 0.5, indent: 72, color: Colors.grey.shade200); + // ==================== LOGOUT ==================== + Widget _buildLogoutButton() { + return Container( + width: double.infinity, + height: 54, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.red.shade200, width: 1.5), + ), + child: Material( + color: Colors.red.shade50.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(16), + child: InkWell( + onTap: _handleLogout, + borderRadius: BorderRadius.circular(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.logout_rounded, + color: Colors.red.shade600, size: 20), + const SizedBox(width: 10), + Text( + 'Keluar dari Akun', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: Colors.red.shade600, + ), + ), + ], + ), + ), + ), + ); } - // --- Dialogs --- - + // ==================== DIALOGS ==================== void _showAboutDialog() { showDialog( context: context, builder: (context) => AlertDialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - contentPadding: const EdgeInsets.fromLTRB(24, 24, 24, 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + contentPadding: const EdgeInsets.fromLTRB(28, 28, 28, 16), content: Column( mainAxisSize: MainAxisSize.min, children: [ + // App icon + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D47A1), Color(0xFF42A5F5)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: + const Color(0xFF1565C0).withValues(alpha: 0.3), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: const Icon(Icons.home_work_rounded, + color: Colors.white, size: 40), + ), + const SizedBox(height: 20), + const Text( + 'SPK Kontrakan & Laundry', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w800, + color: Color(0xFF1A1A2E), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 6), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFF1565C0).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: const Text( + 'Versi 2.5.0', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: Color(0xFF1565C0), + ), + ), + ), + const SizedBox(height: 18), + Text( + 'Sistem Pendukung Keputusan untuk membantu mahasiswa Polije menemukan kontrakan dan laundry terbaik menggunakan metode SAW (Simple Additive Weighting).', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade600, + height: 1.6, + ), + ), + const SizedBox(height: 20), Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: const Color(0xFF1565C0).withOpacity(0.08), - borderRadius: BorderRadius.circular(16), + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + children: [ + _aboutDetailRow( + Icons.code_rounded, 'Developer', 'Tim TA'), + const SizedBox(height: 10), + _aboutDetailRow( + Icons.school_rounded, 'Institusi', 'Polije'), + const SizedBox(height: 10), + _aboutDetailRow( + Icons.calendar_today_rounded, 'Tahun', '2025'), + ], ), - child: const Icon(Icons.home_work, - color: Color(0xFF1565C0), size: 40), ), - const SizedBox(height: 16), - const Text( - 'SPK Kontrakan', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 4), - Text( - 'Versi 1.0.0', - style: TextStyle(fontSize: 13, color: Colors.grey.shade500), - ), - const SizedBox(height: 16), - Text( - 'Sistem Pendukung Keputusan untuk membantu mahasiswa menemukan kontrakan terbaik menggunakan metode SAW.', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, color: Colors.grey.shade600, height: 1.5), - ), - const SizedBox(height: 16), - const Divider(), - const SizedBox(height: 8), - _aboutRow('Developer', 'SPK Team'), - _aboutRow('Tahun', '2026'), ], ), actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Tutup'), + SizedBox( + width: double.infinity, + child: TextButton( + onPressed: () => Navigator.pop(context), + style: TextButton.styleFrom( + foregroundColor: const Color(0xFF1565C0), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text( + 'Tutup', + style: TextStyle(fontWeight: FontWeight.w700), + ), + ), ), ], ), ); } - Widget _aboutRow(String label, String value) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 3), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(label, - style: TextStyle(fontSize: 13, color: Colors.grey.shade600)), - Text(value, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Colors.black87)), - ], - ), + Widget _aboutDetailRow(IconData icon, String label, String value) { + return Row( + children: [ + Icon(icon, size: 16, color: const Color(0xFF1565C0)), + const SizedBox(width: 10), + Text( + label, + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade600, + fontWeight: FontWeight.w500, + ), + ), + const Spacer(), + Text( + value, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Color(0xFF1A1A2E), + ), + ), + ], ); } @@ -464,30 +871,77 @@ class _ProfileScreenState extends State { final confirm = await showDialog( context: context, builder: (context) => AlertDialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - title: Row( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + contentPadding: const EdgeInsets.fromLTRB(28, 28, 28, 12), + content: Column( + mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.logout, color: Colors.red.shade600, size: 24), - const SizedBox(width: 10), - const Text('Keluar', style: TextStyle(fontSize: 18)), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.red.shade50, + shape: BoxShape.circle, + ), + child: Icon(Icons.logout_rounded, + color: Colors.red.shade600, size: 32), + ), + const SizedBox(height: 20), + const Text( + 'Keluar dari Akun?', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w800, + color: Color(0xFF1A1A2E), + ), + ), + const SizedBox(height: 10), + Text( + 'Anda harus login kembali untuk mengakses fitur aplikasi', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade600, + height: 1.5, + ), + ), ], ), - content: const Text('Apakah Anda yakin ingin keluar dari akun?'), actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: - Text('Batal', style: TextStyle(color: Colors.grey.shade600)), - ), - ElevatedButton( - onPressed: () => Navigator.pop(context, true), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red.shade600, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10)), - ), - child: const Text('Keluar'), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.pop(context, false), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.grey.shade700, + side: BorderSide(color: Colors.grey.shade300), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text('Batal', + style: TextStyle(fontWeight: FontWeight.w600)), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red.shade600, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text('Keluar', + style: TextStyle(fontWeight: FontWeight.w700)), + ), + ), + ], ), ], ), diff --git a/spk_mobile/lib/screens/recommendation_screen.dart b/spk_mobile/lib/screens/recommendation_screen.dart index 7e8b6a9..b715536 100644 --- a/spk_mobile/lib/screens/recommendation_screen.dart +++ b/spk_mobile/lib/screens/recommendation_screen.dart @@ -3,10 +3,11 @@ import 'package:http/http.dart' as http; import 'dart:async'; import 'dart:convert'; import 'package:geolocator/geolocator.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import '../config/app_config.dart'; import '../models/kontrakan.dart'; import '../models/laundry.dart'; +import '../models/user.dart'; +import '../services/auth_service.dart'; import '../widgets/kontrakan_card.dart'; import '../widgets/laundry_card.dart'; @@ -28,6 +29,10 @@ class _RecommendationScreenState extends State { bool _noData = false; // true = memang tidak ada data, false = filter terlalu ketat + // User info + final _authService = AuthService(); + User? _currentUser; + // Bobot values (percentage, total must = 100) // Default: profil mahasiswa int _bobotHarga = 50; @@ -38,8 +43,7 @@ class _RecommendationScreenState extends State { // Jenis layanan selection for laundry String _selectedJenisLayanan = 'reguler'; - // Location values - String _referensiJarak = 'kampus'; + // Location values untuk referensi jarak (deteksi lokasi user) double? _userLatitude; double? _userLongitude; bool _isDetectingLocation = false; @@ -48,12 +52,6 @@ class _RecommendationScreenState extends State { widget.category == 'kontrakan' ? 'Jumlah Kamar' : 'Kecepatan Layanan'; String get _kriteria4Label => widget.category == 'kontrakan' ? 'Fasilitas' : 'Variasi Layanan'; - // ignore: unused_element - String get _kriteria3Key => - widget.category == 'kontrakan' ? 'jumlah_kamar' : 'kecepatan'; - // ignore: unused_element - String get _kriteria4Key => - widget.category == 'kontrakan' ? 'fasilitas' : 'layanan'; int get _totalBobot => _bobotHarga + _bobotJarak + _bobotKriteria3 + _bobotKriteria4; @@ -70,6 +68,33 @@ class _RecommendationScreenState extends State { _bobotJarak = 20; _bobotKriteria3 = 15; _bobotKriteria4 = 15; + + // Load user info + _loadUser(); + + // Auto-detect lokasi untuk laundry + if (widget.category == 'laundry') { + WidgetsBinding.instance.addPostFrameCallback((_) { + _detectUserLocation(); + }); + } + } + + Future _loadUser() async { + try { + // Use cached user first (no API call needed) + if (_authService.currentUser != null) { + if (!mounted) return; + setState(() => _currentUser = _authService.currentUser); + return; + } + // Fallback: load from SharedPreferences via API + await _authService.loadToken(); + if (!mounted) return; + setState(() => _currentUser = _authService.currentUser); + } catch (e) { + debugPrint('Load user error: $e'); + } } /// Auto-balance: when one bobot changes, redistribute the remaining @@ -123,17 +148,15 @@ class _RecommendationScreenState extends State { bobots[otherIdx[i]] = newOther[i]; } - // Final safety check: if sum != 100 due to clamping, adjust the largest + // Final safety: force total == 100 by adjusting the largest other bobot + // without snapping to 5 (exact correction takes priority over pretty numbers) int total = bobots.reduce((a, b) => a + b); if (total != 100) { int diff = 100 - total; int adjustIdx = otherIdx.reduce( (a, b) => bobots[a] >= bobots[b] ? a : b, ); - int adjusted = (bobots[adjustIdx] + diff).clamp(10, 70); - // Snap to nearest multiple of 5 - adjusted = ((adjusted / 5).round() * 5).clamp(10, 70); - bobots[adjustIdx] = adjusted; + bobots[adjustIdx] = (bobots[adjustIdx] + diff).clamp(5, 75); } _bobotHarga = bobots[0]; @@ -168,17 +191,31 @@ class _RecommendationScreenState extends State { return; } + // Validate location for laundry + if (widget.category == 'laundry' && _userLatitude == null) { + // Auto-detect location first, then re-calculate + await _detectUserLocation(); + if (!mounted) return; + // If still no location after detection attempt, warn and stop + if (_userLatitude == null || _userLongitude == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Lokasi belum terdeteksi. Aktifkan GPS dan coba lagi.', + ), + backgroundColor: Colors.orange, + ), + ); + return; + } + } + setState(() { _isLoading = true; _errorMessage = null; _noData = false; }); - // Clear image cache agar foto terbaru selalu dimuat - await DefaultCacheManager().emptyCache(); - PaintingBinding.instance.imageCache.clear(); - PaintingBinding.instance.imageCache.clearLiveImages(); - final endpoint = widget.category == 'kontrakan' ? '/saw/calculate/kontrakan' : '/saw/calculate/laundry'; @@ -197,11 +234,11 @@ class _RecommendationScreenState extends State { bodyParams['jenis_layanan'] = _selectedJenisLayanan; } - if (widget.category == 'laundry' && _referensiJarak == 'user') { - if (_userLatitude != null && _userLongitude != null) { - bodyParams['user_lat'] = _userLatitude; - bodyParams['user_lng'] = _userLongitude; - } + if (widget.category == 'laundry' && + _userLatitude != null && + _userLongitude != null) { + bodyParams['user_lat'] = _userLatitude; + bodyParams['user_lng'] = _userLongitude; } final response = await http @@ -215,6 +252,8 @@ class _RecommendationScreenState extends State { ) .timeout(const Duration(seconds: 15)); + if (!mounted) return; + if (response.statusCode == 200) { final data = json.decode(response.body); if (data['success'] == true) { @@ -245,35 +284,37 @@ class _RecommendationScreenState extends State { } catch (e) { debugPrint('SAW API Error: $e'); debugPrint('URL: ${AppConfig.baseUrl}$endpoint'); + if (!mounted) return; setState(() { _errorMessage = 'Tidak dapat terhubung ke server (${AppConfig.baseUrl}). Periksa koneksi internet Anda dan coba lagi.\n\nDetail: $e'; _hasCalculated = true; }); } finally { - setState(() { - _isLoading = false; - }); + if (mounted) { + setState(() { + _isLoading = false; + }); + } } } Future _detectUserLocation() async { + // Guard: prevent re-entrant / duplicate calls + if (_isDetectingLocation) return; + setState(() => _isDetectingLocation = true); try { bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Aktifkan GPS Anda terlebih dahulu'), - backgroundColor: Colors.red, - ), - ); - } - setState(() { - _referensiJarak = 'kampus'; - _isDetectingLocation = false; - }); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Aktifkan GPS Anda terlebih dahulu'), + backgroundColor: Colors.red, + ), + ); + setState(() => _isDetectingLocation = false); return; } @@ -281,71 +322,62 @@ class _RecommendationScreenState extends State { if (permission == LocationPermission.denied) { permission = await Geolocator.requestPermission(); if (permission == LocationPermission.denied) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Izin lokasi ditolak'), - backgroundColor: Colors.red, - ), - ); - } - setState(() { - _referensiJarak = 'kampus'; - _isDetectingLocation = false; - }); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Izin lokasi ditolak'), + backgroundColor: Colors.red, + ), + ); + setState(() => _isDetectingLocation = false); return; } } if (permission == LocationPermission.deniedForever) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Izin lokasi ditolak permanen. Ubah di pengaturan.', - ), - backgroundColor: Colors.red, + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Izin lokasi ditolak permanen. Ubah di pengaturan.', ), - ); - } - setState(() { - _referensiJarak = 'kampus'; - _isDetectingLocation = false; - }); + backgroundColor: Colors.red, + ), + ); + setState(() => _isDetectingLocation = false); return; } Position position = await Geolocator.getCurrentPosition( desiredAccuracy: LocationAccuracy.high, + ).timeout( + const Duration(seconds: 15), + onTimeout: () => throw TimeoutException('Deteksi lokasi timeout'), ); + + if (!mounted) return; setState(() { _userLatitude = position.latitude; _userLongitude = position.longitude; _isDetectingLocation = false; }); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Lokasi berhasil dideteksi!'), - backgroundColor: Colors.green, - duration: Duration(seconds: 2), - ), - ); - } + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Lokasi berhasil dideteksi!'), + backgroundColor: Colors.green, + duration: Duration(seconds: 2), + ), + ); } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Gagal mendeteksi lokasi: $e'), - backgroundColor: Colors.red, - ), - ); - } - setState(() { - _referensiJarak = 'kampus'; - _isDetectingLocation = false; - }); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Gagal mendeteksi lokasi: ${e.toString().replaceAll('Exception: ', '')}'), + backgroundColor: Colors.red, + ), + ); + setState(() => _isDetectingLocation = false); } } @@ -390,6 +422,8 @@ class _RecommendationScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + _buildUserInfoCard(), + const SizedBox(height: 16), _buildMethodInfoCard(), const SizedBox(height: 16), if (widget.category == 'laundry') ...[ @@ -409,12 +443,151 @@ class _RecommendationScreenState extends State { ); } + Widget _buildUserInfoCard() { + final name = _currentUser?.name ?? ''; + final email = _currentUser?.email ?? ''; + final initials = name.isEmpty + ? 'U' + : name.trim().split(' ').length >= 2 + ? '${name.trim().split(' ')[0][0]}${name.trim().split(' ')[1][0]}'.toUpperCase() + : name.trim()[0].toUpperCase(); + + final hour = DateTime.now().hour; + final greeting = hour < 11 + ? 'Selamat Pagi' + : hour < 15 + ? 'Selamat Siang' + : hour < 18 + ? 'Selamat Sore' + : 'Selamat Malam'; + + final categoryLabel = widget.category == 'kontrakan' ? 'kontrakan' : 'laundry'; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: Row( + children: [ + // Avatar + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [_categoryColor, _categoryColor.withValues(alpha: 0.7)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: _categoryColor.withValues(alpha: 0.3), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ], + ), + child: Center( + child: Text( + initials, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + ), + ), + const SizedBox(width: 14), + // Info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$greeting,', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade500, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + name.isEmpty ? 'Pengguna' : name, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w800, + color: Color(0xFF1A1A2E), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (email.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + email, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade400, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + // Category badge + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: _categoryColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + widget.category == 'kontrakan' + ? Icons.home_work_rounded + : Icons.local_laundry_service_rounded, + size: 14, + color: _categoryColor, + ), + const SizedBox(width: 5), + Text( + categoryLabel, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: _categoryColor, + ), + ), + ], + ), + ), + ], + ), + ); + } + Widget _buildMethodInfoCard() { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( gradient: LinearGradient( - colors: [_categoryColor, _categoryColor.withOpacity(0.8)], + colors: [_categoryColor, _categoryColor.withValues(alpha: 0.8)], ), borderRadius: BorderRadius.circular(16), boxShadow: [ @@ -902,158 +1075,114 @@ class _RecommendationScreenState extends State { children: [ Icon(Icons.my_location, color: _categoryColor, size: 20), const SizedBox(width: 8), - Text( - 'Referensi Jarak', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.bold, - color: Colors.grey[800], + Expanded( + child: Text( + 'Lokasi Saya', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Colors.grey[800], + ), ), ), + if (_userLatitude != null && !_isDetectingLocation) + InkWell( + onTap: _detectUserLocation, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(4), + child: Icon( + Icons.refresh, + size: 18, + color: _categoryColor, + ), + ), + ), ], ), const SizedBox(height: 4), Text( - 'Tentukan titik referensi untuk perhitungan jarak', + 'Jarak dihitung dari lokasi Anda saat ini', style: TextStyle(fontSize: 12, color: Colors.grey[500]), ), const SizedBox(height: 12), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - color: Colors.grey[50], - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.grey[200]!), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: _referensiJarak, - isExpanded: true, - icon: Icon(Icons.arrow_drop_down, color: _categoryColor), - items: [ - DropdownMenuItem( - value: 'kampus', - child: Row( - children: [ - Icon(Icons.school, size: 18, color: _categoryColor), - const SizedBox(width: 10), - const Text('Dari Kampus Polije'), - ], - ), + if (_isDetectingLocation) + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.blue[50], + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), ), - DropdownMenuItem( - value: 'user', - child: Row( - children: [ - Icon( - Icons.location_on, - size: 18, - color: Colors.green[700], - ), - const SizedBox(width: 10), - const Text('Dari Lokasi Saya'), - ], - ), + SizedBox(width: 10), + Text( + 'Mendeteksi lokasi...', + style: TextStyle(fontSize: 12), ), ], - onChanged: (val) { - setState(() { - _referensiJarak = val!; - }); - if (val == 'user' && _userLatitude == null) - _detectUserLocation(); - }, ), - ), - ), - if (_referensiJarak == 'user') ...[ - const SizedBox(height: 10), - if (_isDetectingLocation) - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Colors.blue[50], - borderRadius: BorderRadius.circular(8), - ), - child: const Row( - children: [ - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ), - SizedBox(width: 10), - Text( - 'Mendeteksi lokasi...', - style: TextStyle(fontSize: 12), - ), - ], - ), - ) - else if (_userLatitude != null && _userLongitude != null) - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Colors.green[50], - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.green[200]!), - ), - child: Row( - children: [ - const Icon( - Icons.check_circle, - size: 16, - color: Colors.green, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Lokasi: ${_userLatitude!.toStringAsFixed(6)}, ${_userLongitude!.toStringAsFixed(6)}', - style: TextStyle( - fontSize: 11, - color: Colors.green[700], - ), - ), - ), - InkWell( - onTap: _detectUserLocation, - child: Icon( - Icons.refresh, - size: 16, + ) + else if (_userLatitude != null && _userLongitude != null) + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.green[50], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.green[200]!), + ), + child: Row( + children: [ + const Icon( + Icons.check_circle, + size: 16, + color: Colors.green, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Lokasi terdeteksi: ${_userLatitude!.toStringAsFixed(6)}, ${_userLongitude!.toStringAsFixed(6)}', + style: TextStyle( + fontSize: 11, color: Colors.green[700], ), ), - ], - ), - ) - else - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Colors.orange[50], - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - const Icon(Icons.warning, size: 16, color: Colors.orange), - const SizedBox(width: 8), - const Expanded( - child: Text( - 'Lokasi belum terdeteksi', - style: TextStyle(fontSize: 12), - ), - ), - TextButton( - onPressed: _detectUserLocation, - child: const Text( - 'Deteksi', - style: TextStyle(fontSize: 12), - ), - ), - ], - ), + ), + ], ), - ], + ) + else + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.orange[50], + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.warning, size: 16, color: Colors.orange), + const SizedBox(width: 8), + const Expanded( + child: Text( + 'Lokasi belum terdeteksi', + style: TextStyle(fontSize: 12), + ), + ), + TextButton( + onPressed: _detectUserLocation, + child: const Text( + 'Deteksi', + style: TextStyle(fontSize: 12), + ), + ), + ], + ), + ), ], ), ); @@ -1123,7 +1252,6 @@ class _RecommendationScreenState extends State { children: [ _buildBobotSummary(), if (widget.category == 'laundry' && - _referensiJarak == 'user' && _userLatitude != null) Container( width: double.infinity, diff --git a/spk_mobile/lib/screens/search_screen.dart b/spk_mobile/lib/screens/search_screen.dart index afb327c..193a53c 100644 --- a/spk_mobile/lib/screens/search_screen.dart +++ b/spk_mobile/lib/screens/search_screen.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import '../models/kontrakan.dart'; import '../models/laundry.dart'; import '../services/kontrakan_service.dart'; @@ -50,20 +49,22 @@ class _SearchScreenState extends State { Future _loadData() async { setState(() => _isLoading = true); - // Clear image cache agar data terbaru dari server selalu dimuat - await DefaultCacheManager().emptyCache(); - PaintingBinding.instance.imageCache.clear(); - PaintingBinding.instance.imageCache.clearLiveImages(); - final kontrakan = await _kontrakanService.getKontrakan(); - final laundry = await _laundryService.getLaundry(); - setState(() { - _allKontrakan = kontrakan; - _filteredKontrakan = kontrakan; - _allLaundry = laundry; - _filteredLaundry = laundry; - _isLoading = false; - }); - _loadFavoriteIds(); + try { + final kontrakan = await _kontrakanService.getKontrakan(); + final laundry = await _laundryService.getLaundry(); + if (!mounted) return; + setState(() { + _allKontrakan = kontrakan; + _filteredKontrakan = kontrakan; + _allLaundry = laundry; + _filteredLaundry = laundry; + _isLoading = false; + }); + _loadFavoriteIds(); + } catch (e) { + if (!mounted) return; + setState(() => _isLoading = false); + } } Future _loadFavoriteIds() async { @@ -84,38 +85,48 @@ class _SearchScreenState extends State { Future _toggleKontrakanFav(int id) async { final wasFav = _favKontrakanIds.contains(id); setState(() { - if (wasFav) - _favKontrakanIds.remove(id); - else - _favKontrakanIds.add(id); + if (wasFav) _favKontrakanIds.remove(id); else _favKontrakanIds.add(id); }); - final result = await _favoriteService.toggleKontrakanFavorite(id); - if (result['success'] != true && mounted) { - setState(() { - if (wasFav) - _favKontrakanIds.add(id); - else - _favKontrakanIds.remove(id); - }); + try { + final result = await _favoriteService.toggleKontrakanFavorite(id); + if (result['success'] != true && mounted) { + setState(() { + if (wasFav) _favKontrakanIds.add(id); else _favKontrakanIds.remove(id); + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(result['message'] ?? 'Gagal'), backgroundColor: Colors.red.shade600), + ); + } + } catch (e) { + if (mounted) { + setState(() { + if (wasFav) _favKontrakanIds.add(id); else _favKontrakanIds.remove(id); + }); + } } } Future _toggleLaundryFav(int id) async { final wasFav = _favLaundryIds.contains(id); setState(() { - if (wasFav) - _favLaundryIds.remove(id); - else - _favLaundryIds.add(id); + if (wasFav) _favLaundryIds.remove(id); else _favLaundryIds.add(id); }); - final result = await _favoriteService.toggleLaundryFavorite(id); - if (result['success'] != true && mounted) { - setState(() { - if (wasFav) - _favLaundryIds.add(id); - else - _favLaundryIds.remove(id); - }); + try { + final result = await _favoriteService.toggleLaundryFavorite(id); + if (result['success'] != true && mounted) { + setState(() { + if (wasFav) _favLaundryIds.add(id); else _favLaundryIds.remove(id); + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(result['message'] ?? 'Gagal'), backgroundColor: Colors.red.shade600), + ); + } + } catch (e) { + if (mounted) { + setState(() { + if (wasFav) _favLaundryIds.add(id); else _favLaundryIds.remove(id); + }); + } } } @@ -194,10 +205,9 @@ class _SearchScreenState extends State { }).toList(); } - // Filter by price + // Filter by price (laundry hargaKiloan is per-kg, no multiplication needed) filtered = filtered.where((l) { - final harga = l.hargaKiloan * 10; // Approximate for 10kg - return harga >= _priceRange.start && harga <= _priceRange.end; + return l.hargaKiloan >= _priceRange.start && l.hargaKiloan <= _priceRange.end; }).toList(); setState(() => _filteredLaundry = filtered); @@ -280,8 +290,10 @@ class _SearchScreenState extends State { children: [ Expanded( child: GestureDetector( - onTap: () => - setState(() => _selectedCategory = 'Kontrakan'), + onTap: () { + setState(() => _selectedCategory = 'Kontrakan'); + _applyFilters(); + }, child: AnimatedContainer( duration: const Duration(milliseconds: 200), padding: const EdgeInsets.symmetric(vertical: 10), @@ -329,8 +341,10 @@ class _SearchScreenState extends State { const SizedBox(width: 4), Expanded( child: GestureDetector( - onTap: () => - setState(() => _selectedCategory = 'Laundry'), + onTap: () { + setState(() => _selectedCategory = 'Laundry'); + _applyLaundryFilters(); + }, child: AnimatedContainer( duration: const Duration(milliseconds: 200), padding: const EdgeInsets.symmetric(vertical: 10), diff --git a/spk_mobile/lib/services/auth_service.dart b/spk_mobile/lib/services/auth_service.dart index 3230369..f6357ea 100644 --- a/spk_mobile/lib/services/auth_service.dart +++ b/spk_mobile/lib/services/auth_service.dart @@ -220,6 +220,9 @@ class AuthService { required String password, required String passwordConfirmation, }) async { + if (_currentUser == null || _token == null) { + return {'success': false, 'message': 'Silakan login ulang'}; + } try { final response = await http.put( Uri.parse('${AppConfig.baseUrl}/profile/update'), @@ -229,8 +232,8 @@ class AuthService { 'Authorization': 'Bearer $_token', }, body: jsonEncode({ - 'name': _currentUser?.name ?? '', - 'email': _currentUser?.email ?? '', + 'name': _currentUser!.name, + 'email': _currentUser!.email, 'password': password, 'password_confirmation': passwordConfirmation, }), diff --git a/spk_mobile/lib/services/favorite_service.dart b/spk_mobile/lib/services/favorite_service.dart index 6713d8d..13f90b8 100644 --- a/spk_mobile/lib/services/favorite_service.dart +++ b/spk_mobile/lib/services/favorite_service.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import '../config/app_config.dart'; import '../models/kontrakan.dart'; @@ -21,54 +22,118 @@ class FavoriteService { return headers; } - // Get all user's favorites with full data + /// Pastikan token ada sebelum request + bool _ensureAuthenticated() { + if (_authService.token == null) { + debugPrint('[FAV] ❌ Token NULL — user belum login!'); + return false; + } + return true; + } + + // Get all user's favorites with full data (raw JSON) Future> getFavorites() async { + if (!_ensureAuthenticated()) { + return {'success': false, 'message': 'Belum login', 'kontrakan': [], 'laundry': []}; + } + try { - final response = await http.get( - Uri.parse('${AppConfig.baseUrl}/favorites'), - headers: _headers, - ); + final url = '${AppConfig.baseUrl}/favorites'; + debugPrint('[FAV] GET $url'); + debugPrint('[FAV] Token: ${_authService.token!.substring(0, 10)}...'); + + final response = await http + .get(Uri.parse(url), headers: _headers) + .timeout(AppConfig.connectionTimeout); + + debugPrint('[FAV] GET Status: ${response.statusCode}'); + debugPrint('[FAV] GET Body: ${response.body.length > 300 ? response.body.substring(0, 300) : response.body}'); + + if (response.statusCode == 401) { + debugPrint('[FAV] ❌ 401 Unauthorized — token expired/invalid'); + return {'success': false, 'message': 'Sesi expired, silakan login ulang', 'kontrakan': [], 'laundry': []}; + } final data = jsonDecode(response.body); if (response.statusCode == 200 && data['success'] == true) { return { 'success': true, - 'kontrakan': data['data']['kontrakan'] ?? [], - 'laundry': data['data']['laundry'] ?? [], + 'kontrakan': data['data']?['kontrakan'] ?? [], + 'laundry': data['data']?['laundry'] ?? [], }; } else { - return {'success': false, 'kontrakan': [], 'laundry': []}; + debugPrint('[FAV] ❌ Response not success: ${response.statusCode} ${data['message']}'); + return { + 'success': false, + 'message': data['message'] ?? 'Gagal memuat favorit', + 'kontrakan': [], + 'laundry': [], + }; } - } catch (e) { - // Error getting favorites silently - return {'success': false, 'kontrakan': [], 'laundry': []}; + } catch (e, stack) { + debugPrint('[FAV] ❌ Exception in getFavorites: $e'); + debugPrint('[FAV] Stack: $stack'); + return { + 'success': false, + 'message': 'Gagal terhubung ke server: $e', + 'kontrakan': [], + 'laundry': [], + }; } } - // Get favorites with parsed models + // Get favorites with parsed Kontrakan/Laundry models Future> getFavoritesWithModels() async { + final result = await getFavorites(); + + if (result['success'] != true) { + // Return typed empty lists to avoid cast errors downstream + return { + 'success': false, + 'message': result['message'] ?? 'Gagal memuat favorit', + 'kontrakan': [], + 'laundry': [], + }; + } + try { - final result = await getFavorites(); - if (result['success'] != true) return result; + final rawKontrakan = result['kontrakan'] as List? ?? []; + final rawLaundry = result['laundry'] as List? ?? []; - final kontrakanList = (result['kontrakan'] as List) - .map((json) => Kontrakan.fromJson(json as Map)) - .toList(); + debugPrint('[FAV] Parsing ${rawKontrakan.length} kontrakan, ${rawLaundry.length} laundry'); - final laundryList = (result['laundry'] as List) - .map((json) => Laundry.fromJson(json as Map)) - .toList(); + final kontrakanList = []; + for (final json in rawKontrakan) { + try { + kontrakanList.add(Kontrakan.fromJson(json as Map)); + } catch (e) { + debugPrint('[FAV] ⚠ Skip kontrakan parse error: $e — data: $json'); + } + } + + final laundryList = []; + for (final json in rawLaundry) { + try { + laundryList.add(Laundry.fromJson(json as Map)); + } catch (e) { + debugPrint('[FAV] ⚠ Skip laundry parse error: $e — data: $json'); + } + } + + debugPrint('[FAV] ✅ Parsed ${kontrakanList.length} kontrakan, ${laundryList.length} laundry'); return { 'success': true, 'kontrakan': kontrakanList, 'laundry': laundryList, }; - } catch (e) { - // Error parsing favorites silently + } catch (e, stack) { + debugPrint('[FAV] ❌ Exception parsing favorites: $e'); + debugPrint('[FAV] Stack: $stack'); return { 'success': false, + 'message': 'Gagal memproses data favorit', 'kontrakan': [], 'laundry': [], }; @@ -83,80 +148,121 @@ class FavoriteService { return {'kontrakan': [], 'laundry': []}; } - return { - 'kontrakan': (result['kontrakan'] as List) - .map((e) => (e as Map)['id'] as int? ?? 0) - .toList(), - 'laundry': (result['laundry'] as List) - .map((e) => (e as Map)['id'] as int? ?? 0) - .toList(), - }; + final kontrakanIds = []; + for (final e in (result['kontrakan'] as List? ?? [])) { + final id = (e as Map?)?['id']; + if (id != null) kontrakanIds.add(id is int ? id : int.tryParse(id.toString()) ?? 0); + } + + final laundryIds = []; + for (final e in (result['laundry'] as List? ?? [])) { + final id = (e as Map?)?['id']; + if (id != null) laundryIds.add(id is int ? id : int.tryParse(id.toString()) ?? 0); + } + + debugPrint('[FAV] IDs → kontrakan: $kontrakanIds, laundry: $laundryIds'); + return {'kontrakan': kontrakanIds, 'laundry': laundryIds}; } catch (e) { + debugPrint('[FAV] ❌ Exception in getFavoriteIds: $e'); return {'kontrakan': [], 'laundry': []}; } } // Toggle kontrakan favorite status Future> toggleKontrakanFavorite(int kontrakanId) async { + if (!_ensureAuthenticated()) { + return {'success': false, 'message': 'Belum login'}; + } + try { - final response = await http.post( - Uri.parse('${AppConfig.baseUrl}/favorites/kontrakan/$kontrakanId'), - headers: _headers, - ); + final url = '${AppConfig.baseUrl}/favorites/kontrakan/$kontrakanId'; + debugPrint('[FAV] POST $url'); + + final response = await http + .post(Uri.parse(url), headers: _headers) + .timeout(AppConfig.connectionTimeout); + + debugPrint('[FAV] Toggle kontrakan → ${response.statusCode}: ${response.body}'); + + if (response.statusCode == 401) { + return {'success': false, 'message': 'Sesi expired, silakan login ulang'}; + } final data = jsonDecode(response.body); + final statusOk = response.statusCode == 200 || response.statusCode == 201; - if (response.statusCode == 200 && data['success'] == true) { + if (statusOk && data['success'] == true) { return { 'success': true, 'message': data['message'] ?? 'Status favorit berhasil diubah', - 'isFavorite': data['data']['is_favorite'] ?? false, + 'isFavorite': data['is_favorited'] ?? false, }; } else { return { 'success': false, - 'message': data['message'] ?? 'Gagal mengubah status favorit', + 'message': data['message'] ?? 'Gagal mengubah status favorit (${response.statusCode})', }; } } catch (e) { - return {'success': false, 'message': 'Error: $e'}; + debugPrint('[FAV] ❌ Exception toggleKontrakan: $e'); + return {'success': false, 'message': 'Gagal terhubung ke server'}; } } // Toggle laundry favorite status Future> toggleLaundryFavorite(int laundryId) async { + if (!_ensureAuthenticated()) { + return {'success': false, 'message': 'Belum login'}; + } + try { - final response = await http.post( - Uri.parse('${AppConfig.baseUrl}/favorites/laundry/$laundryId'), - headers: _headers, - ); + final url = '${AppConfig.baseUrl}/favorites/laundry/$laundryId'; + debugPrint('[FAV] POST $url'); + + final response = await http + .post(Uri.parse(url), headers: _headers) + .timeout(AppConfig.connectionTimeout); + + debugPrint('[FAV] Toggle laundry → ${response.statusCode}: ${response.body}'); + + if (response.statusCode == 401) { + return {'success': false, 'message': 'Sesi expired, silakan login ulang'}; + } final data = jsonDecode(response.body); + final statusOk = response.statusCode == 200 || response.statusCode == 201; - if (response.statusCode == 200 && data['success'] == true) { + if (statusOk && data['success'] == true) { return { 'success': true, 'message': data['message'] ?? 'Status favorit berhasil diubah', - 'isFavorite': data['data']['is_favorite'] ?? false, + 'isFavorite': data['is_favorited'] ?? false, }; } else { return { 'success': false, - 'message': data['message'] ?? 'Gagal mengubah status favorit', + 'message': data['message'] ?? 'Gagal mengubah status favorit (${response.statusCode})', }; } } catch (e) { - return {'success': false, 'message': 'Error: $e'}; + debugPrint('[FAV] ❌ Exception toggleLaundry: $e'); + return {'success': false, 'message': 'Gagal terhubung ke server'}; } } // Remove favorite Future> removeFavorite(int favoriteId) async { + if (!_ensureAuthenticated()) { + return {'success': false, 'message': 'Belum login'}; + } + try { - final response = await http.delete( - Uri.parse('${AppConfig.baseUrl}/favorites/$favoriteId'), - headers: _headers, - ); + final response = await http + .delete( + Uri.parse('${AppConfig.baseUrl}/favorites/$favoriteId'), + headers: _headers, + ) + .timeout(AppConfig.connectionTimeout); final data = jsonDecode(response.body); @@ -172,18 +278,18 @@ class FavoriteService { }; } } catch (e) { - return {'success': false, 'message': 'Error: $e'}; + debugPrint('[FAV] ❌ Exception removeFavorite: $e'); + return {'success': false, 'message': 'Gagal terhubung ke server'}; } } // Check if kontrakan is favorite Future isKontrakanFavorite(int kontrakanId) async { try { - final favorites = await getFavorites(); - final kontrakanList = favorites['kontrakan'] as List? ?? []; - return kontrakanList.contains(kontrakanId); + final ids = await getFavoriteIds(); + return (ids['kontrakan'] ?? []).contains(kontrakanId); } catch (e) { - // Error checking favorite silently + debugPrint('[FAV] ❌ Exception isKontrakanFavorite: $e'); return false; } } @@ -191,11 +297,10 @@ class FavoriteService { // Check if laundry is favorite Future isLaundryFavorite(int laundryId) async { try { - final favorites = await getFavorites(); - final laundryList = favorites['laundry'] as List? ?? []; - return laundryList.contains(laundryId); + final ids = await getFavoriteIds(); + return (ids['laundry'] ?? []).contains(laundryId); } catch (e) { - // Error checking favorite silently + debugPrint('[FAV] ❌ Exception isLaundryFavorite: $e'); return false; } } diff --git a/spk_mobile/lib/services/kontrakan_service.dart b/spk_mobile/lib/services/kontrakan_service.dart index fefce98..51444d9 100644 --- a/spk_mobile/lib/services/kontrakan_service.dart +++ b/spk_mobile/lib/services/kontrakan_service.dart @@ -30,22 +30,17 @@ class KontrakanService { String status = 'tersedia', }) async { try { - var url = '${AppConfig.baseUrl}/kontrakan?status=$status'; + var uri = Uri.parse('${AppConfig.baseUrl}/kontrakan').replace( + queryParameters: { + 'status': status, + if (search != null && search.isNotEmpty) 'search': search, + if (hargaMin != null) 'harga_min': hargaMin.toString(), + if (hargaMax != null) 'harga_max': hargaMax.toString(), + if (jumlahKamar != null) 'jumlah_kamar': jumlahKamar.toString(), + }, + ); - if (search != null && search.isNotEmpty) { - url += '&search=$search'; - } - if (hargaMin != null) { - url += '&harga_min=$hargaMin'; - } - if (hargaMax != null) { - url += '&harga_max=$hargaMax'; - } - if (jumlahKamar != null) { - url += '&jumlah_kamar=$jumlahKamar'; - } - - final response = await http.get(Uri.parse(url), headers: _headers); + final response = await http.get(uri, headers: _headers); if (response.statusCode == 200) { final data = jsonDecode(response.body); diff --git a/spk_mobile/lib/services/server_discovery_service.dart b/spk_mobile/lib/services/server_discovery_service.dart new file mode 100644 index 0000000..2234afc --- /dev/null +++ b/spk_mobile/lib/services/server_discovery_service.dart @@ -0,0 +1,145 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; +import '../config/app_config.dart'; + +/// Service untuk mendeteksi IP server Laravel secara otomatis. +/// Urutan pencarian: +/// 1. IP tersimpan di cache (paling cepat jika masih valid) +/// 2. Android Emulator (10.0.2.2) +/// 3. Localhost (desktop / iOS simulator) +/// 4. Scan subnet lokal HP secara paralel +class ServerDiscoveryService { + static const int _port = 8000; + static const String _cacheKey = 'discovered_server_url'; + static const Duration _probeTimeout = Duration(seconds: 2); + + // ─── PUBLIC ENTRY POINT ───────────────────────────────────────────────────── + + /// Temukan server dan update [AppConfig] secara otomatis. + /// Kembalikan `true` jika server ditemukan, `false` jika tidak. + static Future discover({ + void Function(String status)? onStatus, + }) async { + onStatus?.call('Mencari server...'); + + // 1. Coba cache terlebih dahulu + final cached = await _getCachedUrl(); + if (cached != null) { + onStatus?.call('Mencoba koneksi tersimpan...'); + if (await _isAlive(cached)) { + AppConfig.setServerUrl(cached); + return true; + } + } + + // 2. Android Emulator + const emulatorUrl = 'http://10.0.2.2:$_port'; + if (await _isAlive(emulatorUrl)) { + await _saveCache(emulatorUrl); + AppConfig.setServerUrl(emulatorUrl); + return true; + } + + // 3. Localhost (Windows/iOS) + const localhostUrl = 'http://localhost:$_port'; + if (await _isAlive(localhostUrl)) { + await _saveCache(localhostUrl); + AppConfig.setServerUrl(localhostUrl); + return true; + } + + // 4. Scan subnet lokal HP + onStatus?.call('Memindai jaringan lokal...'); + final subnets = await _getLocalSubnets(); + if (subnets.isNotEmpty) { + final found = await _scanSubnets(subnets, onStatus: onStatus); + if (found != null) { + await _saveCache(found); + AppConfig.setServerUrl(found); + return true; + } + } + + // Gagal — tetap pakai nilai AppConfig yang lama (last known) + return false; + } + + // ─── HELPERS ──────────────────────────────────────────────────────────────── + + /// Cek apakah server ada di URL tersebut (respons apapun = server hidup). + static Future _isAlive(String serverUrl) async { + try { + final response = await http + .get( + Uri.parse('$serverUrl/api'), + headers: {'Accept': 'application/json'}, + ) + .timeout(_probeTimeout); + // Respons 4xx/5xx tetap berarti server bisa dijangkau + return response.statusCode > 0; + } catch (_) { + return false; + } + } + + /// Ambil semua subnet IPv4 milik device (e.g., "192.168.1"). + static Future> _getLocalSubnets() async { + final subnets = []; + try { + final interfaces = await NetworkInterface.list( + type: InternetAddressType.IPv4, + includeLinkLocal: false, + ); + for (final iface in interfaces) { + for (final addr in iface.addresses) { + final parts = addr.address.split('.'); + if (parts.length == 4 && parts[0] != '127') { + final subnet = '${parts[0]}.${parts[1]}.${parts[2]}'; + if (!subnets.contains(subnet)) subnets.add(subnet); + } + } + } + } catch (_) {} + return subnets; + } + + /// Scan semua host di subnet secara paralel, kembalikan URL pertama yang aktif. + static Future _scanSubnets( + List subnets, { + void Function(String)? onStatus, + }) async { + final completer = Completer(); + int remaining = subnets.length * 254; + + if (remaining == 0) return null; + + for (final subnet in subnets) { + for (int i = 1; i <= 254; i++) { + final url = 'http://$subnet.$i:$_port'; + _isAlive(url).then((alive) { + if (alive && !completer.isCompleted) { + completer.complete(url); + } + remaining--; + if (remaining <= 0 && !completer.isCompleted) { + completer.complete(null); + } + }); + } + } + + return completer.future; + } + + static Future _getCachedUrl() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_cacheKey); + } + + static Future _saveCache(String serverUrl) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_cacheKey, serverUrl); + } +}