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:
micko samawa 2026-02-27 03:56:39 +07:00
parent 786743231c
commit 73e8f197f2
15 changed files with 286 additions and 19 deletions

View File

@ -302,6 +302,9 @@ public function store(Request $request)
'latitude' => 'required|numeric|between:-90,90',
'longitude' => 'required|numeric|between:-180,180',
'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',
// Validasi Layanan
@ -395,6 +398,9 @@ public function store(Request $request)
'longitude' => $validated['longitude'],
'jarak' => round($jarakKm * 1000), // Simpan dalam meter (seperti kontrakan)
'fasilitas' => $request->fasilitas,
'jam_buka' => $validated['jam_buka'],
'jam_tutup' => $validated['jam_tutup'],
'status' => $validated['status'],
'foto' => $filename,
]);
@ -491,6 +497,9 @@ public function update(Request $request, Laundry $laundry)
'latitude' => 'required|numeric|between:-90,90',
'longitude' => 'required|numeric|between:-180,180',
'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',
'hapus_foto' => 'nullable|in:0,1',
@ -604,6 +613,9 @@ public function update(Request $request, Laundry $laundry)
'longitude' => $validated['longitude'],
'jarak' => round($jarakKm * 1000), // Simpan dalam meter (seperti kontrakan)
'fasilitas' => $request->fasilitas,
'jam_buka' => $validated['jam_buka'],
'jam_tutup' => $validated['jam_tutup'],
'status' => $validated['status'],
'foto' => $filename,
]);

View File

@ -19,6 +19,9 @@ class Laundry extends Model
'longitude',
'jarak',
'fasilitas',
'jam_buka',
'jam_tutup',
'status',
'foto',
];

View File

@ -225,6 +225,67 @@ class="form-control border-start-0 @error('fasilitas') is-invalid @enderror"
</div>
<small class="text-muted">Fasilitas yang disediakan laundry ini</small>
</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>

View File

@ -222,6 +222,67 @@ class="form-control border-start-0 @error('fasilitas') is-invalid @enderror"
@enderror
</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>

View File

@ -756,7 +756,7 @@
@foreach($recentLaundry as $l)
<div class="list-group-item px-0 py-2">
<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>
<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) }}

View File

