Web API Security Improvements: Rate limiting, centralized error handling, N+1 query fixes, CORS/Sanctum config, caching, export system fix, and pagination UI improvements
This commit is contained in:
parent
7453d3b322
commit
79305d29c4
|
|
@ -1,12 +1,14 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Permissions for location -->
|
||||
<!-- Permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
|
||||
<application
|
||||
android:label="spk_mobile"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
|
|
|||
|
|
@ -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://192.168.18.16:8000/api';
|
||||
static const String storageUrl = 'http://192.168.18.16:8000/storage';
|
||||
static const String baseUrl = 'http://10.215.176.99:8000/api';
|
||||
static const String storageUrl = 'http://10.215.176.99:8000/storage';
|
||||
|
||||
// Timeouts
|
||||
static const Duration connectionTimeout = Duration(seconds: 10);
|
||||
|
|
|
|||
|
|
@ -104,7 +104,6 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
// Email Field
|
||||
_buildTextField(
|
||||
label: 'Email',
|
||||
|
|
@ -287,7 +286,10 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: const BorderSide(color: Color(0xFF1565C0), width: 1.5),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFF1565C0),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
|
|
@ -309,9 +311,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF7F8FC),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFE0E0E0),
|
||||
),
|
||||
border: Border.all(color: const Color(0xFFE0E0E0)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
@ -324,7 +324,11 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
color: const Color(0xFF1565C0).withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(Icons.school_rounded, color: Color(0xFF1565C0), size: 22),
|
||||
child: const Icon(
|
||||
Icons.school_rounded,
|
||||
color: Color(0xFF1565C0),
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
|
|
|
|||
|
|
@ -57,10 +57,16 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
|||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: Colors.red.shade600, size: 24),
|
||||
Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: Colors.red.shade600,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Text('Batalkan Booking',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
||||
const Text(
|
||||
'Batalkan Booking',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: const Text('Apakah Anda yakin ingin membatalkan booking ini?'),
|
||||
|
|
@ -92,8 +98,11 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
|||
content: Row(
|
||||
children: [
|
||||
Icon(
|
||||
result['success'] == true ? Icons.check_circle_rounded : Icons.error_outline_rounded,
|
||||
color: Colors.white, size: 20,
|
||||
result['success'] == true
|
||||
? Icons.check_circle_rounded
|
||||
: Icons.error_outline_rounded,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(result['message'] ?? '')),
|
||||
|
|
@ -103,7 +112,9 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
|||
? const Color(0xFF2E7D32)
|
||||
: const Color(0xFFC62828),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
margin: const EdgeInsets.all(16),
|
||||
),
|
||||
);
|
||||
|
|
@ -164,8 +175,10 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
|||
size: 22,
|
||||
),
|
||||
),
|
||||
title: const Text('Pilih dari Galeri',
|
||||
style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
title: const Text(
|
||||
'Pilih dari Galeri',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
onTap: () => Navigator.pop(ctx, ImageSource.gallery),
|
||||
),
|
||||
ListTile(
|
||||
|
|
@ -182,8 +195,10 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
|||
size: 22,
|
||||
),
|
||||
),
|
||||
title: const Text('Ambil Foto',
|
||||
style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
title: const Text(
|
||||
'Ambil Foto',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
onTap: () => Navigator.pop(ctx, ImageSource.camera),
|
||||
),
|
||||
],
|
||||
|
|
@ -213,8 +228,11 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
|||
content: Row(
|
||||
children: [
|
||||
Icon(
|
||||
result['success'] == true ? Icons.check_circle_rounded : Icons.error_outline_rounded,
|
||||
color: Colors.white, size: 20,
|
||||
result['success'] == true
|
||||
? Icons.check_circle_rounded
|
||||
: Icons.error_outline_rounded,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(result['message'] ?? '')),
|
||||
|
|
@ -224,7 +242,9 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
|||
? const Color(0xFF2E7D32)
|
||||
: const Color(0xFFC62828),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
margin: const EdgeInsets.all(16),
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
|
|
@ -363,7 +383,9 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
|||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
isActive ? Icons.bookmark_outline_rounded : Icons.history_rounded,
|
||||
isActive
|
||||
? Icons.bookmark_outline_rounded
|
||||
: Icons.history_rounded,
|
||||
size: 56,
|
||||
color: const Color(0xFF1565C0).withOpacity(0.3),
|
||||
),
|
||||
|
|
@ -562,10 +584,7 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
|||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Divider(
|
||||
height: 1,
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
child: Divider(height: 1, color: Colors.grey[200]),
|
||||
),
|
||||
_buildDateRow(
|
||||
Icons.logout_rounded,
|
||||
|
|
@ -574,10 +593,7 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
|||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Divider(
|
||||
height: 1,
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
child: Divider(height: 1, color: Colors.grey[200]),
|
||||
),
|
||||
_buildDateRow(
|
||||
Icons.timelapse_rounded,
|
||||
|
|
@ -641,7 +657,9 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
|||
),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
booking.paymentStatus == 'paid' ? 'Lunas' : 'Belum Bayar',
|
||||
booking.paymentStatus == 'paid'
|
||||
? 'Lunas'
|
||||
: 'Belum Bayar',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
|
@ -661,7 +679,8 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
|||
const SizedBox(height: 12),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
final url = '${AppConfig.storageUrl}/${booking.paymentProof}';
|
||||
final url =
|
||||
'${AppConfig.storageUrl}/${booking.paymentProof}';
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => Dialog(
|
||||
|
|
@ -685,8 +704,11 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
|||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.receipt_long_rounded,
|
||||
color: Colors.white, size: 20),
|
||||
const Icon(
|
||||
Icons.receipt_long_rounded,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
|
|
@ -704,10 +726,15 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
|||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: BorderRadius.circular(
|
||||
8,
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.close_rounded,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
child: const Icon(Icons.close_rounded,
|
||||
color: Colors.white, size: 18),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -742,8 +769,11 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
|||
color: const Color(0xFF2E7D32).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(Icons.image_rounded,
|
||||
size: 16, color: Color(0xFF2E7D32)),
|
||||
child: const Icon(
|
||||
Icons.image_rounded,
|
||||
size: 16,
|
||||
color: Color(0xFF2E7D32),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Expanded(
|
||||
|
|
@ -756,8 +786,11 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
|||
),
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right_rounded,
|
||||
color: Color(0xFF2E7D32), size: 20),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: Color(0xFF2E7D32),
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -854,8 +887,18 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
|||
|
||||
String _formatDate(DateTime date) {
|
||||
const months = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun',
|
||||
'Jul', 'Agt', 'Sep', 'Okt', 'Nov', 'Des',
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'Mei',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Agt',
|
||||
'Sep',
|
||||
'Okt',
|
||||
'Nov',
|
||||
'Des',
|
||||
];
|
||||
return '${date.day} ${months[date.month - 1]} ${date.year}';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,8 +60,10 @@ class _FavoritesScreenState extends State<FavoritesScreen>
|
|||
children: [
|
||||
Icon(Icons.favorite_rounded, color: Colors.red.shade400, size: 24),
|
||||
const SizedBox(width: 10),
|
||||
const Text('Hapus Favorit',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
||||
const Text(
|
||||
'Hapus Favorit',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: const Text('Hapus dari daftar favorit Anda?'),
|
||||
|
|
@ -99,14 +101,20 @@ class _FavoritesScreenState extends State<FavoritesScreen>
|
|||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle_rounded, color: Colors.white, size: 20),
|
||||
const Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(result['message'] ?? 'Dihapus dari favorit'),
|
||||
],
|
||||
),
|
||||
backgroundColor: const Color(0xFF2E7D32),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
margin: const EdgeInsets.all(16),
|
||||
),
|
||||
);
|
||||
|
|
@ -225,7 +233,10 @@ class _FavoritesScreenState extends State<FavoritesScreen>
|
|||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.local_laundry_service_rounded, size: 18),
|
||||
const Icon(
|
||||
Icons.local_laundry_service_rounded,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text('Laundry (${_laundryFavorites.length})'),
|
||||
],
|
||||
|
|
@ -242,14 +253,13 @@ class _FavoritesScreenState extends State<FavoritesScreen>
|
|||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(color: Color(0xFF1565C0)),
|
||||
child: CircularProgressIndicator(
|
||||
color: Color(0xFF1565C0),
|
||||
),
|
||||
)
|
||||
: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildKontrakanList(),
|
||||
_buildLaundryList(),
|
||||
],
|
||||
children: [_buildKontrakanList(), _buildLaundryList()],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -264,7 +274,8 @@ class _FavoritesScreenState extends State<FavoritesScreen>
|
|||
return _buildEmptyState(
|
||||
icon: Icons.home_work_outlined,
|
||||
title: 'Belum ada kontrakan favorit',
|
||||
subtitle: 'Tambahkan kontrakan ke favorit dari halaman detail\natau setelah mendapat hasil rekomendasi',
|
||||
subtitle:
|
||||
'Tambahkan kontrakan ke favorit dari halaman detail\natau setelah mendapat hasil rekomendasi',
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -322,7 +333,11 @@ class _FavoritesScreenState extends State<FavoritesScreen>
|
|||
height: 120,
|
||||
color: const Color(0xFFE3F2FD),
|
||||
child: const Center(
|
||||
child: Icon(Icons.home_work_rounded, color: Color(0xFF90CAF9), size: 32),
|
||||
child: Icon(
|
||||
Icons.home_work_rounded,
|
||||
color: Color(0xFF90CAF9),
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
errorWidget: (_, __, ___) => Container(
|
||||
|
|
@ -330,7 +345,11 @@ class _FavoritesScreenState extends State<FavoritesScreen>
|
|||
height: 120,
|
||||
color: const Color(0xFFE3F2FD),
|
||||
child: const Center(
|
||||
child: Icon(Icons.home_work_rounded, color: Color(0xFF90CAF9), size: 32),
|
||||
child: Icon(
|
||||
Icons.home_work_rounded,
|
||||
color: Color(0xFF90CAF9),
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -355,12 +374,19 @@ class _FavoritesScreenState extends State<FavoritesScreen>
|
|||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.location_on_rounded, size: 13, color: Colors.grey[400]),
|
||||
Icon(
|
||||
Icons.location_on_rounded,
|
||||
size: 13,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Expanded(
|
||||
child: Text(
|
||||
kontrakan.alamat,
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
|
@ -391,9 +417,17 @@ class _FavoritesScreenState extends State<FavoritesScreen>
|
|||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
_buildInfoChip(Icons.bed_rounded, '${kontrakan.jumlahKamar} Kamar', const Color(0xFF1565C0)),
|
||||
_buildInfoChip(
|
||||
Icons.bed_rounded,
|
||||
'${kontrakan.jumlahKamar} Kamar',
|
||||
const Color(0xFF1565C0),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildInfoChip(Icons.directions_walk_rounded, '${kontrakan.jarakKampus.toStringAsFixed(1)} km', const Color(0xFF5C6BC0)),
|
||||
_buildInfoChip(
|
||||
Icons.directions_walk_rounded,
|
||||
'${kontrakan.jarakKampus.toStringAsFixed(1)} km',
|
||||
const Color(0xFF5C6BC0),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
|
@ -405,11 +439,18 @@ class _FavoritesScreenState extends State<FavoritesScreen>
|
|||
children: [
|
||||
IconButton(
|
||||
onPressed: () => _removeFavorite('kontrakan', kontrakan.id),
|
||||
icon: const Icon(Icons.favorite_rounded, color: Colors.red, size: 24),
|
||||
icon: const Icon(
|
||||
Icons.favorite_rounded,
|
||||
color: Colors.red,
|
||||
size: 24,
|
||||
),
|
||||
splashRadius: 20,
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: kontrakan.isAvailable
|
||||
? const Color(0xFFE8F5E9)
|
||||
|
|
@ -442,7 +483,8 @@ class _FavoritesScreenState extends State<FavoritesScreen>
|
|||
return _buildEmptyState(
|
||||
icon: Icons.local_laundry_service_outlined,
|
||||
title: 'Belum ada laundry favorit',
|
||||
subtitle: 'Tambahkan laundry ke favorit dari halaman detail\natau setelah mendapat hasil rekomendasi',
|
||||
subtitle:
|
||||
'Tambahkan laundry ke favorit dari halaman detail\natau setelah mendapat hasil rekomendasi',
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -529,12 +571,19 @@ class _FavoritesScreenState extends State<FavoritesScreen>
|
|||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.location_on_rounded, size: 13, color: Colors.grey[400]),
|
||||
Icon(
|
||||
Icons.location_on_rounded,
|
||||
size: 13,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Expanded(
|
||||
child: Text(
|
||||
laundry.alamat,
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
|
@ -565,9 +614,17 @@ class _FavoritesScreenState extends State<FavoritesScreen>
|
|||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
_buildInfoChip(Icons.schedule_rounded, '${laundry.waktuProses}h proses', const Color(0xFF00897B)),
|
||||
_buildInfoChip(
|
||||
Icons.schedule_rounded,
|
||||
'${laundry.waktuProses}h proses',
|
||||
const Color(0xFF00897B),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildInfoChip(Icons.access_time_rounded, '${laundry.jamBuka}-${laundry.jamTutup}', const Color(0xFF5C6BC0)),
|
||||
_buildInfoChip(
|
||||
Icons.access_time_rounded,
|
||||
'${laundry.jamBuka}-${laundry.jamTutup}',
|
||||
const Color(0xFF5C6BC0),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
|
@ -579,11 +636,18 @@ class _FavoritesScreenState extends State<FavoritesScreen>
|
|||
children: [
|
||||
IconButton(
|
||||
onPressed: () => _removeFavorite('laundry', laundry.id),
|
||||
icon: const Icon(Icons.favorite_rounded, color: Colors.red, size: 24),
|
||||
icon: const Icon(
|
||||
Icons.favorite_rounded,
|
||||
color: Colors.red,
|
||||
size: 24,
|
||||
),
|
||||
splashRadius: 20,
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: laundry.status == 'buka'
|
||||
? const Color(0xFFE8F5E9)
|
||||
|
|
@ -625,7 +689,11 @@ class _FavoritesScreenState extends State<FavoritesScreen>
|
|||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: color),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -649,7 +717,11 @@ class _FavoritesScreenState extends State<FavoritesScreen>
|
|||
color: const Color(0xFF1565C0).withOpacity(0.06),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, size: 56, color: const Color(0xFF1565C0).withOpacity(0.3)),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 56,
|
||||
color: const Color(0xFF1565C0).withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
|
|
@ -664,7 +736,11 @@ class _FavoritesScreenState extends State<FavoritesScreen>
|
|||
Text(
|
||||
subtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey[500], height: 1.5),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey[500],
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -149,7 +149,9 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||
Text(
|
||||
'Halo, ${_authService.currentUser?.name ?? "User"} 👋',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.85),
|
||||
color: Colors.white.withValues(
|
||||
alpha: 0.85,
|
||||
),
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
|
|
@ -212,7 +214,9 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF6C63FF).withValues(alpha: 0.1),
|
||||
color: const Color(
|
||||
0xFF6C63FF,
|
||||
).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
|
|
@ -366,7 +370,10 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Text('Semua', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
Text(
|
||||
'Semua',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
SizedBox(width: 4),
|
||||
Icon(Icons.arrow_forward_ios_rounded, size: 14),
|
||||
],
|
||||
|
|
@ -383,29 +390,35 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||
child: SizedBox(
|
||||
height: 230,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(color: Color(0xFF6C63FF)),
|
||||
child: CircularProgressIndicator(
|
||||
color: Color(0xFF6C63FF),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: _kontrakanList.isEmpty
|
||||
? SliverToBoxAdapter(child: _buildEmptyState(
|
||||
icon: Icons.home_work_outlined,
|
||||
message: 'Belum ada kontrakan',
|
||||
))
|
||||
: SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 265,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: _kontrakanList.length > 6 ? 6 : _kontrakanList.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildKontrakanCard(_kontrakanList[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
? SliverToBoxAdapter(
|
||||
child: _buildEmptyState(
|
||||
icon: Icons.home_work_outlined,
|
||||
message: 'Belum ada kontrakan',
|
||||
),
|
||||
)
|
||||
: SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 265,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: _kontrakanList.length > 6
|
||||
? 6
|
||||
: _kontrakanList.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildKontrakanCard(_kontrakanList[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// LAUNDRY SECTION TITLE
|
||||
SliverToBoxAdapter(
|
||||
|
|
@ -453,7 +466,10 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Text('Semua', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
Text(
|
||||
'Semua',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
SizedBox(width: 4),
|
||||
Icon(Icons.arrow_forward_ios_rounded, size: 14),
|
||||
],
|
||||
|
|
@ -470,26 +486,32 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||
child: SizedBox(
|
||||
height: 150,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(color: Color(0xFF00BFA5)),
|
||||
child: CircularProgressIndicator(
|
||||
color: Color(0xFF00BFA5),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: _laundryList.isEmpty
|
||||
? SliverToBoxAdapter(child: _buildEmptyState(
|
||||
icon: Icons.local_laundry_service_outlined,
|
||||
message: 'Belum ada laundry',
|
||||
))
|
||||
: SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 100),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
return _buildLaundryCard(_laundryList[index]);
|
||||
},
|
||||
childCount: _laundryList.length > 5 ? 5 : _laundryList.length,
|
||||
),
|
||||
),
|
||||
? SliverToBoxAdapter(
|
||||
child: _buildEmptyState(
|
||||
icon: Icons.local_laundry_service_outlined,
|
||||
message: 'Belum ada laundry',
|
||||
),
|
||||
)
|
||||
: SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 100),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
return _buildLaundryCard(_laundryList[index]);
|
||||
},
|
||||
childCount: _laundryList.length > 5
|
||||
? 5
|
||||
: _laundryList.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -673,7 +695,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(18),
|
||||
),
|
||||
child: kontrakan.fotoUtama != null &&
|
||||
child:
|
||||
kontrakan.fotoUtama != null &&
|
||||
kontrakan.fotoUtama!.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl:
|
||||
|
|
@ -780,7 +803,11 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||
const Spacer(),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.bed_rounded, size: 13, color: Colors.grey[500]),
|
||||
Icon(
|
||||
Icons.bed_rounded,
|
||||
size: 13,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'${kontrakan.jumlahKamar}',
|
||||
|
|
@ -791,7 +818,11 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Icon(Icons.near_me_rounded, size: 13, color: Colors.grey[500]),
|
||||
Icon(
|
||||
Icons.near_me_rounded,
|
||||
size: 13,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'${kontrakan.jarakKampus.toStringAsFixed(1)} km',
|
||||
|
|
@ -867,7 +898,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||
color: const Color(0xFF00BFA5).withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: laundry.fotoUtama != null && laundry.fotoUtama!.isNotEmpty
|
||||
child:
|
||||
laundry.fotoUtama != null && laundry.fotoUtama!.isNotEmpty
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: CachedNetworkImage(
|
||||
|
|
@ -935,12 +967,19 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.location_on_rounded, size: 13, color: Colors.grey[500]),
|
||||
Icon(
|
||||
Icons.location_on_rounded,
|
||||
size: 13,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Expanded(
|
||||
child: Text(
|
||||
laundry.alamat,
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
|
@ -951,9 +990,14 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF00BFA5).withValues(alpha: 0.08),
|
||||
color: const Color(
|
||||
0xFF00BFA5,
|
||||
).withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
|
|
@ -966,15 +1010,26 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(Icons.near_me_rounded, size: 13, color: Colors.grey[500]),
|
||||
Icon(
|
||||
Icons.near_me_rounded,
|
||||
size: 13,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'${laundry.jarakKampus.toStringAsFixed(1)} km',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (laundry.avgRating != null) ...[
|
||||
const Icon(Icons.star_rounded, size: 14, color: Color(0xFFFFB800)),
|
||||
const Icon(
|
||||
Icons.star_rounded,
|
||||
size: 14,
|
||||
color: Color(0xFFFFB800),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
laundry.avgRating!.toStringAsFixed(1),
|
||||
|
|
@ -991,7 +1046,11 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(Icons.chevron_right_rounded, color: Colors.grey, size: 22),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: Colors.grey,
|
||||
size: 22,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -403,7 +403,9 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const RecommendationScreen(category: 'kontrakan'),
|
||||
builder: (_) => const RecommendationScreen(
|
||||
category: 'kontrakan',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
@ -421,7 +423,9 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const RecommendationScreen(category: 'laundry'),
|
||||
builder: (_) => const RecommendationScreen(
|
||||
category: 'laundry',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
@ -451,18 +455,21 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
: _kontrakanList.isEmpty
|
||||
? _buildEmptyState('Belum ada data kontrakan', Icons.home_work_outlined)
|
||||
: SizedBox(
|
||||
height: 250,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 8, 0),
|
||||
itemCount: _kontrakanList.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildKontrakanCard(_kontrakanList[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
? _buildEmptyState(
|
||||
'Belum ada data kontrakan',
|
||||
Icons.home_work_outlined,
|
||||
)
|
||||
: SizedBox(
|
||||
height: 250,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 8, 0),
|
||||
itemCount: _kontrakanList.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildKontrakanCard(_kontrakanList[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Laundry Section ──
|
||||
|
|
@ -483,18 +490,21 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
: _laundryList.isEmpty
|
||||
? _buildEmptyState('Belum ada data laundry', Icons.local_laundry_service_outlined)
|
||||
: SizedBox(
|
||||
height: 240,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 8, 0),
|
||||
itemCount: _laundryList.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildLaundryCard(_laundryList[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
? _buildEmptyState(
|
||||
'Belum ada data laundry',
|
||||
Icons.local_laundry_service_outlined,
|
||||
)
|
||||
: SizedBox(
|
||||
height: 240,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 8, 0),
|
||||
itemCount: _laundryList.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildLaundryCard(_laundryList[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Quick Actions ──
|
||||
|
|
@ -589,10 +599,7 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Pilih jenis layanan yang ingin Anda cari',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Padding(
|
||||
|
|
@ -603,7 +610,8 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
child: _buildChoiceCard(
|
||||
icon: Icons.home_work_rounded,
|
||||
label: 'Kontrakan',
|
||||
description: 'Cari kontrakan terbaik sesuai budget dan kebutuhan',
|
||||
description:
|
||||
'Cari kontrakan terbaik sesuai budget dan kebutuhan',
|
||||
color: const Color(0xFF1565C0),
|
||||
bgColor: const Color(0xFFE3F2FD),
|
||||
onTap: () {
|
||||
|
|
@ -611,7 +619,9 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const RecommendationScreen(category: 'kontrakan'),
|
||||
builder: (_) => const RecommendationScreen(
|
||||
category: 'kontrakan',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
@ -622,7 +632,8 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
child: _buildChoiceCard(
|
||||
icon: Icons.local_laundry_service_rounded,
|
||||
label: 'Laundry',
|
||||
description: 'Cari laundry terbaik dari harga dan kecepatan',
|
||||
description:
|
||||
'Cari laundry terbaik dari harga dan kecepatan',
|
||||
color: const Color(0xFF00897B),
|
||||
bgColor: const Color(0xFFE0F2F1),
|
||||
onTap: () {
|
||||
|
|
@ -630,7 +641,8 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const RecommendationScreen(category: 'laundry'),
|
||||
builder: (_) =>
|
||||
const RecommendationScreen(category: 'laundry'),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
@ -745,7 +757,11 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
),
|
||||
),
|
||||
),
|
||||
Icon(Icons.arrow_forward_ios_rounded, size: 14, color: color.withOpacity(0.5)),
|
||||
Icon(
|
||||
Icons.arrow_forward_ios_rounded,
|
||||
size: 14,
|
||||
color: color.withOpacity(0.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -767,8 +783,14 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: isLast
|
||||
? const BorderRadius.only(topRight: Radius.circular(20), bottomRight: Radius.circular(20))
|
||||
: const BorderRadius.only(topLeft: Radius.circular(20), bottomLeft: Radius.circular(20)),
|
||||
? const BorderRadius.only(
|
||||
topRight: Radius.circular(20),
|
||||
bottomRight: Radius.circular(20),
|
||||
)
|
||||
: const BorderRadius.only(
|
||||
topLeft: Radius.circular(20),
|
||||
bottomLeft: Radius.circular(20),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
|
|
@ -793,7 +815,11 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Icon(Icons.arrow_forward_rounded, size: 16, color: color.withOpacity(0.6)),
|
||||
Icon(
|
||||
Icons.arrow_forward_rounded,
|
||||
size: 16,
|
||||
color: color.withOpacity(0.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -836,7 +862,9 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
onPressed: onSeeAll,
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
|
@ -891,7 +919,9 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(16),
|
||||
),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: kontrakan.primaryPhoto,
|
||||
height: 120,
|
||||
|
|
@ -902,7 +932,11 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
width: 200,
|
||||
color: const Color(0xFFE3F2FD),
|
||||
child: const Center(
|
||||
child: Icon(Icons.home_work_rounded, size: 36, color: Color(0xFF90CAF9)),
|
||||
child: Icon(
|
||||
Icons.home_work_rounded,
|
||||
size: 36,
|
||||
color: Color(0xFF90CAF9),
|
||||
),
|
||||
),
|
||||
),
|
||||
errorWidget: (_, __, ___) => Container(
|
||||
|
|
@ -910,7 +944,11 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
width: 200,
|
||||
color: const Color(0xFFE3F2FD),
|
||||
child: const Center(
|
||||
child: Icon(Icons.home_work_rounded, size: 36, color: Color(0xFF90CAF9)),
|
||||
child: Icon(
|
||||
Icons.home_work_rounded,
|
||||
size: 36,
|
||||
color: Color(0xFF90CAF9),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -920,7 +958,10 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
top: 8,
|
||||
right: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: kontrakan.isAvailable
|
||||
? const Color(0xFF2E7D32)
|
||||
|
|
@ -988,12 +1029,19 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.location_on_rounded, size: 12, color: Colors.grey[400]),
|
||||
Icon(
|
||||
Icons.location_on_rounded,
|
||||
size: 12,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Expanded(
|
||||
child: Text(
|
||||
kontrakan.alamat,
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[500]),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
|
@ -1029,7 +1077,10 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE3F2FD),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
|
|
@ -1037,7 +1088,11 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.bed_rounded, size: 12, color: Color(0xFF1565C0)),
|
||||
const Icon(
|
||||
Icons.bed_rounded,
|
||||
size: 12,
|
||||
color: Color(0xFF1565C0),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'${kontrakan.jumlahKamar}',
|
||||
|
|
@ -1055,7 +1110,11 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.directions_walk_rounded, size: 12, color: Colors.grey[400]),
|
||||
Icon(
|
||||
Icons.directions_walk_rounded,
|
||||
size: 12,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'${kontrakan.jarakKampus.toStringAsFixed(1)} km dari kampus',
|
||||
|
|
@ -1104,7 +1163,9 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(16),
|
||||
),
|
||||
child: Container(
|
||||
height: 110,
|
||||
width: 200,
|
||||
|
|
@ -1129,7 +1190,10 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
top: 8,
|
||||
right: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: laundry.status == 'buka'
|
||||
? const Color(0xFF2E7D32)
|
||||
|
|
@ -1197,12 +1261,19 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.location_on_rounded, size: 12, color: Colors.grey[400]),
|
||||
Icon(
|
||||
Icons.location_on_rounded,
|
||||
size: 12,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Expanded(
|
||||
child: Text(
|
||||
laundry.alamat,
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[500]),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
|
@ -1238,7 +1309,10 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE0F2F1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
|
|
@ -1246,7 +1320,11 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.schedule_rounded, size: 12, color: Color(0xFF00897B)),
|
||||
const Icon(
|
||||
Icons.schedule_rounded,
|
||||
size: 12,
|
||||
color: Color(0xFF00897B),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'${laundry.waktuProses}h',
|
||||
|
|
@ -1264,7 +1342,11 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.access_time_rounded, size: 12, color: Colors.grey[400]),
|
||||
Icon(
|
||||
Icons.access_time_rounded,
|
||||
size: 12,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'${laundry.jamBuka} - ${laundry.jamTutup}',
|
||||
|
|
@ -1331,7 +1413,10 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
children: [
|
||||
Icon(icon, size: 48, color: Colors.grey[300]),
|
||||
const SizedBox(height: 8),
|
||||
Text(message, style: TextStyle(fontSize: 13, color: Colors.grey[500])),
|
||||
Text(
|
||||
message,
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey[500]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -1343,8 +1428,16 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
const items = [
|
||||
_NavItem(Icons.home_outlined, Icons.home_rounded, 'Beranda'),
|
||||
_NavItem(Icons.search_outlined, Icons.search_rounded, 'Cari'),
|
||||
_NavItem(Icons.favorite_outline_rounded, Icons.favorite_rounded, 'Favorit'),
|
||||
_NavItem(Icons.bookmark_outline_rounded, Icons.bookmark_rounded, 'Booking'),
|
||||
_NavItem(
|
||||
Icons.favorite_outline_rounded,
|
||||
Icons.favorite_rounded,
|
||||
'Favorit',
|
||||
),
|
||||
_NavItem(
|
||||
Icons.bookmark_outline_rounded,
|
||||
Icons.bookmark_rounded,
|
||||
'Booking',
|
||||
),
|
||||
_NavItem(Icons.person_outline_rounded, Icons.person_rounded, 'Profil'),
|
||||
];
|
||||
|
||||
|
|
@ -1403,7 +1496,9 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
|||
items[i].label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.w400,
|
||||
color: isSelected
|
||||
? const Color(0xFF1565C0)
|
||||
: const Color(0xFF9E9E9E),
|
||||
|
|
|
|||
|
|
@ -33,13 +33,17 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
|||
}
|
||||
|
||||
Future<void> _checkFavorite() async {
|
||||
final result = await _favoriteService.isKontrakanFavorite(widget.kontrakan.id);
|
||||
final result = await _favoriteService.isKontrakanFavorite(
|
||||
widget.kontrakan.id,
|
||||
);
|
||||
if (mounted) setState(() => _isFavorite = result);
|
||||
}
|
||||
|
||||
Future<void> _toggleFavorite() async {
|
||||
setState(() => _isFavLoading = true);
|
||||
final result = await _favoriteService.toggleKontrakanFavorite(widget.kontrakan.id);
|
||||
final result = await _favoriteService.toggleKontrakanFavorite(
|
||||
widget.kontrakan.id,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isFavLoading = false;
|
||||
|
|
@ -52,16 +56,23 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
|||
content: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_isFavorite ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
color: Colors.white, size: 20,
|
||||
_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],
|
||||
backgroundColor: _isFavorite
|
||||
? const Color(0xFF1565C0)
|
||||
: Colors.grey[700],
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
margin: const EdgeInsets.all(16),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
|
|
@ -94,7 +105,9 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
|||
: IconButton(
|
||||
onPressed: _toggleFavorite,
|
||||
icon: Icon(
|
||||
_isFavorite ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
_isFavorite
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded,
|
||||
color: _isFavorite ? Colors.redAccent : Colors.white,
|
||||
size: 26,
|
||||
),
|
||||
|
|
@ -150,7 +163,10 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
|||
),
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1565C0).withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
|
|
@ -214,11 +230,16 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
|||
runSpacing: 8,
|
||||
children: widget.kontrakan.fasilitasList.map((f) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE3F2FD),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: const Color(0xFF1565C0).withOpacity(0.15)),
|
||||
border: Border.all(
|
||||
color: const Color(0xFF1565C0).withOpacity(0.15),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
f,
|
||||
|
|
@ -245,7 +266,11 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
|||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.kontrakan.deskripsi!,
|
||||
style: TextStyle(fontSize: 14, height: 1.6, color: Colors.grey[700]),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.6,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
|
|
@ -272,15 +297,20 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
|||
child: Row(
|
||||
children: [
|
||||
// WhatsApp Button
|
||||
if (widget.kontrakan.noWhatsapp != null && widget.kontrakan.noWhatsapp!.isNotEmpty)
|
||||
if (widget.kontrakan.noWhatsapp != null &&
|
||||
widget.kontrakan.noWhatsapp!.isNotEmpty)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
child: ElevatedButton(
|
||||
onPressed: () => _openWhatsApp(widget.kontrakan.noWhatsapp!),
|
||||
onPressed: () =>
|
||||
_openWhatsApp(widget.kontrakan.noWhatsapp!),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF25D366),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 18),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 14,
|
||||
horizontal: 18,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
|
|
@ -291,7 +321,13 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
|||
children: [
|
||||
Icon(Icons.chat_rounded, size: 20),
|
||||
SizedBox(width: 6),
|
||||
Text('WhatsApp', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
||||
Text(
|
||||
'WhatsApp',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -305,7 +341,9 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
|||
if (!authService.isAuthenticated) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Silakan login terlebih dahulu untuk booking'),
|
||||
content: Text(
|
||||
'Silakan login terlebih dahulu untuk booking',
|
||||
),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
|
|
@ -314,7 +352,9 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
|||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => BookingFormScreen(kontrakan: widget.kontrakan),
|
||||
builder: (_) => BookingFormScreen(
|
||||
kontrakan: widget.kontrakan,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -333,8 +373,13 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
|||
elevation: 0,
|
||||
),
|
||||
child: Text(
|
||||
widget.kontrakan.isAvailable ? 'Booking Sekarang' : 'Tidak Tersedia',
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
|
||||
widget.kontrakan.isAvailable
|
||||
? 'Booking Sekarang'
|
||||
: 'Tidak Tersedia',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -362,7 +407,11 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
|||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF333333), height: 1.3),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF333333),
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -508,14 +557,21 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
|||
} else if (!cleaned.startsWith('62')) {
|
||||
cleaned = '62$cleaned';
|
||||
}
|
||||
final message = Uri.encodeComponent('Halo, saya tertarik dengan kontrakan ${widget.kontrakan.nama}');
|
||||
final message = Uri.encodeComponent(
|
||||
'Halo, saya tertarik dengan kontrakan ${widget.kontrakan.nama}',
|
||||
);
|
||||
final url = Uri.parse('https://wa.me/$cleaned?text=$message');
|
||||
try {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Tidak dapat membuka WhatsApp. Pastikan WhatsApp terpasang.'), backgroundColor: Colors.red),
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Tidak dapat membuka WhatsApp. Pastikan WhatsApp terpasang.',
|
||||
),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
|
|||
|
||||
Future<void> _toggleFavorite() async {
|
||||
setState(() => _isFavLoading = true);
|
||||
final result = await _favoriteService.toggleLaundryFavorite(widget.laundry.id);
|
||||
final result = await _favoriteService.toggleLaundryFavorite(
|
||||
widget.laundry.id,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isFavLoading = false;
|
||||
|
|
@ -49,16 +51,23 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
|
|||
content: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_isFavorite ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
color: Colors.white, size: 20,
|
||||
_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],
|
||||
backgroundColor: _isFavorite
|
||||
? const Color(0xFF00897B)
|
||||
: Colors.grey[700],
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
margin: const EdgeInsets.all(16),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
|
|
@ -93,7 +102,9 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
|
|||
: IconButton(
|
||||
onPressed: _toggleFavorite,
|
||||
icon: Icon(
|
||||
_isFavorite ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
_isFavorite
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded,
|
||||
color: _isFavorite ? Colors.redAccent : Colors.white,
|
||||
size: 26,
|
||||
),
|
||||
|
|
@ -616,7 +627,11 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
|
|||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[500], fontWeight: FontWeight.w500),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
|
|
|
|||
|
|
@ -173,11 +173,11 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
|||
_noData = false;
|
||||
});
|
||||
|
||||
try {
|
||||
final endpoint = widget.category == 'kontrakan'
|
||||
? '/saw/calculate/kontrakan'
|
||||
: '/saw/calculate/laundry';
|
||||
final endpoint = widget.category == 'kontrakan'
|
||||
? '/saw/calculate/kontrakan'
|
||||
: '/saw/calculate/laundry';
|
||||
|
||||
try {
|
||||
final bodyParams = <String, dynamic>{};
|
||||
bodyParams['bobot_harga'] = _bobotHarga;
|
||||
bodyParams['bobot_jarak'] = _bobotJarak;
|
||||
|
|
@ -237,9 +237,11 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
|||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('SAW API Error: $e');
|
||||
debugPrint('URL: ${AppConfig.baseUrl}$endpoint');
|
||||
setState(() {
|
||||
_errorMessage =
|
||||
'Tidak dapat terhubung ke server. Periksa koneksi internet Anda dan coba lagi.';
|
||||
'Tidak dapat terhubung ke server (${AppConfig.baseUrl}). Periksa koneksi internet Anda dan coba lagi.\n\nDetail: $e';
|
||||
_hasCalculated = true;
|
||||
});
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -79,12 +79,18 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||
Future<void> _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);
|
||||
if (wasFav)
|
||||
_favKontrakanIds.add(id);
|
||||
else
|
||||
_favKontrakanIds.remove(id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -92,12 +98,18 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||
Future<void> _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);
|
||||
if (wasFav)
|
||||
_favLaundryIds.add(id);
|
||||
else
|
||||
_favLaundryIds.remove(id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -217,7 +229,11 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||
color: Colors.white.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(Icons.search_rounded, color: Colors.white, size: 22),
|
||||
child: const Icon(
|
||||
Icons.search_rounded,
|
||||
color: Colors.white,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
const Text(
|
||||
|
|
@ -236,7 +252,11 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.tune_rounded, color: Colors.white, size: 22),
|
||||
icon: const Icon(
|
||||
Icons.tune_rounded,
|
||||
color: Colors.white,
|
||||
size: 22,
|
||||
),
|
||||
onPressed: _showFilterDialog,
|
||||
),
|
||||
),
|
||||
|
|
@ -266,7 +286,13 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
boxShadow: _selectedCategory == 'Kontrakan'
|
||||
? [BoxShadow(color: Colors.black.withOpacity(0.08), blurRadius: 4, offset: const Offset(0, 2))]
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.08),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
|
|
@ -309,7 +335,13 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
boxShadow: _selectedCategory == 'Laundry'
|
||||
? [BoxShadow(color: Colors.black.withOpacity(0.08), blurRadius: 4, offset: const Offset(0, 2))]
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.08),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
|
|
@ -322,24 +354,24 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||
: Colors.white.withOpacity(0.85),
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Laundry',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _selectedCategory == 'Laundry'
|
||||
? const Color(0xFF00897B)
|
||||
: Colors.white.withOpacity(0.85),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Laundry',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _selectedCategory == 'Laundry'
|
||||
? const Color(0xFF00897B)
|
||||
: Colors.white.withOpacity(0.85),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 14),
|
||||
|
|
@ -351,11 +383,22 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||
hintText: _selectedCategory == 'Kontrakan'
|
||||
? 'Cari kontrakan...'
|
||||
: 'Cari laundry...',
|
||||
hintStyle: TextStyle(color: Colors.grey[400], fontSize: 14),
|
||||
prefixIcon: Icon(Icons.search_rounded, color: Colors.grey[400], size: 20),
|
||||
hintStyle: TextStyle(
|
||||
color: Colors.grey[400],
|
||||
fontSize: 14,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
Icons.search_rounded,
|
||||
color: Colors.grey[400],
|
||||
size: 20,
|
||||
),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: Icon(Icons.close_rounded, color: Colors.grey[400], size: 20),
|
||||
icon: Icon(
|
||||
Icons.close_rounded,
|
||||
color: Colors.grey[400],
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_filterKontrakan('');
|
||||
|
|
@ -591,7 +634,8 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||
bottomLeft: Radius.circular(16),
|
||||
),
|
||||
child:
|
||||
kontrakan.fotoUtama != null && kontrakan.fotoUtama!.isNotEmpty
|
||||
kontrakan.fotoUtama != null &&
|
||||
kontrakan.fotoUtama!.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: kontrakan.primaryPhoto,
|
||||
width: 120,
|
||||
|
|
@ -638,7 +682,10 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||
color: Colors.white.withOpacity(0.9),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black.withOpacity(0.1), blurRadius: 4),
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
|
|
@ -728,7 +775,11 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.bed_rounded, size: 14, color: Colors.grey[500]),
|
||||
Icon(
|
||||
Icons.bed_rounded,
|
||||
size: 14,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${kontrakan.jumlahKamar} Kamar',
|
||||
|
|
@ -738,7 +789,11 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Icon(Icons.near_me_rounded, size: 14, color: Colors.grey[500]),
|
||||
Icon(
|
||||
Icons.near_me_rounded,
|
||||
size: 14,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${kontrakan.jarakKampus.toStringAsFixed(1)} km',
|
||||
|
|
@ -830,7 +885,10 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||
color: Colors.white.withOpacity(0.9),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black.withOpacity(0.1), blurRadius: 4),
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
|
|
@ -903,7 +961,11 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Icon(Icons.near_me_rounded, size: 14, color: Colors.grey[500]),
|
||||
Icon(
|
||||
Icons.near_me_rounded,
|
||||
size: 14,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${laundry.jarak.toStringAsFixed(1)} km',
|
||||
|
|
|
|||
|
|
@ -54,10 +54,7 @@ class BookingService {
|
|||
try {
|
||||
// Validate payment proof is provided
|
||||
if (paymentProof == null) {
|
||||
return {
|
||||
'success': false,
|
||||
'message': 'Bukti pembayaran wajib diunggah',
|
||||
};
|
||||
return {'success': false, 'message': 'Bukti pembayaran wajib diunggah'};
|
||||
}
|
||||
|
||||
// Use multipart request with payment proof
|
||||
|
|
@ -70,7 +67,9 @@ class BookingService {
|
|||
}
|
||||
|
||||
request.fields['kontrakan_id'] = kontrakanId.toString();
|
||||
request.fields['tanggal_mulai'] = tanggalMulai.toIso8601String().split('T')[0];
|
||||
request.fields['tanggal_mulai'] = tanggalMulai.toIso8601String().split(
|
||||
'T',
|
||||
)[0];
|
||||
request.fields['durasi_bulan'] = durasiBulan.toString();
|
||||
if (catatan != null) request.fields['catatan'] = catatan;
|
||||
|
||||
|
|
@ -78,7 +77,9 @@ class BookingService {
|
|||
await http.MultipartFile.fromPath('payment_proof', paymentProof.path),
|
||||
);
|
||||
|
||||
final streamedResponse = await request.send().timeout(const Duration(seconds: 30));
|
||||
final streamedResponse = await request.send().timeout(
|
||||
const Duration(seconds: 30),
|
||||
);
|
||||
final response = await http.Response.fromStream(streamedResponse);
|
||||
final data = jsonDecode(response.body);
|
||||
|
||||
|
|
|
|||
|
|
@ -38,19 +38,11 @@ class FavoriteService {
|
|||
'laundry': data['data']['laundry'] ?? [],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
'success': false,
|
||||
'kontrakan': [],
|
||||
'laundry': [],
|
||||
};
|
||||
return {'success': false, 'kontrakan': [], 'laundry': []};
|
||||
}
|
||||
} catch (e) {
|
||||
// Error getting favorites silently
|
||||
return {
|
||||
'success': false,
|
||||
'kontrakan': [],
|
||||
'laundry': [],
|
||||
};
|
||||
return {'success': false, 'kontrakan': [], 'laundry': []};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,9 +31,7 @@ class KontrakanCard extends StatelessWidget {
|
|||
child: Card(
|
||||
elevation: 2,
|
||||
shadowColor: Colors.black.withOpacity(0.1),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
@ -93,7 +91,9 @@ class KontrakanCard extends StatelessWidget {
|
|||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(showRanking && ranking != null ? 0 : 16),
|
||||
top: Radius.circular(
|
||||
showRanking && ranking != null ? 0 : 16,
|
||||
),
|
||||
),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: kontrakan.primaryPhoto,
|
||||
|
|
@ -103,9 +103,7 @@ class KontrakanCard extends StatelessWidget {
|
|||
placeholder: (context, url) => Container(
|
||||
height: 180,
|
||||
color: Colors.grey[300],
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
height: 180,
|
||||
|
|
@ -123,9 +121,7 @@ class KontrakanCard extends StatelessWidget {
|
|||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: kontrakan.isAvailable
|
||||
? Colors.green
|
||||
: Colors.red,
|
||||
color: kontrakan.isAvailable ? Colors.green : Colors.red,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
|
|
@ -160,7 +156,11 @@ class KontrakanCard extends StatelessWidget {
|
|||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.location_on, size: 14, color: Colors.grey),
|
||||
const Icon(
|
||||
Icons.location_on,
|
||||
size: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
|
|
@ -242,10 +242,7 @@ class KontrakanCard extends StatelessWidget {
|
|||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${kontrakan.jarakKampus.toStringAsFixed(1)} km dari kampus',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[500]),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -31,9 +31,7 @@ class LaundryCard extends StatelessWidget {
|
|||
child: Card(
|
||||
elevation: 2,
|
||||
shadowColor: Colors.black.withOpacity(0.1),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
@ -93,7 +91,9 @@ class LaundryCard extends StatelessWidget {
|
|||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(showRanking && ranking != null ? 0 : 16),
|
||||
top: Radius.circular(
|
||||
showRanking && ranking != null ? 0 : 16,
|
||||
),
|
||||
),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: laundry.primaryPhoto,
|
||||
|
|
@ -103,9 +103,7 @@ class LaundryCard extends StatelessWidget {
|
|||
placeholder: (context, url) => Container(
|
||||
height: 180,
|
||||
color: Colors.grey[300],
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
height: 180,
|
||||
|
|
@ -160,7 +158,11 @@ class LaundryCard extends StatelessWidget {
|
|||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.location_on, size: 14, color: Colors.grey),
|
||||
const Icon(
|
||||
Icons.location_on,
|
||||
size: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
|
|
@ -252,11 +254,7 @@ class LaundryCard extends StatelessWidget {
|
|||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.schedule,
|
||||
size: 14,
|
||||
color: Colors.green,
|
||||
),
|
||||
const Icon(Icons.schedule, size: 14, color: Colors.green),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${laundry.jamBuka} - ${laundry.jamTutup}',
|
||||
|
|
|
|||
Loading…
Reference in New Issue