Fix laundry distance display with user location and cleanup mobile laundry model
This commit is contained in:
parent
b192557bb8
commit
a8e72f1203
|
|
@ -95,8 +95,8 @@ public function store(Request $request)
|
|||
$startDate = Carbon::parse($request->tanggal_mulai);
|
||||
$endDate = $startDate->copy()->addMonths((int)$request->durasi_bulan);
|
||||
|
||||
// Calculate total biaya
|
||||
$amount = $kontrakan->harga * (int)$request->durasi_bulan;
|
||||
// Calculate total biaya from annual price pro-rated to monthly duration
|
||||
$amount = $kontrakan->harga * ((int)$request->durasi_bulan / 12);
|
||||
|
||||
$bookingData = [
|
||||
'user_id' => $request->user()->id,
|
||||
|
|
@ -211,7 +211,7 @@ public function extend(Request $request, $id)
|
|||
// Create new booking untuk perpanjangan
|
||||
$startDate = Carbon::parse($booking->end_date);
|
||||
$endDate = $startDate->copy()->addMonths($request->durasi_bulan);
|
||||
$amount = $booking->kontrakan->harga * $request->durasi_bulan;
|
||||
$amount = $booking->kontrakan->harga * ($request->durasi_bulan / 12);
|
||||
|
||||
$newBooking = Booking::create([
|
||||
'user_id' => $request->user()->id,
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ public function calculateKontrakan(Request $request)
|
|||
public function calculateLaundry(Request $request)
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'jenis_layanan' => 'nullable|string|in:reguler,express,kilat',
|
||||
'jenis_layanan' => 'nullable|string|in:reguler,express',
|
||||
'harga_min' => 'nullable|numeric',
|
||||
'harga_max' => 'nullable|numeric',
|
||||
'jarak_max' => 'nullable|numeric',
|
||||
|
|
@ -220,7 +220,8 @@ public function calculateLaundry(Request $request)
|
|||
// Filter by jenis_layanan if provided
|
||||
if ($jenisLayanan) {
|
||||
$query->whereHas('layanan', function($q) use ($jenisLayanan) {
|
||||
$q->where('jenis_layanan', $jenisLayanan);
|
||||
$values = $this->mapJenisLayananQueryValues($jenisLayanan);
|
||||
$q->whereIn('jenis_layanan', $values);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -464,12 +465,25 @@ private function prosesMetodeSAWLaundry($items, $kriteria, $customBobot = null,
|
|||
/**
|
||||
* Helper: Get nilai laundry berdasarkan nama kriteria
|
||||
*/
|
||||
private function mapJenisLayananQueryValues($jenisLayanan)
|
||||
{
|
||||
$map = [
|
||||
'reguler' => ['reguler', 'kiloan'],
|
||||
'express' => ['express', 'satuan'],
|
||||
];
|
||||
|
||||
return $map[$jenisLayanan] ?? [$jenisLayanan];
|
||||
}
|
||||
|
||||
private function getNilaiLaundry($item, $field, $jenisLayanan = null)
|
||||
{
|
||||
// Filter layanan by jenis if specified
|
||||
$layananCollection = $jenisLayanan
|
||||
? $item->layanan->where('jenis_layanan', $jenisLayanan)
|
||||
: $item->layanan;
|
||||
if ($jenisLayanan) {
|
||||
$filterValues = $this->mapJenisLayananQueryValues($jenisLayanan);
|
||||
$layananCollection = $item->layanan->whereIn('jenis_layanan', $filterValues);
|
||||
} else {
|
||||
$layananCollection = $item->layanan;
|
||||
}
|
||||
|
||||
switch ($field) {
|
||||
case 'harga':
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ public function map(Request $request)
|
|||
'harga_min' => 'nullable|numeric|min:0',
|
||||
'harga_max' => 'nullable|numeric|min:0|gte:harga_min',
|
||||
'jarak' => 'nullable|in:dekat,sedang,jauh',
|
||||
'jenis_layanan' => 'nullable|in:express,reguler,kilat',
|
||||
'jenis_layanan' => 'nullable|in:express,reguler',
|
||||
]);
|
||||
|
||||
$query = Laundry::with('layanan');
|
||||
|
|
@ -308,7 +308,7 @@ public function store(Request $request)
|
|||
|
||||
// Validasi Layanan
|
||||
'layanan' => 'required|array|min:1',
|
||||
'layanan.*.jenis_layanan' => 'required|in:express,reguler,kilat',
|
||||
'layanan.*.jenis_layanan' => 'required|in:express,reguler',
|
||||
'layanan.*.nama_paket' => 'required|string|max:255',
|
||||
'layanan.*.harga' => 'required|numeric|min:0',
|
||||
'layanan.*.estimasi_selesai' => 'required|numeric|min:1',
|
||||
|
|
@ -327,7 +327,7 @@ public function store(Request $request)
|
|||
'layanan.required' => 'Minimal harus ada 1 jenis layanan',
|
||||
'layanan.min' => 'Minimal harus ada 1 jenis layanan',
|
||||
'layanan.*.jenis_layanan.required' => 'Jenis layanan harus dipilih',
|
||||
'layanan.*.jenis_layanan.in' => 'Jenis layanan harus express, reguler, atau kilat',
|
||||
'layanan.*.jenis_layanan.in' => 'Jenis layanan harus express atau reguler',
|
||||
'layanan.*.nama_paket.required' => 'Nama paket harus diisi',
|
||||
'layanan.*.harga.required' => 'Harga layanan harus diisi',
|
||||
'layanan.*.harga.min' => 'Harga tidak boleh negatif',
|
||||
|
|
@ -504,7 +504,7 @@ public function update(Request $request, Laundry $laundry)
|
|||
|
||||
// Validasi Layanan
|
||||
'layanan' => 'required|array|min:1',
|
||||
'layanan.*.jenis_layanan' => 'required|in:express,reguler,kilat',
|
||||
'layanan.*.jenis_layanan' => 'required|in:express,reguler',
|
||||
'layanan.*.nama_paket' => 'required|string|max:255',
|
||||
'layanan.*.harga' => 'required|numeric|min:0',
|
||||
'layanan.*.estimasi_selesai' => 'required|numeric|min:1',
|
||||
|
|
@ -519,7 +519,7 @@ public function update(Request $request, Laundry $laundry)
|
|||
'layanan.required' => 'Minimal harus ada 1 jenis layanan',
|
||||
'layanan.min' => 'Minimal harus ada 1 jenis layanan',
|
||||
'layanan.*.jenis_layanan.required' => 'Jenis layanan harus dipilih',
|
||||
'layanan.*.jenis_layanan.in' => 'Jenis layanan harus express, reguler, atau kilat',
|
||||
'layanan.*.jenis_layanan.in' => 'Jenis layanan harus express atau reguler',
|
||||
'layanan.*.nama_paket.required' => 'Nama paket harus diisi',
|
||||
'layanan.*.harga.required' => 'Harga layanan harus diisi',
|
||||
'layanan.*.harga.min' => 'Harga tidak boleh negatif',
|
||||
|
|
|
|||
|
|
@ -398,7 +398,6 @@ class="form-control @error('longitude') is-invalid @enderror"
|
|||
<option value="">-- Pilih Jenis --</option>
|
||||
<option value="reguler">🕐 Reguler (Normal)</option>
|
||||
<option value="express">⚡ Express (Cepat)</option>
|
||||
<option value="kilat">🚀 Kilat (Super Cepat)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
|
@ -546,7 +545,7 @@ class="form-control @error('foto') is-invalid @enderror"
|
|||
<li>Koordinat akan terisi otomatis saat klik peta</li>
|
||||
<li>Reguler: Layanan normal dengan harga standar</li>
|
||||
<li>Express: Layanan cepat dengan harga lebih tinggi</li>
|
||||
<li>Kilat: Layanan super cepat dengan harga premium</li>
|
||||
<li>Express: Layanan cepat dengan waktu penyelesaian lebih singkat.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -722,7 +721,6 @@ function(error) {
|
|||
<option value="">-- Pilih Jenis --</option>
|
||||
<option value="reguler">🕐 Reguler (Normal)</option>
|
||||
<option value="express">⚡ Express (Cepat)</option>
|
||||
<option value="kilat">🚀 Kilat (Super Cepat)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -393,7 +393,6 @@ class="form-control"
|
|||
<option value="">-- Pilih Jenis --</option>
|
||||
<option value="reguler" {{ $layanan->jenis_layanan == 'reguler' ? 'selected' : '' }}>🕐 Reguler (Normal)</option>
|
||||
<option value="express" {{ $layanan->jenis_layanan == 'express' ? 'selected' : '' }}>⚡ Express (Cepat)</option>
|
||||
<option value="kilat" {{ $layanan->jenis_layanan == 'kilat' ? 'selected' : '' }}>🚀 Kilat (Super Cepat)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
|
@ -488,7 +487,6 @@ class="form-control"
|
|||
<option value="">-- Pilih Jenis --</option>
|
||||
<option value="reguler">🕐 Reguler (Normal)</option>
|
||||
<option value="express">⚡ Express (Cepat)</option>
|
||||
<option value="kilat">🚀 Kilat (Super Cepat)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
|
@ -755,7 +753,6 @@ function updateJarakKampus(lat, lng) {
|
|||
<option value="">-- Pilih Jenis --</option>
|
||||
<option value="reguler">🕐 Reguler (Normal)</option>
|
||||
<option value="express">⚡ Express (Cepat)</option>
|
||||
<option value="kilat">🚀 Kilat (Super Cepat)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
|
||||
<application
|
||||
android:label="spk_mobile"
|
||||
android:label="Kontrak Kampus"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Spk Mobile</string>
|
||||
<string>Kontrak Kampus</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>spk_mobile</string>
|
||||
<string>KontrakKampus</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ class AppConfig {
|
|||
// ============================================================================
|
||||
|
||||
// Default fallback (dipakai jika auto-detect gagal)
|
||||
static const String _defaultServer = 'http://192.168.1.154:8000';
|
||||
static const String _defaultServer = 'http://192.168.18.16:8000';
|
||||
|
||||
// Runtime values — diupdate otomatis oleh ServerDiscoveryService
|
||||
static String _serverUrl = _defaultServer;
|
||||
|
|
|
|||
|
|
@ -8,12 +8,12 @@ class Environment {
|
|||
// 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
|
||||
defaultValue: 'http://192.168.18.16:8000', // Default IP lokal
|
||||
);
|
||||
|
||||
static const String storageBaseUrl = String.fromEnvironment(
|
||||
'STORAGE_BASE_URL',
|
||||
defaultValue: 'http://192.168.1.154:8000/storage',
|
||||
defaultValue: 'http://192.168.18.16:8000/storage',
|
||||
);
|
||||
|
||||
// Mode debugging
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ class MyApp extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'SPK Rekomendasi Kontrakan & Laundry',
|
||||
title: 'Kontrak Kampus',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: _primary,
|
||||
|
|
@ -71,7 +71,10 @@ class MyApp extends StatelessWidget {
|
|||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFF7F8FC),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: Color(0xFFE0E0E0)),
|
||||
|
|
@ -120,9 +123,10 @@ class _SplashScreenState extends State<SplashScreen>
|
|||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
);
|
||||
_fadeAnim = Tween<double>(begin: 0, end: 1).animate(
|
||||
CurvedAnimation(parent: _animController, curve: Curves.easeOut),
|
||||
);
|
||||
_fadeAnim = Tween<double>(
|
||||
begin: 0,
|
||||
end: 1,
|
||||
).animate(CurvedAnimation(parent: _animController, curve: Curves.easeOut));
|
||||
_scaleAnim = Tween<double>(begin: 0.7, end: 1).animate(
|
||||
CurvedAnimation(parent: _animController, curve: Curves.elasticOut),
|
||||
);
|
||||
|
|
@ -174,11 +178,7 @@ class _SplashScreenState extends State<SplashScreen>
|
|||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Color(0xFF1565C0),
|
||||
Color(0xFF0D47A1),
|
||||
Color(0xFF1A237E),
|
||||
],
|
||||
colors: [Color(0xFF1565C0), Color(0xFF0D47A1), Color(0xFF1A237E)],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
|
|
@ -210,7 +210,7 @@ class _SplashScreenState extends State<SplashScreen>
|
|||
),
|
||||
const SizedBox(height: 32),
|
||||
const Text(
|
||||
'SPK Rekomendasi',
|
||||
'Kontrak Kampus',
|
||||
style: TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.w800,
|
||||
|
|
@ -220,7 +220,7 @@ class _SplashScreenState extends State<SplashScreen>
|
|||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Kontrakan & Laundry Terbaik untuk Mahasiswa',
|
||||
'Rekomendasi Kontrakan & Laundry untuk Mahasiswa',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ class Laundry {
|
|||
final String jamBuka;
|
||||
final String jamTutup;
|
||||
final double hargaPerKg;
|
||||
final double hargaKiloan;
|
||||
final int waktuProses; // dalam jam
|
||||
final String? deskripsi;
|
||||
final String? fotoUtama;
|
||||
|
|
@ -31,6 +32,7 @@ class Laundry {
|
|||
required this.jamBuka,
|
||||
required this.jamTutup,
|
||||
required this.hargaPerKg,
|
||||
required this.hargaKiloan,
|
||||
required this.waktuProses,
|
||||
this.deskripsi,
|
||||
this.fotoUtama,
|
||||
|
|
@ -53,34 +55,54 @@ class Laundry {
|
|||
|
||||
// Get harga from layanan if harga_per_kg is not set directly
|
||||
double harga = 0;
|
||||
double hargaKiloan = 0;
|
||||
|
||||
if (json['harga_per_kg'] != null) {
|
||||
harga = double.tryParse(json['harga_per_kg'].toString()) ?? 0;
|
||||
} else if (json['layanan'] != null &&
|
||||
}
|
||||
|
||||
if (json['harga_kiloan'] != null) {
|
||||
hargaKiloan = double.tryParse(json['harga_kiloan'].toString()) ?? 0;
|
||||
}
|
||||
|
||||
if ((harga == 0 || hargaKiloan == 0) &&
|
||||
json['layanan'] != null &&
|
||||
(json['layanan'] as List).isNotEmpty) {
|
||||
final layananList = json['layanan'] as List;
|
||||
harga =
|
||||
double.tryParse(layananList.first['harga']?.toString() ?? '0') ?? 0;
|
||||
harga = harga > 0
|
||||
? harga
|
||||
: double.tryParse(layananList.first['harga']?.toString() ?? '0') ?? 0;
|
||||
for (final item in layananList) {
|
||||
final jenis = item['jenis_layanan']?.toString().toLowerCase();
|
||||
final itemHarga =
|
||||
double.tryParse(item['harga']?.toString() ?? '0') ?? 0;
|
||||
if (jenis == 'kiloan') {
|
||||
hargaKiloan = itemHarga;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
(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) {
|
||||
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,
|
||||
)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -99,6 +121,7 @@ class Laundry {
|
|||
jamBuka: _parseTime(json['jam_buka'], '08:00'),
|
||||
jamTutup: _parseTime(json['jam_tutup'], '20:00'),
|
||||
hargaPerKg: harga,
|
||||
hargaKiloan: hargaKiloan,
|
||||
waktuProses: json['waktu_proses'] ?? 24,
|
||||
deskripsi: json['deskripsi'],
|
||||
fotoUtama: json['foto_utama'],
|
||||
|
|
@ -149,13 +172,23 @@ class Laundry {
|
|||
|
||||
// Format harga dengan Rupiah
|
||||
String get formattedHarga {
|
||||
return 'Rp ${hargaPerKg.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}';
|
||||
return 'Rp ${_formatRupiah(hargaPerKg)}';
|
||||
}
|
||||
|
||||
String _formatRupiah(double value) {
|
||||
return value
|
||||
.toStringAsFixed(0)
|
||||
.replaceAllMapped(
|
||||
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
|
||||
(Match m) => '${m[1]}.',
|
||||
);
|
||||
}
|
||||
|
||||
String get formattedHargaKiloan {
|
||||
final value = hargaKiloan > 0 ? hargaKiloan : hargaPerKg;
|
||||
return 'Rp ${_formatRupiah(value)}';
|
||||
}
|
||||
|
||||
// Legacy getters for backward compatibility
|
||||
String get formattedHargaKiloan => formattedHarga;
|
||||
String get formattedHargaSatuan => formattedHarga;
|
||||
double get hargaKiloan => hargaPerKg;
|
||||
double get jarak => jarakKampus; // alias for jarak field
|
||||
double get rating => avgRating ?? 0.0;
|
||||
String get estimasiSelesai => '$waktuProses jam';
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ class User {
|
|||
if (roleLabel != null && roleLabel!.isNotEmpty) {
|
||||
return roleLabel!;
|
||||
}
|
||||
|
||||
|
||||
switch (role) {
|
||||
case 'super_admin':
|
||||
return 'Super Admin';
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import 'package:image_picker/image_picker.dart';
|
|||
import '../models/kontrakan.dart';
|
||||
import '../services/booking_service.dart';
|
||||
import '../services/auth_service.dart';
|
||||
import 'improved_home_screen.dart';
|
||||
|
||||
class BookingFormScreen extends StatefulWidget {
|
||||
final Kontrakan kontrakan;
|
||||
|
|
@ -23,7 +24,7 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
|
|||
final _imagePicker = ImagePicker();
|
||||
|
||||
DateTime? _tanggalMulai;
|
||||
int _durasiBulan = 1;
|
||||
int _durasiBulan = 6;
|
||||
bool _isSubmitting = false;
|
||||
File? _paymentProofImage;
|
||||
|
||||
|
|
@ -33,7 +34,11 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
|
|||
decimalDigits: 0,
|
||||
);
|
||||
|
||||
double get _totalBiaya => widget.kontrakan.harga * _durasiBulan;
|
||||
// Harga kontrakan disimpan sebagai harga tahunan, jadi total disesuaikan
|
||||
// proporsional terhadap durasi sewa (6 bulan atau 12 bulan).
|
||||
double get _totalBiaya => widget.kontrakan.harga * (_durasiBulan / 12);
|
||||
|
||||
String get _durasiLabel => _durasiBulan == 12 ? '1 tahun' : '6 bulan';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -194,7 +199,7 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
|
|||
intl.DateFormat('dd MMMM yyyy', 'id_ID').format(_tanggalMulai!),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildConfirmRow('Durasi', '$_durasiBulan bulan'),
|
||||
_buildConfirmRow('Durasi', _durasiLabel),
|
||||
const SizedBox(height: 8),
|
||||
_buildConfirmRow(
|
||||
'Total Biaya',
|
||||
|
|
@ -290,7 +295,13 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
|
|||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx); // Close dialog
|
||||
Navigator.pop(context, true); // Go back to detail with result
|
||||
Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const ImprovedHomeScreen(),
|
||||
),
|
||||
(route) => false,
|
||||
);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF667eea),
|
||||
|
|
@ -413,7 +424,7 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
|
|||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'${widget.kontrakan.formattedHarga}/bulan',
|
||||
'${widget.kontrakan.formattedHarga}/tahun',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
|
|
@ -490,7 +501,7 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
|
|||
_buildFormCard(
|
||||
icon: Icons.access_time,
|
||||
title: 'Durasi Sewa',
|
||||
subtitle: 'Tentukan berapa bulan Anda ingin menyewa',
|
||||
subtitle: 'Pilih durasi 6 bulan atau 1 tahun',
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -513,12 +524,16 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
|
|||
color: Colors.black87,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
items: List.generate(12, (i) => i + 1).map((bulan) {
|
||||
return DropdownMenuItem<int>(
|
||||
value: bulan,
|
||||
child: Text('$bulan bulan'),
|
||||
);
|
||||
}).toList(),
|
||||
items: const [
|
||||
DropdownMenuItem<int>(
|
||||
value: 6,
|
||||
child: Text('6 bulan'),
|
||||
),
|
||||
DropdownMenuItem<int>(
|
||||
value: 12,
|
||||
child: Text('1 tahun'),
|
||||
),
|
||||
],
|
||||
onChanged: (val) =>
|
||||
setState(() => _durasiBulan = val!),
|
||||
),
|
||||
|
|
@ -669,11 +684,11 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
|
|||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildBiayaRow(
|
||||
'Harga per bulan',
|
||||
'Harga per tahun',
|
||||
widget.kontrakan.formattedHarga,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_buildBiayaRow('Durasi sewa', '$_durasiBulan bulan'),
|
||||
_buildBiayaRow('Durasi sewa', _durasiLabel),
|
||||
const Divider(height: 20),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
|
|
|
|||
|
|
@ -491,7 +491,7 @@ class _FavoritesScreenState extends State<FavoritesScreen>
|
|||
),
|
||||
),
|
||||
Text(
|
||||
'/bln',
|
||||
'/thn',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
|
|
|
|||
|
|
@ -845,7 +845,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'Rp ${_formatPrice(kontrakan.harga)}/bln',
|
||||
'Rp ${_formatPrice(kontrakan.harga)}/thn',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
|
|
|
|||
|
|
@ -509,7 +509,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
kontrakan.formattedHarga + '/bln',
|
||||
kontrakan.formattedHarga + '/thn',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
|
|
|||
|
|
@ -281,7 +281,7 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'SPK Rekomendasi',
|
||||
'Kontrak Kampus',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 22,
|
||||
|
|
@ -1088,7 +1088,7 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
),
|
||||
),
|
||||
Text(
|
||||
'/bln',
|
||||
'/thn',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ class KontrakanDetailScreen extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
||||
double? distance; // jarak dari kampus Polije ke kontrakan ini
|
||||
double? distance; // jarak dari kampus Polije ke kontrakan ini
|
||||
bool _isFavorite = false;
|
||||
bool _isFavLoading = false;
|
||||
final _favoriteService = FavoriteService();
|
||||
|
|
@ -35,7 +35,8 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
|||
|
||||
/// Hitung jarak dari Kampus Polije ke kontrakan ini secara langsung.
|
||||
void _calcDistanceFromPolije() {
|
||||
if (widget.kontrakan.latitude == null || widget.kontrakan.longitude == null) return;
|
||||
if (widget.kontrakan.latitude == null || widget.kontrakan.longitude == null)
|
||||
return;
|
||||
final dist = LocationService.calculateDistance(
|
||||
_polije_lat,
|
||||
_polije_lng,
|
||||
|
|
@ -100,7 +101,9 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
|||
content: Text(result['message'] ?? 'Gagal mengubah favorit'),
|
||||
backgroundColor: Colors.red[700],
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
margin: const EdgeInsets.all(16),
|
||||
),
|
||||
);
|
||||
|
|
@ -226,7 +229,7 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
|||
),
|
||||
),
|
||||
Text(
|
||||
' /bulan',
|
||||
' /tahun',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
|
|
@ -478,7 +481,11 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
|||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.school_rounded, color: Color(0xFF1565C0), size: 22),
|
||||
const Icon(
|
||||
Icons.school_rounded,
|
||||
color: Color(0xFF1565C0),
|
||||
size: 22,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Text(
|
||||
'Jarak dari Kampus Polije',
|
||||
|
|
@ -521,18 +528,15 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
|||
children: [
|
||||
const Text(
|
||||
'Politeknik Negeri Jember',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
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',
|
||||
? '${(distance! * 1000).toStringAsFixed(0)} meter'
|
||||
: '${distance!.toStringAsFixed(2)} km',
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
|
|
@ -588,5 +592,4 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -263,8 +263,8 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
|
|||
),
|
||||
_buildInfoRow(
|
||||
Icons.schedule,
|
||||
'Estimasi Selesai',
|
||||
'${widget.laundry.estimasiSelesai} jam',
|
||||
'Kecepatan Layanan',
|
||||
widget.laundry.estimasiSelesai,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -283,13 +283,6 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
|
|||
Icons.scale,
|
||||
const Color(0xFF00897B),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildPriceCard(
|
||||
'Laundry Satuan',
|
||||
widget.laundry.formattedHargaSatuan,
|
||||
Icons.checkroom,
|
||||
Colors.purple,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -449,37 +449,6 @@ class _LaundryListScreenState extends State<LaundryListScreen> {
|
|||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.purple.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Satuan',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
laundry.formattedHargaSatuan,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.purple,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
|
@ -496,7 +465,7 @@ class _LaundryListScreenState extends State<LaundryListScreen> {
|
|||
Icon(Icons.schedule, size: 16, color: Colors.orange[700]),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Selesai dalam ${laundry.estimasiSelesai} jam',
|
||||
'Selesai dalam ${laundry.estimasiSelesai}',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ class _MobileHomeScreenState extends State<MobileHomeScreen> {
|
|||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'SPK Kontrakan',
|
||||
'Kontrak Kampus',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
|
|
|
|||
|
|
@ -72,7 +72,8 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||
final bookings = await _bookingService.getBookingHistory();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_totalFavorites = (favResult['kontrakan']?.length ?? 0) +
|
||||
_totalFavorites =
|
||||
(favResult['kontrakan']?.length ?? 0) +
|
||||
(favResult['laundry']?.length ?? 0);
|
||||
_totalBookings = bookings.length;
|
||||
});
|
||||
|
|
@ -105,7 +106,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||
'September',
|
||||
'Oktober',
|
||||
'November',
|
||||
'Desember'
|
||||
'Desember',
|
||||
];
|
||||
return '${months[date.month]} ${date.year}';
|
||||
}
|
||||
|
|
@ -116,7 +117,8 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||
backgroundColor: const Color(0xFFF5F6FA),
|
||||
body: _isLoading
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(color: Color(0xFF1565C0)))
|
||||
child: CircularProgressIndicator(color: Color(0xFF1565C0)),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: _loadAll,
|
||||
color: const Color(0xFF1565C0),
|
||||
|
|
@ -156,8 +158,8 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||
final result = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => EditProfileScreen(
|
||||
user: _currentUser!),
|
||||
builder: (_) =>
|
||||
EditProfileScreen(user: _currentUser!),
|
||||
),
|
||||
);
|
||||
if (result == true) _loadAll();
|
||||
|
|
@ -185,7 +187,9 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||
|
||||
// --- Aktivitas ---
|
||||
_buildSectionTitle(
|
||||
'Aktivitas', Icons.timeline_rounded),
|
||||
'Aktivitas',
|
||||
Icons.timeline_rounded,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_buildMenuCard([
|
||||
_buildMenuItem(
|
||||
|
|
@ -225,7 +229,9 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||
|
||||
// --- Lainnya ---
|
||||
_buildSectionTitle(
|
||||
'Lainnya', Icons.more_horiz_rounded),
|
||||
'Lainnya',
|
||||
Icons.more_horiz_rounded,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_buildMenuCard([
|
||||
_buildMenuItem(
|
||||
|
|
@ -318,8 +324,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||
// Title bar
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.person_rounded,
|
||||
color: Colors.white, size: 24),
|
||||
Icon(Icons.person_rounded, color: Colors.white, size: 24),
|
||||
SizedBox(width: 10),
|
||||
Text(
|
||||
'Profil Saya',
|
||||
|
|
@ -355,10 +360,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.white,
|
||||
Color(0xFFBBDEFB),
|
||||
],
|
||||
colors: [Colors.white, Color(0xFFBBDEFB)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
|
|
@ -412,7 +414,9 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 4),
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
|
|
@ -420,18 +424,20 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.calendar_month_rounded,
|
||||
size: 13,
|
||||
color: Colors.white
|
||||
.withValues(alpha: 0.85)),
|
||||
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),
|
||||
color: Colors.white.withValues(
|
||||
alpha: 0.9,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -454,8 +460,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||
Widget _headerInfoRow(IconData icon, String text) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon,
|
||||
size: 14, color: Colors.white.withValues(alpha: 0.75)),
|
||||
Icon(icon, size: 14, color: Colors.white.withValues(alpha: 0.75)),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
|
|
@ -559,11 +564,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||
}
|
||||
|
||||
Widget _buildStatDivider() {
|
||||
return Container(
|
||||
width: 1,
|
||||
height: 45,
|
||||
color: Colors.grey.shade200,
|
||||
);
|
||||
return Container(width: 1, height: 45, color: Colors.grey.shade200);
|
||||
}
|
||||
|
||||
// ==================== SECTION TITLE ====================
|
||||
|
|
@ -681,8 +682,11 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||
color: Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(Icons.chevron_right_rounded,
|
||||
color: Colors.grey.shade400, size: 20),
|
||||
child: Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: Colors.grey.shade400,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -709,8 +713,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.logout_rounded,
|
||||
color: Colors.red.shade600, size: 20),
|
||||
Icon(Icons.logout_rounded, color: Colors.red.shade600, size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'Keluar dari Akun',
|
||||
|
|
@ -749,19 +752,21 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color:
|
||||
const Color(0xFF1565C0).withValues(alpha: 0.3),
|
||||
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),
|
||||
child: const Icon(
|
||||
Icons.home_work_rounded,
|
||||
color: Colors.white,
|
||||
size: 40,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'SPK Kontrakan & Laundry',
|
||||
'Kontrak Kampus',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
|
|
@ -771,8 +776,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||
),
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1565C0).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
|
|
@ -805,14 +809,15 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_aboutDetailRow(
|
||||
Icons.code_rounded, 'Developer', 'Tim TA'),
|
||||
_aboutDetailRow(Icons.code_rounded, 'Developer', 'Tim TA'),
|
||||
const SizedBox(height: 10),
|
||||
_aboutDetailRow(Icons.school_rounded, 'Institusi', 'Polije'),
|
||||
const SizedBox(height: 10),
|
||||
_aboutDetailRow(
|
||||
Icons.school_rounded, 'Institusi', 'Polije'),
|
||||
const SizedBox(height: 10),
|
||||
_aboutDetailRow(
|
||||
Icons.calendar_today_rounded, 'Tahun', '2025'),
|
||||
Icons.calendar_today_rounded,
|
||||
'Tahun',
|
||||
'2025',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -882,8 +887,11 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||
color: Colors.red.shade50,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.logout_rounded,
|
||||
color: Colors.red.shade600, size: 32),
|
||||
child: Icon(
|
||||
Icons.logout_rounded,
|
||||
color: Colors.red.shade600,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
|
|
@ -920,8 +928,10 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Text('Batal',
|
||||
style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
child: const Text(
|
||||
'Batal',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
|
@ -937,8 +947,10 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Text('Keluar',
|
||||
style: TextStyle(fontWeight: FontWeight.w700)),
|
||||
child: const Text(
|
||||
'Keluar',
|
||||
style: TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -385,8 +385,8 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final categoryTitle = widget.category == 'kontrakan'
|
||||
? 'SPK Rekomendasi Kontrakan'
|
||||
: 'SPK Rekomendasi Laundry';
|
||||
? 'Kontrak Kampus - Rekomendasi Kontrakan'
|
||||
: 'Kontrak Kampus - Rekomendasi Laundry';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
|
|
@ -660,12 +660,6 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
|||
'icon': Icons.flash_on,
|
||||
'desc': 'Layanan cepat dengan waktu lebih singkat',
|
||||
},
|
||||
{
|
||||
'value': 'kilat',
|
||||
'label': 'Kilat',
|
||||
'icon': Icons.bolt,
|
||||
'desc': 'Layanan tercepat, selesai dalam hitungan jam',
|
||||
},
|
||||
];
|
||||
|
||||
return Container(
|
||||
|
|
@ -1390,8 +1384,6 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
|||
return 'Reguler';
|
||||
case 'express':
|
||||
return 'Express';
|
||||
case 'kilat':
|
||||
return 'Kilat';
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
|
|
@ -1704,7 +1696,11 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
|||
padding: const EdgeInsets.all(8),
|
||||
child: widget.category == 'kontrakan'
|
||||
? KontrakanCard(kontrakan: Kontrakan.fromJson(itemData))
|
||||
: LaundryCard(laundry: Laundry.fromJson(itemData)),
|
||||
: LaundryCard(
|
||||
laundry: Laundry.fromJson(itemData),
|
||||
userLatitude: _userLatitude,
|
||||
userLongitude: _userLongitude,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -850,7 +850,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'${kontrakan.formattedHarga}/bln',
|
||||
'${kontrakan.formattedHarga}/thn',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
|
|
|
|||
|
|
@ -14,14 +14,13 @@ class ServerDiscoveryService {
|
|||
static const int _port = 8000;
|
||||
static const String _cacheKey = 'discovered_server_url';
|
||||
static const Duration _probeTimeout = Duration(seconds: 2);
|
||||
static const int _scanBatchSize = 24;
|
||||
|
||||
// ─── PUBLIC ENTRY POINT ─────────────────────────────────────────────────────
|
||||
|
||||
/// Temukan server dan update [AppConfig] secara otomatis.
|
||||
/// Kembalikan `true` jika server ditemukan, `false` jika tidak.
|
||||
static Future<bool> discover({
|
||||
void Function(String status)? onStatus,
|
||||
}) async {
|
||||
static Future<bool> discover({void Function(String status)? onStatus}) async {
|
||||
onStatus?.call('Mencari server...');
|
||||
|
||||
// 1. Coba cache terlebih dahulu
|
||||
|
|
@ -73,12 +72,12 @@ class ServerDiscoveryService {
|
|||
try {
|
||||
final response = await http
|
||||
.get(
|
||||
Uri.parse('$serverUrl/api'),
|
||||
Uri.parse('$serverUrl/api/health'),
|
||||
headers: {'Accept': 'application/json'},
|
||||
)
|
||||
.timeout(_probeTimeout);
|
||||
// Respons 4xx/5xx tetap berarti server bisa dijangkau
|
||||
return response.statusCode > 0;
|
||||
// Pastikan benar-benar backend API aplikasi ini.
|
||||
return response.statusCode == 200;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -110,27 +109,33 @@ class ServerDiscoveryService {
|
|||
List<String> subnets, {
|
||||
void Function(String)? onStatus,
|
||||
}) async {
|
||||
final completer = Completer<String?>();
|
||||
int remaining = subnets.length * 254;
|
||||
|
||||
if (remaining == 0) return null;
|
||||
if (subnets.isEmpty) return null;
|
||||
|
||||
// Scan host secara bertahap untuk menghindari "socket flood" di device.
|
||||
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);
|
||||
onStatus?.call('Memindai subnet $subnet.x ...');
|
||||
|
||||
for (int start = 1; start <= 254; start += _scanBatchSize) {
|
||||
final end = (start + _scanBatchSize - 1) > 254
|
||||
? 254
|
||||
: (start + _scanBatchSize - 1);
|
||||
|
||||
final futures = <Future<String?>>[];
|
||||
for (int i = start; i <= end; i++) {
|
||||
final url = 'http://$subnet.$i:$_port';
|
||||
futures.add(_isAlive(url).then((alive) => alive ? url : null));
|
||||
}
|
||||
|
||||
final results = await Future.wait(futures);
|
||||
for (final found in results) {
|
||||
if (found != null) {
|
||||
return found;
|
||||
}
|
||||
remaining--;
|
||||
if (remaining <= 0 && !completer.isCompleted) {
|
||||
completer.complete(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return completer.future;
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<String?> _getCachedUrl() async {
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ class KontrakanCard extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
Text(
|
||||
'/bln',
|
||||
'/thn',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
|
|
|
|||
|
|
@ -2,12 +2,15 @@ import 'package:flutter/material.dart';
|
|||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../models/laundry.dart';
|
||||
import '../screens/laundry_detail_screen.dart';
|
||||
import '../services/location_service.dart';
|
||||
|
||||
class LaundryCard extends StatelessWidget {
|
||||
final Laundry laundry;
|
||||
final int? ranking;
|
||||
final double? skor;
|
||||
final bool showRanking;
|
||||
final double? userLatitude;
|
||||
final double? userLongitude;
|
||||
|
||||
const LaundryCard({
|
||||
super.key,
|
||||
|
|
@ -15,6 +18,8 @@ class LaundryCard extends StatelessWidget {
|
|||
this.ranking,
|
||||
this.skor,
|
||||
this.showRanking = false,
|
||||
this.userLatitude,
|
||||
this.userLongitude,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -243,7 +248,7 @@ class LaundryCard extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${laundry.jarakKampus.toStringAsFixed(1)} km dari kampus',
|
||||
_distanceText(),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey,
|
||||
|
|
@ -304,6 +309,26 @@ class LaundryCard extends StatelessWidget {
|
|||
return const Color(0xFF00897B);
|
||||
}
|
||||
|
||||
String _distanceText() {
|
||||
if (userLatitude != null &&
|
||||
userLongitude != null &&
|
||||
laundry.latitude != null &&
|
||||
laundry.longitude != null) {
|
||||
final distance = LocationService.calculateDistance(
|
||||
userLatitude!,
|
||||
userLongitude!,
|
||||
laundry.latitude!,
|
||||
laundry.longitude!,
|
||||
);
|
||||
if (distance < 1) {
|
||||
return '${(distance * 1000).toStringAsFixed(0)} m dari lokasi Anda';
|
||||
}
|
||||
return '${distance.toStringAsFixed(1)} km dari lokasi Anda';
|
||||
}
|
||||
|
||||
return '${laundry.jarakKampus.toStringAsFixed(1)} km dari kampus';
|
||||
}
|
||||
|
||||
IconData _getRankingIcon(int ranking) {
|
||||
if (ranking <= 3) return Icons.emoji_events;
|
||||
return Icons.star;
|
||||
|
|
|
|||
Loading…
Reference in New Issue