Add laundry open/close times and status fields
Features: - Laundry: Add jam_buka, jam_tutup, status (buka/tutup) fields to database - Web forms: Add opening/closing times and status dropdown to create/edit views - Controller: Validate and save new fields in store() and update() - Mobile: Parse time strings to remove seconds, display status and hours - Mobile: Add cache clearing to auto-refresh image data from server - Mobile: Fix photo priority (prefer 'foto' field over galeri entries) Changes: - spk_kontrakan/app/Models/Laundry.php: Update fillable - spk_kontrakan/app/Http/Controllers/LaundryController.php: Add validation and storage - spk_kontrakan/resources/views/Laundry/create.blade.php: Add form fields - spk_kontrakan/resources/views/Laundry/edit.blade.php: Add form fields - spk_mobile/lib/models/laundry.dart: Time parsing, photo priority fix - spk_mobile/lib/models/kontrakan.dart: Photo priority fix - spk_mobile/pubspec.yaml: Add flutter_cache_manager - Mobile screens: Add cache clearing on data refresh
This commit is contained in:
parent
786743231c
commit
73e8f197f2
|
|
@ -302,6 +302,9 @@ public function store(Request $request)
|
||||||
'latitude' => 'required|numeric|between:-90,90',
|
'latitude' => 'required|numeric|between:-90,90',
|
||||||
'longitude' => 'required|numeric|between:-180,180',
|
'longitude' => 'required|numeric|between:-180,180',
|
||||||
'fasilitas' => 'nullable|string|max:1000',
|
'fasilitas' => 'nullable|string|max:1000',
|
||||||
|
'jam_buka' => 'required|date_format:H:i',
|
||||||
|
'jam_tutup' => 'required|date_format:H:i',
|
||||||
|
'status' => 'required|in:buka,tutup',
|
||||||
'foto' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:2048',
|
'foto' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:2048',
|
||||||
|
|
||||||
// Validasi Layanan
|
// Validasi Layanan
|
||||||
|
|
@ -395,6 +398,9 @@ public function store(Request $request)
|
||||||
'longitude' => $validated['longitude'],
|
'longitude' => $validated['longitude'],
|
||||||
'jarak' => round($jarakKm * 1000), // Simpan dalam meter (seperti kontrakan)
|
'jarak' => round($jarakKm * 1000), // Simpan dalam meter (seperti kontrakan)
|
||||||
'fasilitas' => $request->fasilitas,
|
'fasilitas' => $request->fasilitas,
|
||||||
|
'jam_buka' => $validated['jam_buka'],
|
||||||
|
'jam_tutup' => $validated['jam_tutup'],
|
||||||
|
'status' => $validated['status'],
|
||||||
'foto' => $filename,
|
'foto' => $filename,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -491,6 +497,9 @@ public function update(Request $request, Laundry $laundry)
|
||||||
'latitude' => 'required|numeric|between:-90,90',
|
'latitude' => 'required|numeric|between:-90,90',
|
||||||
'longitude' => 'required|numeric|between:-180,180',
|
'longitude' => 'required|numeric|between:-180,180',
|
||||||
'fasilitas' => 'nullable|string|max:1000',
|
'fasilitas' => 'nullable|string|max:1000',
|
||||||
|
'jam_buka' => 'required|date_format:H:i',
|
||||||
|
'jam_tutup' => 'required|date_format:H:i',
|
||||||
|
'status' => 'required|in:buka,tutup',
|
||||||
'foto' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:2048',
|
'foto' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:2048',
|
||||||
'hapus_foto' => 'nullable|in:0,1',
|
'hapus_foto' => 'nullable|in:0,1',
|
||||||
|
|
||||||
|
|
@ -604,6 +613,9 @@ public function update(Request $request, Laundry $laundry)
|
||||||
'longitude' => $validated['longitude'],
|
'longitude' => $validated['longitude'],
|
||||||
'jarak' => round($jarakKm * 1000), // Simpan dalam meter (seperti kontrakan)
|
'jarak' => round($jarakKm * 1000), // Simpan dalam meter (seperti kontrakan)
|
||||||
'fasilitas' => $request->fasilitas,
|
'fasilitas' => $request->fasilitas,
|
||||||
|
'jam_buka' => $validated['jam_buka'],
|
||||||
|
'jam_tutup' => $validated['jam_tutup'],
|
||||||
|
'status' => $validated['status'],
|
||||||
'foto' => $filename,
|
'foto' => $filename,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ class Laundry extends Model
|
||||||
'longitude',
|
'longitude',
|
||||||
'jarak',
|
'jarak',
|
||||||
'fasilitas',
|
'fasilitas',
|
||||||
|
'jam_buka',
|
||||||
|
'jam_tutup',
|
||||||
|
'status',
|
||||||
'foto',
|
'foto',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -225,6 +225,67 @@ class="form-control border-start-0 @error('fasilitas') is-invalid @enderror"
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted">Fasilitas yang disediakan laundry ini</small>
|
<small class="text-muted">Fasilitas yang disediakan laundry ini</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Operasional -->
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="status" class="form-label fw-semibold">
|
||||||
|
Status Operasional <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<select name="status" id="status" class="form-select @error('status') is-invalid @enderror" required>
|
||||||
|
<option value="buka" {{ old('status', 'buka') == 'buka' ? 'selected' : '' }}>🟢 Buka</option>
|
||||||
|
<option value="tutup" {{ old('status') == 'tutup' ? 'selected' : '' }}>🔴 Tutup</option>
|
||||||
|
</select>
|
||||||
|
@error('status')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
<small class="text-muted">Status laundry saat ini</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Jam Buka -->
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="jam_buka" class="form-label fw-semibold">
|
||||||
|
Jam Buka <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text bg-light border-end-0">
|
||||||
|
<i class="bi bi-clock-history text-success"></i>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
name="jam_buka"
|
||||||
|
id="jam_buka"
|
||||||
|
class="form-control border-start-0 @error('jam_buka') is-invalid @enderror"
|
||||||
|
value="{{ old('jam_buka', '08:00') }}"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
@error('jam_buka')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Jam Tutup -->
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="jam_tutup" class="form-label fw-semibold">
|
||||||
|
Jam Tutup <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text bg-light border-end-0">
|
||||||
|
<i class="bi bi-clock text-danger"></i>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
name="jam_tutup"
|
||||||
|
id="jam_tutup"
|
||||||
|
class="form-control border-start-0 @error('jam_tutup') is-invalid @enderror"
|
||||||
|
value="{{ old('jam_tutup', '20:00') }}"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
@error('jam_tutup')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,67 @@ class="form-control border-start-0 @error('fasilitas') is-invalid @enderror"
|
||||||
@enderror
|
@enderror
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Operasional -->
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="status" class="form-label fw-semibold">
|
||||||
|
Status Operasional <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<select name="status" id="status" class="form-select @error('status') is-invalid @enderror" required>
|
||||||
|
<option value="buka" {{ old('status', $laundry->status) == 'buka' ? 'selected' : '' }}>🟢 Buka</option>
|
||||||
|
<option value="tutup" {{ old('status', $laundry->status) == 'tutup' ? 'selected' : '' }}>🔴 Tutup</option>
|
||||||
|
</select>
|
||||||
|
@error('status')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
<small class="text-muted">Status laundry saat ini</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Jam Buka -->
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="jam_buka" class="form-label fw-semibold">
|
||||||
|
Jam Buka <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text bg-light border-end-0">
|
||||||
|
<i class="bi bi-clock-history text-success"></i>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
name="jam_buka"
|
||||||
|
id="jam_buka"
|
||||||
|
class="form-control border-start-0 @error('jam_buka') is-invalid @enderror"
|
||||||
|
value="{{ old('jam_buka', \Carbon\Carbon::parse($laundry->jam_buka)->format('H:i')) }}"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
@error('jam_buka')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Jam Tutup -->
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="jam_tutup" class="form-label fw-semibold">
|
||||||
|
Jam Tutup <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text bg-light border-end-0">
|
||||||
|
<i class="bi bi-clock text-danger"></i>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
name="jam_tutup"
|
||||||
|
id="jam_tutup"
|
||||||
|
class="form-control border-start-0 @error('jam_tutup') is-invalid @enderror"
|
||||||
|
value="{{ old('jam_tutup', \Carbon\Carbon::parse($laundry->jam_tutup)->format('H:i')) }}"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
@error('jam_tutup')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -756,7 +756,7 @@
|
||||||
@foreach($recentLaundry as $l)
|
@foreach($recentLaundry as $l)
|
||||||
<div class="list-group-item px-0 py-2">
|
<div class="list-group-item px-0 py-2">
|
||||||
<div class="d-flex w-100 justify-content-between align-items-start">
|
<div class="d-flex w-100 justify-content-between align-items-start">
|
||||||
<div class="flex-grow-1 me-2">
|
<div class="flex-grow-1 me-2">
|
||||||
<h6 class="mb-1 fw-semibold" style="font-size: 0.875rem;">{{ $l->nama }}</h6>
|
<h6 class="mb-1 fw-semibold" style="font-size: 0.875rem;">{{ $l->nama }}</h6>
|
||||||
<p class="mb-0 text-muted" style="font-size: 0.75rem;">
|
<p class="mb-0 text-muted" style="font-size: 0.75rem;">
|
||||||
<i class="bi bi-geo-alt me-1"></i>{{ Str::limit($l->alamat ?? '-', 25) }}
|
<i class="bi bi-geo-alt me-1"></i>{{ Str::limit($l->alamat ?? '-', 25) }}
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ class AppConfig {
|
||||||
|
|
||||||
// CATATAN: Untuk real device, ganti IP sesuai dengan IP komputer Anda
|
// CATATAN: Untuk real device, ganti IP sesuai dengan IP komputer Anda
|
||||||
// Cek IP dengan: ipconfig (di Windows) atau ifconfig (di Linux/Mac)
|
// Cek IP dengan: ipconfig (di Windows) atau ifconfig (di Linux/Mac)
|
||||||
static const String baseUrl = 'http://10.215.176.99:8000/api';
|
static const String baseUrl = 'http://192.168.18.16:8000/api';
|
||||||
static const String storageUrl = 'http://10.215.176.99:8000/storage';
|
static const String storageUrl = 'http://192.168.18.16:8000/storage';
|
||||||
|
|
||||||
// Timeouts
|
// Timeouts
|
||||||
static const Duration connectionTimeout = Duration(seconds: 10);
|
static const Duration connectionTimeout = Duration(seconds: 10);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ class Kontrakan {
|
||||||
final String? deskripsi;
|
final String? deskripsi;
|
||||||
final String status;
|
final String status;
|
||||||
final String? fotoUtama;
|
final String? fotoUtama;
|
||||||
|
final String? foto; // Field foto dari API
|
||||||
final String? noWhatsapp;
|
final String? noWhatsapp;
|
||||||
final List<Galeri> galeri;
|
final List<Galeri> galeri;
|
||||||
final double? avgRating;
|
final double? avgRating;
|
||||||
|
|
@ -27,6 +28,7 @@ class Kontrakan {
|
||||||
this.deskripsi,
|
this.deskripsi,
|
||||||
required this.status,
|
required this.status,
|
||||||
this.fotoUtama,
|
this.fotoUtama,
|
||||||
|
this.foto,
|
||||||
this.noWhatsapp,
|
this.noWhatsapp,
|
||||||
this.galeri = const [],
|
this.galeri = const [],
|
||||||
this.avgRating,
|
this.avgRating,
|
||||||
|
|
@ -45,6 +47,28 @@ class Kontrakan {
|
||||||
jarak = (double.tryParse(json['jarak'].toString()) ?? 0) / 1000;
|
jarak = (double.tryParse(json['jarak'].toString()) ?? 0) / 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get foto from API and build gallery list
|
||||||
|
final List<Galeri> galleryList = [];
|
||||||
|
|
||||||
|
// Parse galeri array if exists
|
||||||
|
if (json['galeri'] != null && (json['galeri'] as List).isNotEmpty) {
|
||||||
|
galleryList.addAll(
|
||||||
|
(json['galeri'] as List).map((g) => Galeri.fromJson(g)).toList()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If gallery is empty but foto field exists, create a galeri item from it
|
||||||
|
if (galleryList.isEmpty && json['foto'] != null && json['foto'].toString().isNotEmpty) {
|
||||||
|
galleryList.add(
|
||||||
|
Galeri(
|
||||||
|
id: json['id'] ?? 0,
|
||||||
|
foto: json['foto'].toString(),
|
||||||
|
isPrimary: true,
|
||||||
|
urutan: 0,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Kontrakan(
|
return Kontrakan(
|
||||||
id: json['id'] ?? 0,
|
id: json['id'] ?? 0,
|
||||||
nama: json['nama'] ?? '',
|
nama: json['nama'] ?? '',
|
||||||
|
|
@ -56,10 +80,9 @@ class Kontrakan {
|
||||||
deskripsi: json['deskripsi'],
|
deskripsi: json['deskripsi'],
|
||||||
status: json['status'] ?? 'tersedia',
|
status: json['status'] ?? 'tersedia',
|
||||||
fotoUtama: json['foto_utama'],
|
fotoUtama: json['foto_utama'],
|
||||||
|
foto: json['foto'], // Store raw foto field
|
||||||
noWhatsapp: json['no_whatsapp'],
|
noWhatsapp: json['no_whatsapp'],
|
||||||
galeri: json['galeri'] != null
|
galeri: galleryList,
|
||||||
? (json['galeri'] as List).map((g) => Galeri.fromJson(g)).toList()
|
|
||||||
: [],
|
|
||||||
avgRating: json['avg_rating'] != null
|
avgRating: json['avg_rating'] != null
|
||||||
? double.tryParse(json['avg_rating'].toString())
|
? double.tryParse(json['avg_rating'].toString())
|
||||||
: null,
|
: null,
|
||||||
|
|
@ -77,15 +100,32 @@ class Kontrakan {
|
||||||
bool get isAvailable => status == 'available' || status == 'tersedia';
|
bool get isAvailable => status == 'available' || status == 'tersedia';
|
||||||
|
|
||||||
// Get primary photo URL
|
// Get primary photo URL
|
||||||
|
// Prioritize 'foto' field (Admin-set primary photo) over galeri entries,
|
||||||
|
// because galeri items may reference files that have been deleted/replaced.
|
||||||
String get primaryPhoto {
|
String get primaryPhoto {
|
||||||
|
// 1. Prefer the explicit 'foto' field (most up-to-date primary photo)
|
||||||
|
if (foto != null && foto!.isNotEmpty) {
|
||||||
|
if (foto!.startsWith('http')) {
|
||||||
|
return foto!;
|
||||||
|
}
|
||||||
|
const String baseUrl = 'http://192.168.18.16:8000';
|
||||||
|
return '$baseUrl/uploads/Kontrakan/$foto';
|
||||||
|
}
|
||||||
|
// 2. Fall back to galeri items if foto is not set
|
||||||
if (galeri.isNotEmpty) {
|
if (galeri.isNotEmpty) {
|
||||||
final primary = galeri.firstWhere(
|
final primary = galeri.firstWhere(
|
||||||
(g) => g.isPrimary,
|
(g) => g.isPrimary,
|
||||||
orElse: () => galeri.first,
|
orElse: () => galeri.first,
|
||||||
);
|
);
|
||||||
return primary.foto;
|
if (primary.foto.isNotEmpty && primary.foto.startsWith('http')) {
|
||||||
|
return primary.foto;
|
||||||
|
}
|
||||||
|
if (primary.foto.isNotEmpty) {
|
||||||
|
const String baseUrl = 'http://192.168.18.16:8000';
|
||||||
|
return '$baseUrl/uploads/Kontrakan/${primary.foto}';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return fotoUtama ?? 'https://via.placeholder.com/300';
|
return 'https://via.placeholder.com/300';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format harga dengan Rupiah
|
// Format harga dengan Rupiah
|
||||||
|
|
@ -120,4 +160,15 @@ class Galeri {
|
||||||
urutan: json['urutan'] ?? 0,
|
urutan: json['urutan'] ?? 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get full photo URL
|
||||||
|
String get photoUrl {
|
||||||
|
// Check if it's already a full URL
|
||||||
|
if (foto.startsWith('http')) {
|
||||||
|
return foto;
|
||||||
|
}
|
||||||
|
// Build full URL from uploads
|
||||||
|
const String baseUrl = 'http://192.168.18.16:8000';
|
||||||
|
return '$baseUrl/uploads/Kontrakan/$foto';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ class Laundry {
|
||||||
final int waktuProses; // dalam jam
|
final int waktuProses; // dalam jam
|
||||||
final String? deskripsi;
|
final String? deskripsi;
|
||||||
final String? fotoUtama;
|
final String? fotoUtama;
|
||||||
|
final String? foto; // Field foto dari API
|
||||||
final List<GaleriLaundry> galeri;
|
final List<GaleriLaundry> galeri;
|
||||||
final double? avgRating;
|
final double? avgRating;
|
||||||
final int? totalReviews;
|
final int? totalReviews;
|
||||||
|
|
@ -31,6 +32,7 @@ class Laundry {
|
||||||
required this.waktuProses,
|
required this.waktuProses,
|
||||||
this.deskripsi,
|
this.deskripsi,
|
||||||
this.fotoUtama,
|
this.fotoUtama,
|
||||||
|
this.foto,
|
||||||
this.galeri = const [],
|
this.galeri = const [],
|
||||||
this.avgRating,
|
this.avgRating,
|
||||||
this.totalReviews,
|
this.totalReviews,
|
||||||
|
|
@ -58,6 +60,28 @@ class Laundry {
|
||||||
double.tryParse(layananList.first['harga']?.toString() ?? '0') ?? 0;
|
double.tryParse(layananList.first['harga']?.toString() ?? '0') ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get foto from API and build gallery list
|
||||||
|
final List<GaleriLaundry> galleryList = [];
|
||||||
|
|
||||||
|
// Parse galeri array if exists
|
||||||
|
if (json['galeri'] != null && (json['galeri'] as List).isNotEmpty) {
|
||||||
|
galleryList.addAll(
|
||||||
|
(json['galeri'] as List).map((g) => GaleriLaundry.fromJson(g)).toList()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If gallery is empty but foto field exists, create a galeri item from it
|
||||||
|
if (galleryList.isEmpty && json['foto'] != null && json['foto'].toString().isNotEmpty) {
|
||||||
|
galleryList.add(
|
||||||
|
GaleriLaundry(
|
||||||
|
id: json['id'] ?? 0,
|
||||||
|
foto: json['foto'].toString(),
|
||||||
|
isPrimary: true,
|
||||||
|
urutan: 0,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Laundry(
|
return Laundry(
|
||||||
id: json['id'] ?? 0,
|
id: json['id'] ?? 0,
|
||||||
nama: json['nama'] ?? '',
|
nama: json['nama'] ?? '',
|
||||||
|
|
@ -70,17 +94,14 @@ class Laundry {
|
||||||
? double.tryParse(json['longitude'].toString())
|
? double.tryParse(json['longitude'].toString())
|
||||||
: null,
|
: null,
|
||||||
jarakKampus: jarak,
|
jarakKampus: jarak,
|
||||||
jamBuka: json['jam_buka'] ?? '08:00',
|
jamBuka: _parseTime(json['jam_buka'], '08:00'),
|
||||||
jamTutup: json['jam_tutup'] ?? '17:00',
|
jamTutup: _parseTime(json['jam_tutup'], '20:00'),
|
||||||
hargaPerKg: harga,
|
hargaPerKg: harga,
|
||||||
waktuProses: json['waktu_proses'] ?? 24,
|
waktuProses: json['waktu_proses'] ?? 24,
|
||||||
deskripsi: json['deskripsi'],
|
deskripsi: json['deskripsi'],
|
||||||
fotoUtama: json['foto_utama'],
|
fotoUtama: json['foto_utama'],
|
||||||
galeri: json['galeri'] != null
|
foto: json['foto'], // Store raw foto field
|
||||||
? (json['galeri'] as List)
|
galeri: galleryList,
|
||||||
.map((g) => GaleriLaundry.fromJson(g))
|
|
||||||
.toList()
|
|
||||||
: [],
|
|
||||||
avgRating: json['avg_rating'] != null
|
avgRating: json['avg_rating'] != null
|
||||||
? double.tryParse(json['avg_rating'].toString())
|
? double.tryParse(json['avg_rating'].toString())
|
||||||
: null,
|
: null,
|
||||||
|
|
@ -89,16 +110,41 @@ class Laundry {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse time string, stripping seconds if present (e.g. "08:00:00" -> "08:00")
|
||||||
|
static String _parseTime(dynamic value, String fallback) {
|
||||||
|
if (value == null) return fallback;
|
||||||
|
final str = value.toString();
|
||||||
|
if (str.length >= 5) return str.substring(0, 5);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
// Get primary photo URL
|
// Get primary photo URL
|
||||||
|
// Prioritize 'foto' field (Admin-set primary photo) over galeri entries,
|
||||||
|
// because galeri items may reference files that have been deleted/replaced.
|
||||||
String get primaryPhoto {
|
String get primaryPhoto {
|
||||||
|
// 1. Prefer the explicit 'foto' field (most up-to-date primary photo)
|
||||||
|
if (foto != null && foto!.isNotEmpty) {
|
||||||
|
if (foto!.startsWith('http')) {
|
||||||
|
return foto!;
|
||||||
|
}
|
||||||
|
const String baseUrl = 'http://192.168.18.16:8000';
|
||||||
|
return '$baseUrl/uploads/Laundry/$foto';
|
||||||
|
}
|
||||||
|
// 2. Fall back to galeri items if foto is not set
|
||||||
if (galeri.isNotEmpty) {
|
if (galeri.isNotEmpty) {
|
||||||
final primary = galeri.firstWhere(
|
final primary = galeri.firstWhere(
|
||||||
(g) => g.isPrimary,
|
(g) => g.isPrimary,
|
||||||
orElse: () => galeri.first,
|
orElse: () => galeri.first,
|
||||||
);
|
);
|
||||||
return primary.foto;
|
if (primary.foto.isNotEmpty && primary.foto.startsWith('http')) {
|
||||||
|
return primary.foto;
|
||||||
|
}
|
||||||
|
if (primary.foto.isNotEmpty) {
|
||||||
|
const String baseUrl = 'http://192.168.18.16:8000';
|
||||||
|
return '$baseUrl/uploads/Laundry/${primary.foto}';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return fotoUtama ?? 'https://via.placeholder.com/300';
|
return 'https://via.placeholder.com/300';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format harga dengan Rupiah
|
// Format harga dengan Rupiah
|
||||||
|
|
@ -155,4 +201,15 @@ class GaleriLaundry {
|
||||||
urutan: json['urutan'] ?? 0,
|
urutan: json['urutan'] ?? 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get full photo URL
|
||||||
|
String get photoUrl {
|
||||||
|
// Check if it's already a full URL
|
||||||
|
if (foto.startsWith('http')) {
|
||||||
|
return foto;
|
||||||
|
}
|
||||||
|
// Build full URL from uploads
|
||||||
|
const String baseUrl = 'http://192.168.18.16:8000';
|
||||||
|
return '$baseUrl/uploads/Laundry/$foto';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.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/kontrakan.dart';
|
||||||
import '../models/laundry.dart';
|
import '../models/laundry.dart';
|
||||||
import '../services/favorite_service.dart';
|
import '../services/favorite_service.dart';
|
||||||
|
|
@ -37,6 +38,10 @@ class _FavoritesScreenState extends State<FavoritesScreen>
|
||||||
|
|
||||||
Future<void> _loadFavorites() async {
|
Future<void> _loadFavorites() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
// Clear image cache agar foto terbaru selalu dimuat
|
||||||
|
await DefaultCacheManager().emptyCache();
|
||||||
|
PaintingBinding.instance.imageCache.clear();
|
||||||
|
PaintingBinding.instance.imageCache.clearLiveImages();
|
||||||
try {
|
try {
|
||||||
final result = await _favoriteService.getFavoritesWithModels();
|
final result = await _favoriteService.getFavoritesWithModels();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import 'kontrakan_detail_screen.dart';
|
||||||
import 'laundry_detail_screen.dart';
|
import 'laundry_detail_screen.dart';
|
||||||
import '../login.dart';
|
import '../login.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
|
|
||||||
class ImprovedHomeScreen extends StatefulWidget {
|
class ImprovedHomeScreen extends StatefulWidget {
|
||||||
const ImprovedHomeScreen({super.key});
|
const ImprovedHomeScreen({super.key});
|
||||||
|
|
@ -43,6 +44,10 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
||||||
|
|
||||||
Future<void> _loadData() async {
|
Future<void> _loadData() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
// Clear image cache agar foto terbaru dari server selalu dimuat
|
||||||
|
await DefaultCacheManager().emptyCache();
|
||||||
|
PaintingBinding.instance.imageCache.clear();
|
||||||
|
PaintingBinding.instance.imageCache.clearLiveImages();
|
||||||
await Future.wait([_loadKontrakan(), _loadLaundry(), _loadFavoriteIds()]);
|
await Future.wait([_loadKontrakan(), _loadLaundry(), _loadFavoriteIds()]);
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,7 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final imageUrl = widget.kontrakan.galeri.isEmpty
|
final imageUrl = widget.kontrakan.galeri.isEmpty
|
||||||
? widget.kontrakan.primaryPhoto
|
? widget.kontrakan.primaryPhoto
|
||||||
: widget.kontrakan.galeri[index].foto;
|
: widget.kontrakan.galeri[index].photoUrl;
|
||||||
|
|
||||||
return CachedNetworkImage(
|
return CachedNetworkImage(
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:http/http.dart' as http;
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
import '../config/app_config.dart';
|
import '../config/app_config.dart';
|
||||||
import '../models/kontrakan.dart';
|
import '../models/kontrakan.dart';
|
||||||
import '../models/laundry.dart';
|
import '../models/laundry.dart';
|
||||||
|
|
@ -173,6 +174,11 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
_noData = false;
|
_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'
|
final endpoint = widget.category == 'kontrakan'
|
||||||
? '/saw/calculate/kontrakan'
|
? '/saw/calculate/kontrakan'
|
||||||
: '/saw/calculate/laundry';
|
: '/saw/calculate/laundry';
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.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/kontrakan.dart';
|
||||||
import '../models/laundry.dart';
|
import '../models/laundry.dart';
|
||||||
import '../services/kontrakan_service.dart';
|
import '../services/kontrakan_service.dart';
|
||||||
|
|
@ -49,6 +50,10 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||||
|
|
||||||
Future<void> _loadData() async {
|
Future<void> _loadData() async {
|
||||||
setState(() => _isLoading = true);
|
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 kontrakan = await _kontrakanService.getKontrakan();
|
||||||
final laundry = await _laundryService.getLaundry();
|
final laundry = await _laundryService.getLaundry();
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,7 @@ packages:
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
flutter_cache_manager:
|
flutter_cache_manager:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_cache_manager
|
name: flutter_cache_manager
|
||||||
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
|
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ dependencies:
|
||||||
|
|
||||||
# Image picker & display
|
# Image picker & display
|
||||||
cached_network_image: ^3.3.1
|
cached_network_image: ^3.3.1
|
||||||
|
flutter_cache_manager: ^3.3.1
|
||||||
image_picker: ^1.1.2
|
image_picker: ^1.1.2
|
||||||
|
|
||||||
# State management
|
# State management
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue