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:
micko samawa 2026-02-26 00:18:36 +07:00
parent 7453d3b322
commit 79305d29c4
15 changed files with 693 additions and 291 deletions

View File

@ -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"

View File

@ -7,8 +7,8 @@ class AppConfig {
// CATATAN: Untuk real device, ganti IP sesuai dengan IP komputer Anda
// Cek IP dengan: ipconfig (di Windows) atau ifconfig (di Linux/Mac)
static const String baseUrl = 'http://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);

View File

@ -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(

View File

@ -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}';
}

View File

@ -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,
),
),
],
),

View File

@ -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,
),
],
),
),

View File

@ -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),

View File

@ -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,
),
);
}
}

View File

@ -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(

View File

@ -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 {

View File

@ -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',

View File

@ -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);

View File

@ -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': []};
}
}

View File

@ -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]),
),
],
),

View File

@ -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}',