@ -7,8 +7,8 @@ class AppConfig {
// 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://10.215.176.99:8000/api';
static const String storageUrl = 'http://10.215.176.99:8000/storage';
static const String baseUrl = 'http://192.168.18.16:8000/api';
static const String storageUrl = 'http://192.168.18.16:8000/storage';
// Timeouts
static const Duration connectionTimeout = Duration(seconds: 10);

View File

@ -9,6 +9,7 @@ class Kontrakan {
final String? deskripsi;
final String status;
final String? fotoUtama;
final String? foto; // Field foto dari API
final String? noWhatsapp;
final List<Galeri> galeri;
final double? avgRating;
@ -27,6 +28,7 @@ class Kontrakan {
this.deskripsi,
required this.status,
this.fotoUtama,
this.foto,
this.noWhatsapp,
this.galeri = const [],
this.avgRating,
@ -45,6 +47,28 @@ class Kontrakan {
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(
id: json['id'] ?? 0,
nama: json['nama'] ?? '',
@ -56,10 +80,9 @@ class Kontrakan {
deskripsi: json['deskripsi'],
status: json['status'] ?? 'tersedia',
fotoUtama: json['foto_utama'],
foto: json['foto'], // Store raw foto field
noWhatsapp: json['no_whatsapp'],
galeri: json['galeri'] != null
? (json['galeri'] as List).map((g) => Galeri.fromJson(g)).toList()
: [],
galeri: galleryList,
avgRating: json['avg_rating'] != null
? double.tryParse(json['avg_rating'].toString())
: null,
@ -77,15 +100,32 @@ class Kontrakan {
bool get isAvailable => status == 'available' || status == 'tersedia';
// 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 {
// 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) {
final primary = galeri.firstWhere(
(g) => g.isPrimary,
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
@ -120,4 +160,15 @@ class Galeri {
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';
}
}

View File

@ -12,6 +12,7 @@ class Laundry {
final int waktuProses; // dalam jam
final String? deskripsi;
final String? fotoUtama;
final String? foto; // Field foto dari API
final List<GaleriLaundry> galeri;
final double? avgRating;
final int? totalReviews;
@ -31,6 +32,7 @@ class Laundry {
required this.waktuProses,
this.deskripsi,
this.fotoUtama,
this.foto,
this.galeri = const [],
this.avgRating,
this.totalReviews,
@ -58,6 +60,28 @@ class Laundry {
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(
id: json['id'] ?? 0,
nama: json['nama'] ?? '',
@ -70,17 +94,14 @@ class Laundry {
? double.tryParse(json['longitude'].toString())
: null,
jarakKampus: jarak,
jamBuka: json['jam_buka'] ?? '08:00',
jamTutup: json['jam_tutup'] ?? '17:00',
jamBuka: _parseTime(json['jam_buka'], '08:00'),
jamTutup: _parseTime(json['jam_tutup'], '20:00'),
hargaPerKg: harga,
waktuProses: json['waktu_proses'] ?? 24,
deskripsi: json['deskripsi'],
fotoUtama: json['foto_utama'],
galeri: json['galeri'] != null
? (json['galeri'] as List)
.map((g) => GaleriLaundry.fromJson(g))
.toList()
: [],
foto: json['foto'], // Store raw foto field
galeri: galleryList,
avgRating: json['avg_rating'] != null
? double.tryParse(json['avg_rating'].toString())
: 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
// Prioritize 'foto' field (Admin-set primary photo) over galeri entries,
// because galeri items may reference files that have been deleted/replaced.
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) {
final primary = galeri.firstWhere(
(g) => g.isPrimary,
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
@ -155,4 +201,15 @@ class GaleriLaundry {
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';
}
}

View File

@ -1,5 +1,6 @@
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/favorite_service.dart';
@ -37,6 +38,10 @@ class _FavoritesScreenState extends State<FavoritesScreen>
Future<void> _loadFavorites() async {
setState(() => _isLoading = true);
// Clear image cache agar foto terbaru selalu dimuat
await DefaultCacheManager().emptyCache();
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
try {
final result = await _favoriteService.getFavoritesWithModels();
if (mounted) {

View File

@ -14,6 +14,7 @@ import 'kontrakan_detail_screen.dart';
import 'laundry_detail_screen.dart';
import '../login.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
class ImprovedHomeScreen extends StatefulWidget {
const ImprovedHomeScreen({super.key});
@ -43,6 +44,10 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
Future<void> _loadData() 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();
await Future.wait([_loadKontrakan(), _loadLaundry(), _loadFavoriteIds()]);
setState(() => _isLoading = false);
}

View File

@ -128,7 +128,7 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
itemBuilder: (context, index) {
final imageUrl = widget.kontrakan.galeri.isEmpty
? widget.kontrakan.primaryPhoto
: widget.kontrakan.galeri[index].foto;
: widget.kontrakan.galeri[index].photoUrl;
return CachedNetworkImage(
imageUrl: imageUrl,

View File

@ -3,6 +3,7 @@ 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';
@ -173,6 +174,11 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
_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';

View File

@ -1,5 +1,6 @@
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';
@ -49,6 +50,10 @@ class _SearchScreenState extends State<SearchScreen> {
Future<void> _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(() {

View File

@ -167,7 +167,7 @@ packages:
source: sdk
version: "0.0.0"
flutter_cache_manager:
dependency: transitive
dependency: "direct main"
description:
name: flutter_cache_manager
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"

View File

@ -46,6 +46,7 @@ dependencies:
# Image picker & display
cached_network_image: ^3.3.1
flutter_cache_manager: ^3.3.1
image_picker: ^1.1.2
# State management