Fix price display format and improve UI/UX across all screens - Replace confusing K notation with proper Rp format with thousand separators - Add /bln and /kg suffix for clarity - Improve card shadows, icons, and sections for professional appearance - Create currency formatter utility

This commit is contained in:
micko samawa 2026-02-24 19:42:59 +07:00
parent d94e7db501
commit 7453d3b322
18 changed files with 416 additions and 259 deletions

View File

@ -7,8 +7,8 @@ class AppConfig {
// CATATAN: Untuk real device, ganti IP sesuai dengan IP komputer Anda // CATATAN: Untuk real device, ganti IP sesuai dengan IP komputer Anda
// Cek IP dengan: ipconfig (di Windows) atau ifconfig (di Linux/Mac) // Cek IP dengan: ipconfig (di Windows) atau ifconfig (di Linux/Mac)
static const String baseUrl = 'http://10.215.176.99:8000/api'; static const String baseUrl = 'http://192.168.18.16:8000/api';
static const String storageUrl = 'http://10.215.176.99:8000/storage'; static const String storageUrl = 'http://192.168.18.16:8000/storage';
// Timeouts // Timeouts
static const Duration connectionTimeout = Duration(seconds: 10); static const Duration connectionTimeout = Duration(seconds: 10);

View File

@ -198,11 +198,7 @@ class _LoginScreenState extends State<LoginScreen> {
} }
Future<void> _handleLogin() async { Future<void> _handleLogin() async {
print('=== LOGIN BUTTON CLICKED ===');
print('FormKey currentState: ${_formKey.currentState}');
if (_formKey.currentState == null) { if (_formKey.currentState == null) {
print('ERROR: FormKey currentState is null!');
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Error: Form not initialized')), const SnackBar(content: Text('Error: Form not initialized')),
); );
@ -210,13 +206,9 @@ class _LoginScreenState extends State<LoginScreen> {
} }
if (!_formKey.currentState!.validate()) { if (!_formKey.currentState!.validate()) {
print('Form validation failed');
return; return;
} }
print('Form validated, attempting login...');
print('Email: ${_emailController.text.trim()}');
setState(() => _isLoading = true); setState(() => _isLoading = true);
final result = await _authService.login( final result = await _authService.login(
@ -224,20 +216,16 @@ class _LoginScreenState extends State<LoginScreen> {
password: _passwordController.text, password: _passwordController.text,
); );
print('Login result: $result');
setState(() => _isLoading = false); setState(() => _isLoading = false);
if (!mounted) return; if (!mounted) return;
if (result['success']) { if (result['success']) {
print('Login successful, navigating to home...');
Navigator.pushReplacement( Navigator.pushReplacement(
context, context,
MaterialPageRoute(builder: (context) => const ImprovedHomeScreen()), MaterialPageRoute(builder: (context) => const ImprovedHomeScreen()),
); );
} else { } else {
print('Login failed: ${result['message']}');
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(result['message'] ?? 'Login gagal'), content: Text(result['message'] ?? 'Login gagal'),

View File

@ -349,13 +349,14 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
Widget _buildBookingList(List<Booking> bookings, bool isActive) { Widget _buildBookingList(List<Booking> bookings, bool isActive) {
if (bookings.isEmpty) { if (bookings.isEmpty) {
return Center( return RefreshIndicator(
child: Padding( onRefresh: _loadBookings,
padding: const EdgeInsets.all(32), child: ListView(
child: Column( physics: const AlwaysScrollableScrollPhysics(),
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Container( SizedBox(height: MediaQuery.of(context).size.height * 0.2),
Center(
child: Container(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF1565C0).withOpacity(0.06), color: const Color(0xFF1565C0).withOpacity(0.06),
@ -367,8 +368,10 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
color: const Color(0xFF1565C0).withOpacity(0.3), color: const Color(0xFF1565C0).withOpacity(0.3),
), ),
), ),
),
const SizedBox(height: 20), const SizedBox(height: 20),
Text( Center(
child: Text(
isActive ? 'Belum ada booking aktif' : 'Belum ada riwayat', isActive ? 'Belum ada booking aktif' : 'Belum ada riwayat',
style: const TextStyle( style: const TextStyle(
fontSize: 17, fontSize: 17,
@ -376,26 +379,32 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
color: Color(0xFF1A1A2E), color: Color(0xFF1A1A2E),
), ),
), ),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Center(
child: Text(
isActive isActive
? 'Booking kontrakan Anda akan muncul di sini' ? 'Booking kontrakan Anda akan muncul di sini'
: 'Riwayat booking sebelumnya akan muncul di sini', : 'Riwayat booking sebelumnya akan muncul di sini',
style: TextStyle(fontSize: 13, color: Colors.grey[500]), style: TextStyle(fontSize: 13, color: Colors.grey[500]),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
],
), ),
],
), ),
); );
} }
return ListView.builder( return RefreshIndicator(
onRefresh: _loadBookings,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
itemCount: bookings.length, itemCount: bookings.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return _buildBookingCard(bookings[index]); return _buildBookingCard(bookings[index]);
}, },
),
); );
} }

View File

@ -368,6 +368,8 @@ class _FavoritesScreenState extends State<FavoritesScreen>
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Row(
children: [
Text( Text(
kontrakan.formattedHarga, kontrakan.formattedHarga,
style: const TextStyle( style: const TextStyle(
@ -376,6 +378,16 @@ class _FavoritesScreenState extends State<FavoritesScreen>
color: Color(0xFF1565C0), color: Color(0xFF1565C0),
), ),
), ),
Text(
'/bln',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.grey[500],
),
),
],
),
const SizedBox(height: 6), const SizedBox(height: 6),
Row( Row(
children: [ children: [
@ -530,6 +542,8 @@ class _FavoritesScreenState extends State<FavoritesScreen>
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Row(
children: [
Text( Text(
laundry.formattedHarga, laundry.formattedHarga,
style: const TextStyle( style: const TextStyle(
@ -538,6 +552,16 @@ class _FavoritesScreenState extends State<FavoritesScreen>
color: Color(0xFF00897B), color: Color(0xFF00897B),
), ),
), ),
Text(
'/kg',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.grey[500],
),
),
],
),
const SizedBox(height: 6), const SizedBox(height: 6),
Row( Row(
children: [ children: [

View File

@ -1000,12 +1000,12 @@ class _HomeScreenState extends State<HomeScreen> {
} }
String _formatPrice(double price) { String _formatPrice(double price) {
if (price >= 1000000) { return price
return '${(price / 1000000).toStringAsFixed(1)}Jt'; .toStringAsFixed(0)
} else if (price >= 1000) { .replaceAllMapped(
return '${(price / 1000).toStringAsFixed(0)}K'; RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
} (Match m) => '${m[1]}.',
return price.toStringAsFixed(0); );
} }
Widget _buildBottomNav() { Widget _buildBottomNav() {

View File

@ -56,7 +56,7 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
_favLaundryIds = (ids['laundry'] ?? []).toSet(); _favLaundryIds = (ids['laundry'] ?? []).toSet();
}); });
} catch (e) { } catch (e) {
print('Error loading favorite ids: $e'); // Error loading favorite ids silently
} }
} }
@ -107,7 +107,7 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
final list = await _kontrakanService.getKontrakan(); final list = await _kontrakanService.getKontrakan();
setState(() => _kontrakanList = list.take(6).toList()); setState(() => _kontrakanList = list.take(6).toList());
} catch (e) { } catch (e) {
print('Error loading kontrakan: $e'); // Error loading kontrakan silently
} }
} }
@ -116,7 +116,7 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
final list = await _laundryService.getLaundry(); final list = await _laundryService.getLaundry();
setState(() => _laundryList = list.take(6).toList()); setState(() => _laundryList = list.take(6).toList());
} catch (e) { } catch (e) {
print('Error loading laundry: $e'); // Error loading laundry silently
} }
} }
@ -1004,6 +1004,9 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
Row( Row(
children: [ children: [
Expanded( Expanded(
child: Row(
children: [
Flexible(
child: Text( child: Text(
kontrakan.formattedHarga, kontrakan.formattedHarga,
style: const TextStyle( style: const TextStyle(
@ -1011,6 +1014,18 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Color(0xFF1565C0), color: Color(0xFF1565C0),
), ),
overflow: TextOverflow.ellipsis,
),
),
Text(
'/bln',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: Colors.grey[500],
),
),
],
), ),
), ),
Container( Container(
@ -1198,6 +1213,9 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
Row( Row(
children: [ children: [
Expanded( Expanded(
child: Row(
children: [
Flexible(
child: Text( child: Text(
laundry.formattedHarga, laundry.formattedHarga,
style: const TextStyle( style: const TextStyle(
@ -1205,6 +1223,18 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Color(0xFF00897B), color: Color(0xFF00897B),
), ),
overflow: TextOverflow.ellipsis,
),
),
Text(
'/kg',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: Colors.grey[500],
),
),
],
), ),
), ),
Container( Container(

View File

@ -145,18 +145,38 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
style: const TextStyle( style: const TextStyle(
fontSize: 22, fontSize: 22,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.black87, color: Color(0xFF1A1A2E),
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 10),
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: const Color(0xFF1565C0).withOpacity(0.08),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text( Text(
'${widget.kontrakan.formattedHarga}/bulan', widget.kontrakan.formattedHarga,
style: const TextStyle( style: const TextStyle(
fontSize: 20, fontSize: 22,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w800,
color: Color(0xFF1565C0), color: Color(0xFF1565C0),
), ),
), ),
Text(
' /bulan',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.grey[600],
),
),
],
),
),
const SizedBox(height: 16), const SizedBox(height: 16),
// Location // Location
@ -183,19 +203,31 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
const Text( const Text(
'Fasilitas', 'Fasilitas',
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 17,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w700,
color: Colors.black87, color: Color(0xFF1A1A2E),
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 10),
Wrap( Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: widget.kontrakan.fasilitasList.map((f) { children: widget.kontrakan.fasilitasList.map((f) {
return Chip( return Container(
label: Text(f), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
backgroundColor: Colors.blue[50], decoration: BoxDecoration(
color: const Color(0xFFE3F2FD),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFF1565C0).withOpacity(0.15)),
),
child: Text(
f,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Color(0xFF1565C0),
),
),
); );
}).toList(), }).toList(),
), ),
@ -205,15 +237,15 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
const Text( const Text(
'Deskripsi', 'Deskripsi',
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 17,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w700,
color: Colors.black87, color: Color(0xFF1A1A2E),
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
widget.kontrakan.deskripsi!, widget.kontrakan.deskripsi!,
style: const TextStyle(fontSize: 14, height: 1.5), style: TextStyle(fontSize: 14, height: 1.6, color: Colors.grey[700]),
), ),
], ],
@ -315,15 +347,22 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
Widget _buildInfoRow(IconData icon, String text) { Widget _buildInfoRow(IconData icon, String text) {
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.only(bottom: 10),
child: Row( child: Row(
children: [ children: [
Icon(icon, size: 20, color: Colors.grey[600]), Container(
const SizedBox(width: 8), padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: const Color(0xFF1565C0).withOpacity(0.08),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, size: 18, color: const Color(0xFF1565C0)),
),
const SizedBox(width: 12),
Expanded( Expanded(
child: Text( child: Text(
text, text,
style: const TextStyle(fontSize: 14, color: Colors.black87), style: const TextStyle(fontSize: 14, color: Color(0xFF333333), height: 1.3),
), ),
), ),
], ],

View File

@ -329,19 +329,19 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.1), color: statusColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(14),
border: Border.all(color: statusColor, width: 2), border: Border.all(color: statusColor.withOpacity(0.3), width: 1.5),
), ),
child: Row( child: Row(
children: [ children: [
Container( Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: statusColor, color: statusColor.withOpacity(0.15),
shape: BoxShape.circle, borderRadius: BorderRadius.circular(12),
), ),
child: const Icon(Icons.store, color: Colors.white, size: 24), child: Icon(Icons.store_rounded, color: statusColor, size: 24),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
@ -569,14 +569,21 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
children: [ children: [
Row( Row(
children: [ children: [
Icon(icon, color: const Color(0xFF00897B)), Container(
const SizedBox(width: 8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFF00897B).withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: const Color(0xFF00897B), size: 20),
),
const SizedBox(width: 10),
Text( Text(
title, title,
style: const TextStyle( style: const TextStyle(
fontSize: 18, fontSize: 17,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w700,
color: Colors.black87, color: Color(0xFF1A1A2E),
), ),
), ),
], ],
@ -590,11 +597,18 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
Widget _buildInfoRow(IconData icon, String label, String value) { Widget _buildInfoRow(IconData icon, String label, String value) {
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 14),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon(icon, size: 20, color: Colors.grey[600]), Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: const Color(0xFF00897B).withOpacity(0.08),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, size: 18, color: const Color(0xFF00897B)),
),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
@ -602,15 +616,15 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
children: [ children: [
Text( Text(
label, label,
style: TextStyle(fontSize: 13, color: Colors.grey[600]), style: TextStyle(fontSize: 12, color: Colors.grey[500], fontWeight: FontWeight.w500),
), ),
const SizedBox(height: 2), const SizedBox(height: 3),
Text( Text(
value, value,
style: const TextStyle( style: const TextStyle(
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Colors.black87, color: Color(0xFF1A1A2E),
), ),
), ),
], ],

View File

@ -239,7 +239,7 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
} catch (e) { } catch (e) {
setState(() { setState(() {
_errorMessage = _errorMessage =
'Tidak dapat terhubung ke server. Pastikan server Laravel aktif dan IP di app_config.dart benar.'; 'Tidak dapat terhubung ke server. Periksa koneksi internet Anda dan coba lagi.';
_hasCalculated = true; _hasCalculated = true;
}); });
} finally { } finally {

View File

@ -6,6 +6,7 @@ import '../services/kontrakan_service.dart';
import '../services/laundry_service.dart'; import '../services/laundry_service.dart';
import '../services/auth_service.dart'; import '../services/auth_service.dart';
import '../services/favorite_service.dart'; import '../services/favorite_service.dart';
import '../utils/currency_formatter.dart';
import 'kontrakan_detail_screen.dart'; import 'kontrakan_detail_screen.dart';
import 'laundry_detail_screen.dart'; import 'laundry_detail_screen.dart';
@ -71,7 +72,7 @@ class _SearchScreenState extends State<SearchScreen> {
}); });
} }
} catch (e) { } catch (e) {
print('Error loading favorite ids: $e'); // Error loading favorite ids silently
} }
} }
@ -434,13 +435,16 @@ class _SearchScreenState extends State<SearchScreen> {
Widget _buildKontrakanList() { Widget _buildKontrakanList() {
if (_filteredKontrakan.isEmpty) { if (_filteredKontrakan.isEmpty) {
return Center( return RefreshIndicator(
child: Column( onRefresh: _loadData,
mainAxisAlignment: MainAxisAlignment.center, child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [ children: [
SizedBox(height: MediaQuery.of(context).size.height * 0.2),
Icon(Icons.search_off, size: 80, color: Colors.grey[300]), Icon(Icons.search_off, size: 80, color: Colors.grey[300]),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Center(
child: Text(
'Tidak ada hasil', 'Tidak ada hasil',
style: TextStyle( style: TextStyle(
fontSize: 18, fontSize: 18,
@ -448,34 +452,44 @@ class _SearchScreenState extends State<SearchScreen> {
color: Colors.grey[600], color: Colors.grey[600],
), ),
), ),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Center(
child: Text(
'Coba kata kunci lain', 'Coba kata kunci lain',
style: TextStyle(fontSize: 14, color: Colors.grey[500]), style: TextStyle(fontSize: 14, color: Colors.grey[500]),
), ),
),
], ],
), ),
); );
} }
return ListView.builder( return RefreshIndicator(
onRefresh: _loadData,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
itemCount: _filteredKontrakan.length, itemCount: _filteredKontrakan.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return _buildKontrakanItem(_filteredKontrakan[index]); return _buildKontrakanItem(_filteredKontrakan[index]);
}, },
),
); );
} }
Widget _buildLaundryList() { Widget _buildLaundryList() {
if (_filteredLaundry.isEmpty) { if (_filteredLaundry.isEmpty) {
return Center( return RefreshIndicator(
child: Column( onRefresh: _loadData,
mainAxisAlignment: MainAxisAlignment.center, child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [ children: [
SizedBox(height: MediaQuery.of(context).size.height * 0.2),
Icon(Icons.search_off, size: 80, color: Colors.grey[300]), Icon(Icons.search_off, size: 80, color: Colors.grey[300]),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Center(
child: Text(
'Tidak ada hasil', 'Tidak ada hasil',
style: TextStyle( style: TextStyle(
fontSize: 18, fontSize: 18,
@ -483,22 +497,29 @@ class _SearchScreenState extends State<SearchScreen> {
color: Colors.grey[600], color: Colors.grey[600],
), ),
), ),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Center(
child: Text(
'Coba kata kunci lain', 'Coba kata kunci lain',
style: TextStyle(fontSize: 14, color: Colors.grey[500]), style: TextStyle(fontSize: 14, color: Colors.grey[500]),
), ),
),
], ],
), ),
); );
} }
return ListView.builder( return RefreshIndicator(
onRefresh: _loadData,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
itemCount: _filteredLaundry.length, itemCount: _filteredLaundry.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return _buildLaundryItem(_filteredLaundry[index]); return _buildLaundryItem(_filteredLaundry[index]);
}, },
),
); );
} }
@ -552,9 +573,9 @@ class _SearchScreenState extends State<SearchScreen> {
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withValues(alpha: 0.08), color: Colors.black.withValues(alpha: 0.06),
blurRadius: 10, blurRadius: 12,
offset: const Offset(0, 2), offset: const Offset(0, 3),
), ),
], ],
), ),
@ -663,16 +684,18 @@ class _SearchScreenState extends State<SearchScreen> {
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: kontrakan.status == 'available' color: kontrakan.status == 'available'
? Colors.green ? const Color(0xFFE8F5E9)
: Colors.orange, : const Color(0xFFFFF3E0),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(8),
), ),
child: Text( child: Text(
kontrakan.status == 'available' kontrakan.status == 'available'
? 'Tersedia' ? 'Tersedia'
: 'Penuh', : 'Penuh',
style: const TextStyle( style: TextStyle(
color: Colors.white, color: kontrakan.status == 'available'
? const Color(0xFF2E7D32)
: const Color(0xFFF57C00),
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
@ -702,49 +725,39 @@ class _SearchScreenState extends State<SearchScreen> {
), ),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 6),
Row( Row(
children: [ children: [
Icon(Icons.bed, size: 14, color: Colors.grey[600]), Icon(Icons.bed_rounded, size: 14, color: Colors.grey[500]),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
'${kontrakan.jumlahKamar} Kamar', '${kontrakan.jumlahKamar} Kamar',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: Colors.grey[700], color: Colors.grey[600],
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Icon(Icons.near_me, size: 14, color: Colors.grey[600]), Icon(Icons.near_me_rounded, size: 14, color: Colors.grey[500]),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
'${kontrakan.jarakKampus.toStringAsFixed(1)} km', '${kontrakan.jarakKampus.toStringAsFixed(1)} km',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: Colors.grey[700], color: Colors.grey[600],
), ),
), ),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 10),
Container( Text(
padding: const EdgeInsets.symmetric( '${kontrakan.formattedHarga}/bln',
vertical: 6,
horizontal: 10,
),
decoration: BoxDecoration(
color: const Color(0xFF1565C0).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Rp ${(kontrakan.harga / 1000).toStringAsFixed(0)}K/bln',
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 15,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w700,
color: Color(0xFF1565C0), color: Color(0xFF1565C0),
), ),
), ),
),
], ],
), ),
), ),
@ -873,53 +886,43 @@ class _SearchScreenState extends State<SearchScreen> {
), ),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 6),
Row( Row(
children: [ children: [
Icon( Icon(
Icons.access_time, Icons.schedule_rounded,
size: 14, size: 14,
color: Colors.grey[600], color: Colors.grey[500],
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
'${laundry.estimasiSelesai}jam', '${laundry.waktuProses} jam',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: Colors.grey[700], color: Colors.grey[600],
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Icon(Icons.near_me, size: 14, color: Colors.grey[600]), Icon(Icons.near_me_rounded, size: 14, color: Colors.grey[500]),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
'${laundry.jarak.toStringAsFixed(1)} km', '${laundry.jarak.toStringAsFixed(1)} km',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: Colors.grey[700], color: Colors.grey[600],
), ),
), ),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 10),
Container( Text(
padding: const EdgeInsets.symmetric( '${laundry.formattedHarga}/kg',
vertical: 6,
horizontal: 10,
),
decoration: BoxDecoration(
color: const Color(0xFF00897B).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Rp ${(laundry.hargaKiloan / 1000).toStringAsFixed(0)}K/kg',
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 15,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w700,
color: Color(0xFF00897B), color: Color(0xFF00897B),
), ),
), ),
),
], ],
), ),
), ),
@ -946,7 +949,7 @@ class _SearchScreenState extends State<SearchScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
'Rp ${(_priceRange.start / 1000000).toStringAsFixed(1)}jt - Rp ${(_priceRange.end / 1000000).toStringAsFixed(1)}jt', '${CurrencyFormatter.formatCompact(_priceRange.start)} - ${CurrencyFormatter.formatCompact(_priceRange.end)}',
style: const TextStyle( style: const TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@ -961,8 +964,8 @@ class _SearchScreenState extends State<SearchScreen> {
divisions: 20, divisions: 20,
activeColor: const Color(0xFF1565C0), activeColor: const Color(0xFF1565C0),
labels: RangeLabels( labels: RangeLabels(
'Rp ${(_priceRange.start / 1000).toStringAsFixed(0)}K', CurrencyFormatter.formatCompact(_priceRange.start),
'Rp ${(_priceRange.end / 1000).toStringAsFixed(0)}K', CurrencyFormatter.formatCompact(_priceRange.end),
), ),
onChanged: (values) { onChanged: (values) {
setDialogState(() => _priceRange = values); setDialogState(() => _priceRange = values);

View File

@ -38,7 +38,7 @@ class BookingService {
} }
return []; return [];
} catch (e) { } catch (e) {
print('Error getting booking history: $e'); // Error getting booking history silently
return []; return [];
} }
} }
@ -139,7 +139,7 @@ class BookingService {
} }
return null; return null;
} catch (e) { } catch (e) {
print('Error getting booking detail: $e'); // Error getting booking detail silently
return null; return null;
} }
} }

View File

@ -45,7 +45,7 @@ class FavoriteService {
}; };
} }
} catch (e) { } catch (e) {
print('Error getting favorites: $e'); // Error getting favorites silently
return { return {
'success': false, 'success': false,
'kontrakan': [], 'kontrakan': [],
@ -74,7 +74,7 @@ class FavoriteService {
'laundry': laundryList, 'laundry': laundryList,
}; };
} catch (e) { } catch (e) {
print('Error parsing favorites: $e'); // Error parsing favorites silently
return { return {
'success': false, 'success': false,
'kontrakan': <Kontrakan>[], 'kontrakan': <Kontrakan>[],
@ -191,7 +191,7 @@ class FavoriteService {
final kontrakanList = favorites['kontrakan'] as List<int>? ?? []; final kontrakanList = favorites['kontrakan'] as List<int>? ?? [];
return kontrakanList.contains(kontrakanId); return kontrakanList.contains(kontrakanId);
} catch (e) { } catch (e) {
print('Error checking favorite: $e'); // Error checking favorite silently
return false; return false;
} }
} }
@ -203,7 +203,7 @@ class FavoriteService {
final laundryList = favorites['laundry'] as List<int>? ?? []; final laundryList = favorites['laundry'] as List<int>? ?? [];
return laundryList.contains(laundryId); return laundryList.contains(laundryId);
} catch (e) { } catch (e) {
print('Error checking favorite: $e'); // Error checking favorite silently
return false; return false;
} }
} }

View File

@ -56,7 +56,7 @@ class KontrakanService {
} }
return []; return [];
} catch (e) { } catch (e) {
print('Error getting kontrakan: $e'); // Error getting kontrakan silently
return []; return [];
} }
} }
@ -77,7 +77,7 @@ class KontrakanService {
} }
return null; return null;
} catch (e) { } catch (e) {
print('Error getting kontrakan detail: $e'); // Error getting kontrakan detail silently
return null; return null;
} }
} }

View File

@ -27,9 +27,6 @@ class LaundryService {
.get(Uri.parse('${AppConfig.baseUrl}/laundry'), headers: _headers) .get(Uri.parse('${AppConfig.baseUrl}/laundry'), headers: _headers)
.timeout(AppConfig.connectionTimeout); .timeout(AppConfig.connectionTimeout);
print('Laundry API Response: ${response.statusCode}');
print('Laundry API Body: ${response.body}');
if (response.statusCode == 200) { if (response.statusCode == 200) {
final data = jsonDecode(response.body); final data = jsonDecode(response.body);
if (data['success'] == true) { if (data['success'] == true) {
@ -39,7 +36,7 @@ class LaundryService {
} }
return []; return [];
} catch (e) { } catch (e) {
print('Error getting laundry: $e'); // Error getting laundry silently
return []; return [];
} }
} }
@ -59,7 +56,7 @@ class LaundryService {
} }
return null; return null;
} catch (e) { } catch (e) {
print('Error getting laundry detail: $e'); // Error getting laundry detail silently
return null; return null;
} }
} }

