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"
+
+
+
+ Estimasi Selesai (Jam) *
+
+
+
+
+
+
+ Deskripsi (Opsional)
+
+
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 @@
- Jenis Layanan
- Harga/kg
- Kecepatan
+ Nama Paket
+ Harga
+ Estimasi Selesai
@@ -242,7 +242,7 @@ class="btn btn-sm btn-danger">
- {{ 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' : '-' }}
@@ -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)
-
+
@else
@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'); ?>
-
-
-
-
-
-
- Dashboard
- Kontrakan
- Detail
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Update Status Cepat
- Tandai status ketersediaan kontrakan
-
-
-
-
-
-
-
-
-
-
-
- foto): ?>
-
-
-
-
-
-
-
-
- Klik foto untuk melihat ukuran penuh
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Foto Tidak Tersedia
-
-
- Belum ada foto yang diupload untuk kontrakan ini
-
-
-
-
-
-
-
-
-
-
-
- galeri->count() > 0): ?>
-
- galeri; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $foto): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
-
-
-
-
- is_primary): ?>
-
- Utama
-
-
-
-
-
- is_primary): ?>
-
-
-
-
-
-
-
-
- #urutan); ?>
-
-
-
-
- popLoop(); $loop = $__env->getLastLoop(); ?>
-
-
-
-
-
Belum ada foto di galeri
-
Upload foto untuk menampilkan galeri
-
- Upload Foto Pertama
-
-
-
-
-
-
-
-
-
-
-
-
-
- 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): ?>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Nama Kontrakan
-
nama); ?>
-
-
-
-
-
-
-
-
-
-
-
-
Alamat Lengkap
-
alamat); ?>
-
-
-
-
-
- no_whatsapp): ?>
-
-
-
-
-
-
-
Kontak WhatsApp
-
no_whatsapp); ?>
-
-
-
-
-
-
-
-
-
-
-
-
-
- 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'): ?>
-
-
-
-
- Hapus Data
-
-
-
-
- Hapus Data (Terbatas)
-
-
-
-
-
-
-
-
-
-
-
×
-
-
-
-
-
-
-
-
- Nomor WhatsApp berhasil disalin ke clipboard!
-
-
-
-
-
-
-
-
-
-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()): ?>
-
-
- onFirstPage()): ?>
-
-
-
-
-
-
-
-
-
-
-
- hasMorePages()): ?>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- firstItem()): ?>
- firstItem()); ?>
-
-
- lastItem()); ?>
-
- count()); ?>
-
-
-
-
- total()); ?>
-
-
-
-
-
-
-
-
- onFirstPage()): ?>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- addLoop($__currentLoopData); foreach($__currentLoopData as $element): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
-
-
-
-
-
-
-
-
-
- addLoop($__currentLoopData); foreach($__currentLoopData as $page => $url): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
- currentPage()): ?>
-
-
-
-
-
-
-
-
-
- popLoop(); $loop = $__env->getLastLoop(); ?>
-
- popLoop(); $loop = $__env->getLastLoop(); ?>
-
-
- hasMorePages()): ?>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ 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
-
-
-
-
-
-
-
- User
-
- Semua User
- addLoop($__currentLoopData); foreach($__currentLoopData as $u): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
- id ? 'selected' : ''); ?>>name); ?>
- popLoop(); $loop = $__env->getLastLoop(); ?>
-
-
-
-
- Action
-
- Semua Action
- >Create
- >Update
- >Delete
- >Export
-
-
-
-
- Model Type
-
- Semua
- >Kontrakan
- >Laundry
- >Kriteria
- >User
-
-
-
-
-
-
-
-
-
-
-
-
-
- isEmpty()): ?>
-
-
-
Tidak ada aktivitas ditemukan
-
-
-
-
-
-
- Waktu
- User
- Action
- Deskripsi
- Model
- IP Address
-
-
-
- addLoop($__currentLoopData); foreach($__currentLoopData as $log): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
-
-
- 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); ?>
-
-
- popLoop(); $loop = $__env->getLastLoop(); ?>
-
-
-
-
-
-
-
-
-
- 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
-
-
-
- Dashboard
- Booking
-
-
-
-
- Booking Baru
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Status
-
- Semua Status
- >Menunggu Konfirmasi
- >Dikonfirmasi
- >Sedang Ditempati
- >Selesai
- >Dibatalkan
-
-
-
- Kontrakan
-
- Semua Kontrakan
- addLoop($__currentLoopData); foreach($__currentLoopData as $k): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
- id ? 'selected' : ''); ?>>nama); ?>
- popLoop(); $loop = $__env->getLastLoop(); ?>
-
-
-
-
-
-
-
-
-
- '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(); ?>
-
-
-
-
-
-
-
-
-
- ID
- Kontrakan
- Penyewa
- Periode
- Biaya
- Status
- Pembayaran
- Aksi
-
-
-
- addLoop($__currentLoopData); foreach($__currentLoopData as $booking): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); $__empty_1 = false; ?>
-
-
- #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'): ?>
-
-
-
-
-
-
-
-
-
-
-
- popLoop(); $loop = $__env->getLastLoop(); if ($__empty_1): ?>
-
-
-
- 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'); ?>
-
-
-
-
-
-
- Dashboard
- Kontrakan
- Tambah Data
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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
-
-
-
-
-
-
-
-
-
-
-
-
-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'); ?>
-
-
-
-
-
-
- Dashboard
- Kontrakan
- Edit Data
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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
-
-
-
-
- Buat Backup Baru
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Tips: Lakukan backup secara berkala untuk mengamankan data. Backup dibuat dalam format ZIP dan dapat di-download.
-
-
-
-
-
-
-
-
-
-
-
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
-
-
-
-
-
- Restore
-
-
-
-
-
-
-
- Hapus
-
-
-
-
-
-
-
-
- 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'); ?>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Berhasil!
-
-
-
-
-
-
-
-
-
- Terjadi Kesalahan!
-
-
-
-
-
-
-
-
-
-
-
-
-
-
⚠️ Peringatan Total Bobot!
-
- addLoop($__currentLoopData); foreach($__currentLoopData as $warning): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
-
- popLoop(); $loop = $__env->getLastLoop(); ?>
-
-
-
-
- Total bobot untuk setiap tipe bisnis harus = 1.00 (atau 100%) agar perhitungan SAW valid dan akurat.
- Total bobot tiap tipe bisnis harus = 1.00 (100%)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 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); ?>
-
-
-
-
-
-
-
- popLoop(); $loop = $__env->getLastLoop(); ?>
-
-
-
-
-
-
-
-
-
-
- No
-
-
-
- Tipe Bisnis
-
-
-
-
-
- Nama Kriteria
-
-
-
-
-
- Bobot
-
-
-
-
-
- Tipe
-
-
-
-
- Keterangan
-
-
- Aksi
-
-
-
-
- addLoop($__currentLoopData); foreach($__currentLoopData as $index => $item): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
-
-
-
-
-
- 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'): ?>
-
-
-
-
-
-
-
-
-
-
-
- popLoop(); $loop = $__env->getLastLoop(); ?>
-
-
-
-
-
-
-
-
📊 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
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Apakah Anda yakin?
-
-
- Kriteria akan dihapus secara permanen.
-
-
-
-
-
- Peringatan: Tindakan ini tidak dapat dibatalkan. Data kriteria yang dihapus akan hilang selamanya dan dapat mempengaruhi perhitungan SAW.
-
-
-
-
-
-
-
-
-
-
-
-
-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'); ?>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
total() ?? 0); ?>
-
Total Kontrakan
-
-
-
-
-
-
-
Harga Terendah
-
-
-
-
-
-
-
Jarak Terjauh
-
-
-
-
-
-
-
Kamar Terbanyak
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Cari Kontrakan
-
-
-
-
- 💡 Ketik "WiFi", "Dapur", "AC" atau kata kunci lainnya untuk hasil lebih spesifik
- 💡 Coba "WiFi", "Dapur", "AC"
-
-
-
-
-
-
-
-
- Filter Lanjutan
-
-
- Reset
-
-
-
-
-
-
-
-
- 💰 Range Harga per Tahun
-
-
-
-
- Range tersedia: Rp - Rp
- Rp -
-
-
-
-
-
-
-
- 📍 Jarak Max dari Kampus
-
-
-
- km
-
-
-
Geser untuk menyesuaikan
-
-
-
- Jarak terjauh tersedia: km
- Max: km
-
-
-
-
-
-
-
- 🏠 Jumlah Kamar
-
-
-
-
- Range: - kamar
-
-
-
-
- count() > 0): ?>
-
-
-
- ✨ Fasilitas yang Diinginkan
-
-
-
-
- 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
-
-
-
-
-
-
-
-
- 📋 Urutkan Berdasarkan
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- dari
-
- kontrakan
-
-
-
-
-
-
- ""
- 🔍
-
-
-
-
-
-
-
- Rp - Rp
-
-
- 💰
-
-
-
-
-
-
- Max km
- 📍km
-
-
-
-
-
-
- - kamar
- 🏠-
-
-
-
-
-
-
- fasilitas
- ✨
-
-
-
-
-
- 0): ?>
- query();
- $queryString = !empty($queryParams) ? '?' . http_build_query($queryParams) : '';
- } catch (\Exception $e) {
- $queryString = '';
- }
- ?>
-
-
-
-
-
-
-
-
-
-
-
- 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'): ?>
-
-
-
-
-
-
-
-
- No
-
-
- Nama Kontrakan
-
-
- Alamat
-
-
- Harga/Tahun
-
-
- Jarak
-
-
- Fasilitas
-
-
- Kamar
-
-
- Aksi
-
-
-
-
- addLoop($__currentLoopData); foreach($__currentLoopData as $index => $item): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
-
- user()->role == 'super_admin'): ?>
-
-
-
-
-
-
- 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'): ?>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- popLoop(); $loop = $__env->getLastLoop(); ?>
-
-
-
-
-
-
-
- hasPages()): ?>
-
-
-
-
-
-
-
-
-
-
-
-
-
Konfirmasi Hapus Data
-
Tindakan ini tidak dapat dibatalkan dan akan menghapus data secara permanen!
-
-
-
Apakah Anda yakin ingin menghapus kontrakan berikut:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Batal
-
-
- Ya, Hapus
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Konfirmasi Hapus Multiple Data
-
Tindakan ini tidak dapat dibatalkan dan akan menghapus data secara permanen!
-
-
-
- Anda akan menghapus 0 kontrakan:
-
-
-
-
-
-
-
-
- Batal
-
-
- Ya, Hapus Semua
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-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'); ?>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Cari User
-
-
-
-
- Role
-
- Semua Role
- >Admin
- >Super Admin
-
-
-
-
- Status
-
- Semua
- >Aktif
- >Tidak Aktif
-
-
-
-
-
-
-
-
-
-
-
-
-
- isEmpty()): ?>
-
-
-
Tidak ada user ditemukan
-
-
-
-
-
-
- Nama
- Email
- Role
- Status
- Dibuat
- Aksi
-
-
-
- addLoop($__currentLoopData); foreach($__currentLoopData as $user): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
-
-
-
-
- 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()): ?>
-
-
-
-
-
-
-
-
-
-
-
- popLoop(); $loop = $__env->getLastLoop(); ?>
-
-
-
-
-
-
-
-
-
- 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'); ?>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Cari Laundry
-
-
-
-
-
-
-
- Cari
-
-
-
-
- 💡 Coba "WiFi", "Antar Jemput", "24 Jam" untuk hasil lebih spesifik
- 💡 Coba "WiFi", "Antar Jemput"
-
-
-
-
-
-
-
-
- Filter Lanjutan
-
-
- Reset
-
-
-
-
-
-
-
-
- 💰 Range Harga per KG
-
-
-
-
- Range: Rp - Rp
- Rp -
-
-
-
-
-
-
-
- 📍 Jarak Max dari Kampus
-
-
-
- km
-
-
-
Geser untuk menyesuaikan
-
-
-
- Jarak terjauh: km
- Max: km
-
-
-
-
-
-
-
- ⚡ Jenis Layanan
-
-
-
-
- addLoop($__currentLoopData); foreach($__currentLoopData as $jenis): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
-
-
-
-
- style="accent-color: #667eea;"
- >
-
-
-
-
-
-
- popLoop(); $loop = $__env->getLastLoop(); ?>
-
-
-
-
-
-
- count() > 0): ?>
-
-
-
- ✨ Fasilitas yang Diinginkan
-
-
-
-
- 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
-
-
-
-
-
-
-
-
- 📋 Urutkan Berdasarkan
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- dari
-
- laundry
-
-
-
-
-
-
- ""
- 🔍
-
-
-
-
-
-
-
- Rp - Rp
-
-
- 💰
-
-
-
-
-
-
- Max km
- 📍km
-
-
-
-
-
-
- layanan
- ⚡
-
-
-
-
-
-
- fasilitas
- ✨
-
-
-
-
-
- 0): ?>
-
-
-
-
-
-
-
-
-
-
-
-
- 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'): ?>
-
-
-
-
-
-
-
-
- No
-
-
- Nama Laundry
-
-
- Alamat
-
-
- Harga
-
-
- Jarak
-
-
- Jenis Layanan
-
-
- Estimasi
-
-
- Aksi
-
-
-
-
- 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'): ?>
-
-
-
-
-
-
- firstItem() + $index); ?>
-
-
-
-
-
-
- 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'): ?>
-
-
-
-
-
-
-
-
-
-
-
- popLoop(); $loop = $__env->getLastLoop(); ?>
-
-
-
-
-
-
-
- hasPages()): ?>
-
-
-
-
-
-
-
-
-
-
-
-
Konfirmasi Hapus Data
-
Tindakan ini tidak dapat dibatalkan!
-
-
Apakah Anda yakin ingin menghapus laundry:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Batal
-
-
- Ya, Hapus
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Konfirmasi Hapus Data Terpilih
-
Tindakan ini tidak dapat dibatalkan!
-
-
Anda akan menghapus:
-
-
- 0
- Laundry
-
-
-
-
-
- Perhatian: Semua data laundry yang dipilih beserta layanannya akan dihapus permanen!
-
-
-
-
- Batal
-
-
- Ya, Hapus Semua
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-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'); ?>
-
-
-
-
-
-
- Dashboard
- Metode SAW
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Kelebihan SAW
-
-
- Mudah dipahami
- Perhitungan sederhana
- Hasil objektif
- Jarak dihitung otomatis dari GPS
-
-
-
-
-
-
-
-
- Cara Kerja
-
-
- Kontrakan: Jarak dari Kampus Polije
- Laundry: Jarak dari Kampus atau Lokasi Anda
- Normalisasi nilai kriteria
- Kalikan dengan bobot
- Urutkan berdasarkan nilai tertinggi
-
-
-
-
-
-
-
-
-
-
-
-
-
-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()): ?>
-
-
- onFirstPage()): ?>
-
-
-
-
-
-
-
-
-
-
-
- hasMorePages()): ?>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- firstItem()): ?>
- firstItem()); ?>
-
-
- lastItem()); ?>
-
- count()); ?>
-
-
-
-
- total()); ?>
-
-
-
-
-
-
-
-
- onFirstPage()): ?>
-
-
- Prev
-
-
-
-
- Prev
-
-
-
-
- addLoop($__currentLoopData); foreach($__currentLoopData as $element): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
-
-
-
-
-
-
-
-
-
- addLoop($__currentLoopData); foreach($__currentLoopData as $page => $url): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
- currentPage()): ?>
-
-
-
-
-
-
-
-
-
- popLoop(); $loop = $__env->getLastLoop(); ?>
-
- popLoop(); $loop = $__env->getLastLoop(); ?>
-
-
- hasMorePages()): ?>
-
- Next
-
-
-
-
- Next
-
-
-
-
-
-
-
-
-
\ 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'); ?>
-
-
-
-
-
-
- Dashboard
- Laundry
- Tambah Data
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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);
+ }
+}