View File

@ -38,7 +38,7 @@ class LocationService {
return position; return position;
} catch (e) { } catch (e) {
print('Error getting location: $e'); // Error getting location silently
return null; return null;
} }
} }

View File

@ -0,0 +1,45 @@
/// Utility class for formatting Indonesian Rupiah currency
class CurrencyFormatter {
/// Format a number to full Rupiah format: "Rp 8.500.000"
static String formatRupiah(double amount) {
final formatted = amount
.toStringAsFixed(0)
.replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
(Match m) => '${m[1]}.',
);
return 'Rp $formatted';
}
/// Format to compact readable format:
/// - < 1000: "Rp 500"
/// - 1000-999999: "Rp 5rb" or "Rp 50rb"
/// - 1000000+: "Rp 1jt" or "Rp 8,5jt"
static String formatCompact(double amount) {
if (amount >= 1000000) {
final juta = amount / 1000000;
if (juta == juta.roundToDouble()) {
return 'Rp ${juta.toStringAsFixed(0)}jt';
}
return 'Rp ${juta.toStringAsFixed(1).replaceAll('.0', '')}jt';
} else if (amount >= 1000) {
final ribu = amount / 1000;
if (ribu == ribu.roundToDouble()) {
return 'Rp ${ribu.toStringAsFixed(0)}rb';
}
return 'Rp ${ribu.toStringAsFixed(1).replaceAll('.0', '')}rb';
}
return 'Rp ${amount.toStringAsFixed(0)}';
}
/// Format for price badge on cards - clear and unambiguous
/// e.g. "Rp 8.500.000" for millions, "Rp 5.000" for thousands
static String formatCardPrice(double amount) {
return formatRupiah(amount);
}
/// Format for filter slider labels - compact
static String formatSlider(double amount) {
return formatCompact(amount);
}
}

View File

@ -29,10 +29,12 @@ class KontrakanCard extends StatelessWidget {
); );
}, },
child: Card( child: Card(
elevation: 3, elevation: 2,
shadowColor: Colors.black.withOpacity(0.1),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
clipBehavior: Clip.antiAlias,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -177,8 +179,8 @@ class KontrakanCard extends StatelessWidget {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Column( Flexible(
crossAxisAlignment: CrossAxisAlignment.start, child: Row(
children: [ children: [
Text( Text(
kontrakan.formattedHarga, kontrakan.formattedHarga,
@ -188,15 +190,17 @@ class KontrakanCard extends StatelessWidget {
color: Color(0xFF1565C0), color: Color(0xFF1565C0),
), ),
), ),
const Text( Text(
'/bulan', '/bln',
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 12,
color: Colors.grey, color: Colors.grey[500],
fontWeight: FontWeight.w500,
), ),
), ),
], ],
), ),
),
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 10, horizontal: 10,
@ -227,20 +231,20 @@ class KontrakanCard extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 10),
Row( Row(
children: [ children: [
const Icon( const Icon(
Icons.directions_walk, Icons.near_me_rounded,
size: 14, size: 14,
color: Colors.orange, color: Colors.orange,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
'${kontrakan.jarakKampus.toStringAsFixed(1)} km dari kampus', '${kontrakan.jarakKampus.toStringAsFixed(1)} km dari kampus',
style: const TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
color: Colors.grey, color: Colors.grey[500],
), ),
), ),
], ],

View File

@ -29,10 +29,12 @@ class LaundryCard extends StatelessWidget {
); );
}, },
child: Card( child: Card(
elevation: 3, elevation: 2,
shadowColor: Colors.black.withOpacity(0.1),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
clipBehavior: Clip.antiAlias,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -177,8 +179,8 @@ class LaundryCard extends StatelessWidget {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Column( Flexible(
crossAxisAlignment: CrossAxisAlignment.start, child: Row(
children: [ children: [
Text( Text(
laundry.formattedHarga, laundry.formattedHarga,
@ -188,15 +190,17 @@ class LaundryCard extends StatelessWidget {
color: Color(0xFF00897B), color: Color(0xFF00897B),
), ),
), ),
const Text( Text(
'/kg', '/kg',
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 12,
color: Colors.grey, color: Colors.grey[500],
fontWeight: FontWeight.w500,
), ),
), ),
], ],
), ),
),
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 10, horizontal: 10,