feat: implement booking system and SAW improvements for mobile

- Add BookingFormScreen with date picker, duration selection, and cost calculation
- Wire up booking button in kontrakan detail screen with auth check
- Add auto-balance bobot system ensuring total always equals 100 percent
- Replace preset profiles with jenis_layanan selection for laundry SAW
- Fix kontrakan status display (tersedia/available) in cards
- Add isAvailable helper in Kontrakan model
- Add jenis_layanan parameter to API SAWController
- Update getNilaiLaundry to filter by specific service type
- Add intl package for date formatting in booking form
This commit is contained in:
micko samawa 2026-02-20 22:57:21 +07:00
parent fd956653ea
commit 5fe31899db
11 changed files with 1942 additions and 928 deletions

View File

@ -45,5 +45,9 @@
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Aplikasi ini membutuhkan akses lokasi Anda untuk menghitung jarak ke laundry terdekat.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Aplikasi ini membutuhkan akses lokasi Anda untuk menghitung jarak ke laundry terdekat.</string>
</dict>
</plist>

View File

@ -12,6 +12,8 @@ class Kontrakan {
final List<Galeri> galeri;
final double? avgRating;
final int? totalReviews;
final double? latitude;
final double? longitude;
Kontrakan({
required this.id,
@ -27,6 +29,8 @@ class Kontrakan {
this.galeri = const [],
this.avgRating,
this.totalReviews,
this.latitude,
this.longitude,
});
factory Kontrakan.fromJson(Map<String, dynamic> json) {
@ -57,9 +61,18 @@ class Kontrakan {
? double.tryParse(json['avg_rating'].toString())
: null,
totalReviews: json['total_reviews'],
latitude: json['latitude'] != null
? double.tryParse(json['latitude'].toString())
: null,
longitude: json['longitude'] != null
? double.tryParse(json['longitude'].toString())
: null,
);
}
// Check if kontrakan is available
bool get isAvailable => status == 'available' || status == 'tersedia';
// Get primary photo URL
String get primaryPhoto {
if (galeri.isNotEmpty) {

View File

@ -0,0 +1,534 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../models/kontrakan.dart';
import '../services/booking_service.dart';
import '../services/auth_service.dart';
class BookingFormScreen extends StatefulWidget {
final Kontrakan kontrakan;
const BookingFormScreen({super.key, required this.kontrakan});
@override
State<BookingFormScreen> createState() => _BookingFormScreenState();
}
class _BookingFormScreenState extends State<BookingFormScreen> {
final _bookingService = BookingService();
final _authService = AuthService();
final _catatanController = TextEditingController();
DateTime? _tanggalMulai;
int _durasiBulan = 1;
bool _isSubmitting = false;
final _currencyFormat = NumberFormat.currency(
locale: 'id_ID',
symbol: 'Rp ',
decimalDigits: 0,
);
double get _totalBiaya => widget.kontrakan.harga * _durasiBulan;
@override
void dispose() {
_catatanController.dispose();
super.dispose();
}
Future<void> _selectDate() async {
final now = DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: _tanggalMulai ?? now.add(const Duration(days: 1)),
firstDate: now.add(const Duration(days: 1)),
lastDate: now.add(const Duration(days: 365)),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: const ColorScheme.light(
primary: Color(0xFF667eea),
onPrimary: Colors.white,
surface: Colors.white,
onSurface: Colors.black87,
),
),
child: child!,
);
},
);
if (picked != null) {
setState(() => _tanggalMulai = picked);
}
}
Future<void> _submitBooking() async {
if (_tanggalMulai == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Pilih tanggal mulai terlebih dahulu'),
backgroundColor: Colors.red,
),
);
return;
}
if (!_authService.isAuthenticated) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Anda harus login terlebih dahulu'),
backgroundColor: Colors.red,
),
);
return;
}
// Confirmation dialog
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: const Row(
children: [
Icon(Icons.bookmark_add, color: Color(0xFF667eea)),
SizedBox(width: 8),
Text('Konfirmasi Booking'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildConfirmRow('Kontrakan', widget.kontrakan.nama),
const SizedBox(height: 8),
_buildConfirmRow('Tanggal Mulai', DateFormat('dd MMMM yyyy', 'id_ID').format(_tanggalMulai!)),
const SizedBox(height: 8),
_buildConfirmRow('Durasi', '$_durasiBulan bulan'),
const SizedBox(height: 8),
_buildConfirmRow('Total Biaya', _currencyFormat.format(_totalBiaya)),
if (_catatanController.text.isNotEmpty) ...[
const SizedBox(height: 8),
_buildConfirmRow('Catatan', _catatanController.text),
],
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Batal'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF667eea),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text('Konfirmasi'),
),
],
),
);
if (confirmed != true) return;
setState(() => _isSubmitting = true);
final result = await _bookingService.createBooking(
kontrakanId: widget.kontrakan.id,
tanggalMulai: _tanggalMulai!,
durasiBulan: _durasiBulan,
catatan: _catatanController.text.isNotEmpty ? _catatanController.text : null,
);
setState(() => _isSubmitting = false);
if (!mounted) return;
if (result['success'] == true) {
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(Icons.check_circle, color: Colors.green, size: 64),
),
const SizedBox(height: 16),
const Text(
'Booking Berhasil!',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'Booking Anda sedang menunggu konfirmasi dari pemilik kontrakan.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
),
const SizedBox(height: 8),
],
),
actions: [
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
Navigator.pop(ctx); // Close dialog
Navigator.pop(context, true); // Go back to detail with result
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF667eea),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text('OK'),
),
),
],
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result['message'] ?? 'Gagal membuat booking'),
backgroundColor: Colors.red,
),
);
}
}
Widget _buildConfirmRow(String label, String value) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: Text(label, style: TextStyle(fontSize: 13, color: Colors.grey[600])),
),
Expanded(
child: Text(value, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
),
],
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: AppBar(
title: const Text('Form Booking'),
backgroundColor: const Color(0xFF667eea),
foregroundColor: Colors.white,
elevation: 0,
),
body: SingleChildScrollView(
child: Column(
children: [
// Kontrakan Info Header
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF667eea), Color(0xFF764ba2)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.kontrakan.nama,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 4),
Row(
children: [
const Icon(Icons.location_on, size: 16, color: Colors.white70),
const SizedBox(width: 4),
Expanded(
child: Text(
widget.kontrakan.alamat,
style: const TextStyle(fontSize: 13, color: Colors.white70),
),
),
],
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
),
child: Text(
'${widget.kontrakan.formattedHarga}/bulan',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Tanggal Mulai
_buildFormCard(
icon: Icons.calendar_today,
title: 'Tanggal Mulai Sewa',
subtitle: 'Pilih tanggal mulai menempati kontrakan',
child: InkWell(
onTap: _selectDate,
borderRadius: BorderRadius.circular(12),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _tanggalMulai != null ? const Color(0xFF667eea) : Colors.grey[300]!),
),
child: Row(
children: [
Icon(
Icons.date_range,
color: _tanggalMulai != null ? const Color(0xFF667eea) : Colors.grey[400],
),
const SizedBox(width: 12),
Text(
_tanggalMulai != null
? DateFormat('dd MMMM yyyy', 'id_ID').format(_tanggalMulai!)
: 'Pilih tanggal...',
style: TextStyle(
fontSize: 15,
color: _tanggalMulai != null ? Colors.black87 : Colors.grey[500],
fontWeight: _tanggalMulai != null ? FontWeight.w600 : FontWeight.normal,
),
),
],
),
),
),
),
const SizedBox(height: 16),
// Durasi
_buildFormCard(
icon: Icons.access_time,
title: 'Durasi Sewa',
subtitle: 'Tentukan berapa bulan Anda ingin menyewa',
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF667eea).withOpacity(0.3)),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: _durasiBulan,
isExpanded: true,
icon: const Icon(Icons.arrow_drop_down, color: Color(0xFF667eea)),
style: const TextStyle(fontSize: 15, color: Colors.black87, fontWeight: FontWeight.w600),
items: List.generate(12, (i) => i + 1).map((bulan) {
return DropdownMenuItem<int>(
value: bulan,
child: Text('$bulan bulan'),
);
}).toList(),
onChanged: (val) => setState(() => _durasiBulan = val!),
),
),
),
),
const SizedBox(height: 16),
// Catatan
_buildFormCard(
icon: Icons.note_alt_outlined,
title: 'Catatan (Opsional)',
subtitle: 'Tambahkan catatan untuk pemilik kontrakan',
child: TextField(
controller: _catatanController,
maxLines: 3,
decoration: InputDecoration(
hintText: 'Contoh: Saya mahasiswa Polije semester 4...',
hintStyle: TextStyle(fontSize: 13, color: Colors.grey[400]),
filled: true,
fillColor: Colors.grey[100],
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey[300]!),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey[300]!),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFF667eea)),
),
contentPadding: const EdgeInsets.all(14),
),
),
),
const SizedBox(height: 16),
// Ringkasan Biaya
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [const Color(0xFF667eea).withOpacity(0.05), Colors.white],
),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF667eea).withOpacity(0.2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.receipt_long, size: 20, color: const Color(0xFF667eea)),
const SizedBox(width: 8),
Text('Ringkasan Biaya', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Colors.grey[800])),
],
),
const SizedBox(height: 12),
_buildBiayaRow('Harga per bulan', widget.kontrakan.formattedHarga),
const SizedBox(height: 6),
_buildBiayaRow('Durasi sewa', '$_durasiBulan bulan'),
const Divider(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Total Biaya', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
Text(
_currencyFormat.format(_totalBiaya),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF667eea),
),
),
],
),
],
),
),
const SizedBox(height: 24),
// Submit Button
SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: _isSubmitting ? null : _submitBooking,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF667eea),
foregroundColor: Colors.white,
disabledBackgroundColor: Colors.grey[400],
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 3,
),
child: _isSubmitting
? const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)),
SizedBox(width: 12),
Text('Memproses...', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
],
)
: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.bookmark_add, size: 22),
SizedBox(width: 8),
Text('Booking Sekarang', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
],
),
),
),
const SizedBox(height: 16),
],
),
),
],
),
),
);
}
Widget _buildFormCard({
required IconData icon,
required String title,
required String subtitle,
required Widget child,
}) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: Colors.grey.withOpacity(0.08), blurRadius: 8, offset: const Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 20, color: const Color(0xFF667eea)),
const SizedBox(width: 8),
Text(title, style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Colors.grey[800])),
],
),
const SizedBox(height: 4),
Text(subtitle, style: TextStyle(fontSize: 12, color: Colors.grey[500])),
const SizedBox(height: 12),
child,
],
),
);
}
Widget _buildBiayaRow(String label, String value) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: TextStyle(fontSize: 14, color: Colors.grey[600])),
Text(value, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
],
);
}
}

View File

@ -1,12 +1,26 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/kontrakan.dart';
import '../services/location_service.dart';
import '../services/auth_service.dart';
import 'booking_form_screen.dart';
class KontrakanDetailScreen extends StatelessWidget {
class KontrakanDetailScreen extends StatefulWidget {
final Kontrakan kontrakan;
const KontrakanDetailScreen({super.key, required this.kontrakan});
@override
State<KontrakanDetailScreen> createState() => _KontrakanDetailScreenState();
}
class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
double? userLat;
double? userLng;
double? distance;
bool isLoadingLocation = false;
String? locationError;
@override
Widget build(BuildContext context) {
return Scaffold(
@ -24,13 +38,13 @@ class KontrakanDetailScreen extends StatelessWidget {
SizedBox(
height: 250,
child: PageView.builder(
itemCount: kontrakan.galeri.isEmpty
itemCount: widget.kontrakan.galeri.isEmpty
? 1
: kontrakan.galeri.length,
: widget.kontrakan.galeri.length,
itemBuilder: (context, index) {
final imageUrl = kontrakan.galeri.isEmpty
? kontrakan.primaryPhoto
: kontrakan.galeri[index].foto;
final imageUrl = widget.kontrakan.galeri.isEmpty
? widget.kontrakan.primaryPhoto
: widget.kontrakan.galeri[index].foto;
return CachedNetworkImage(
imageUrl: imageUrl,
@ -56,7 +70,7 @@ class KontrakanDetailScreen extends StatelessWidget {
children: [
// Title & Price
Text(
kontrakan.nama,
widget.kontrakan.nama,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
@ -65,7 +79,7 @@ class KontrakanDetailScreen extends StatelessWidget {
),
const SizedBox(height: 8),
Text(
'${kontrakan.formattedHarga}/bulan',
'${widget.kontrakan.formattedHarga}/bulan',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
@ -75,12 +89,18 @@ class KontrakanDetailScreen extends StatelessWidget {
const SizedBox(height: 16),
// Location
_buildInfoRow(Icons.location_on, kontrakan.alamat),
_buildInfoRow(Icons.location_on, widget.kontrakan.alamat),
_buildInfoRow(
Icons.directions_walk,
'${kontrakan.jarakKampus} km dari kampus',
'${widget.kontrakan.jarakKampus} km dari kampus',
),
_buildInfoRow(Icons.bed, '${kontrakan.jumlahKamar} Kamar'),
_buildInfoRow(Icons.bed, '${widget.kontrakan.jumlahKamar} Kamar'),
const SizedBox(height: 20),
// Location Detection Card
if (widget.kontrakan.latitude != null && widget.kontrakan.longitude != null)
_buildLocationCard(),
const SizedBox(height: 20),
@ -97,7 +117,7 @@ class KontrakanDetailScreen extends StatelessWidget {
Wrap(
spacing: 8,
runSpacing: 8,
children: kontrakan.fasilitasList.map((f) {
children: widget.kontrakan.fasilitasList.map((f) {
return Chip(
label: Text(f),
backgroundColor: Colors.blue[50],
@ -105,7 +125,7 @@ class KontrakanDetailScreen extends StatelessWidget {
}).toList(),
),
if (kontrakan.deskripsi != null) ...[
if (widget.kontrakan.deskripsi != null) ...[
const SizedBox(height: 20),
const Text(
'Deskripsi',
@ -117,7 +137,7 @@ class KontrakanDetailScreen extends StatelessWidget {
),
const SizedBox(height: 8),
Text(
kontrakan.deskripsi!,
widget.kontrakan.deskripsi!,
style: const TextStyle(fontSize: 14, height: 1.5),
),
],
@ -135,7 +155,7 @@ class KontrakanDetailScreen extends StatelessWidget {
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, -2),
),
@ -143,23 +163,41 @@ class KontrakanDetailScreen extends StatelessWidget {
),
child: SafeArea(
child: ElevatedButton(
onPressed: () {
// TODO: Navigate to booking screen
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Fitur booking segera hadir')),
);
},
onPressed: widget.kontrakan.isAvailable
? () {
final authService = AuthService();
if (!authService.isAuthenticated) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Silakan login terlebih dahulu untuk booking'),
backgroundColor: Colors.red,
),
);
return;
}
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => BookingFormScreen(kontrakan: widget.kontrakan),
),
);
}
: null,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1565C0),
backgroundColor: widget.kontrakan.isAvailable
? const Color(0xFF667eea)
: Colors.grey,
foregroundColor: Colors.white,
disabledBackgroundColor: Colors.grey[400],
disabledForegroundColor: Colors.white70,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text(
'Booking Sekarang',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
child: Text(
widget.kontrakan.isAvailable ? 'Booking Sekarang' : 'Kontrakan Tidak Tersedia',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
),
),
@ -184,4 +222,191 @@ class KontrakanDetailScreen extends StatelessWidget {
),
);
}
Widget _buildLocationCard() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.purple.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.purple.withValues(alpha: 0.3),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(
Icons.location_on,
color: Colors.purple,
size: 24,
),
const SizedBox(width: 12),
Text(
'Deteksi Lokasi Saya',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
],
),
if (isLoadingLocation)
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.purple),
),
),
],
),
const SizedBox(height: 12),
if (distance != null) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.check_circle, color: Colors.green, size: 20),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Jarak dari Lokasi Saya',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
Text(
distance! < 1
? '${(distance! * 1000).toStringAsFixed(0)} m'
: '${distance!.toStringAsFixed(2)} km',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
],
),
],
),
),
] else if (locationError != null) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.error, color: Colors.red, size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
locationError!,
style: const TextStyle(
fontSize: 13,
color: Colors.red,
),
),
),
],
),
),
],
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
height: 44,
child: ElevatedButton.icon(
onPressed: isLoadingLocation ? null : _detectLocation,
icon: Icon(
isLoadingLocation ? Icons.hourglass_bottom : Icons.my_location,
size: 20,
),
label: Text(
isLoadingLocation ? 'Mendeteksi...' : 'Deteksi Lokasi Saya',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.purple,
foregroundColor: Colors.white,
disabledBackgroundColor: Colors.grey,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
),
);
}
Future<void> _detectLocation() async {
setState(() {
isLoadingLocation = true;
locationError = null;
});
try {
final locationService = LocationService();
final isEnabled = await locationService.isLocationServiceEnabled();
if (!isEnabled) {
setState(() {
locationError = 'Layanan lokasi tidak aktif';
isLoadingLocation = false;
});
return;
}
final position = await locationService.getCurrentLocation();
if (position != null) {
final dist = LocationService.calculateDistance(
position.latitude,
position.longitude,
widget.kontrakan.latitude!,
widget.kontrakan.longitude!,
);
setState(() {
userLat = position.latitude;
userLng = position.longitude;
distance = dist;
isLoadingLocation = false;
});
} else {
setState(() {
locationError = 'Gagal mendapatkan lokasi Anda';
isLoadingLocation = false;
});
}
} catch (e) {
setState(() {
locationError = 'Error: ${e.toString()}';
isLoadingLocation = false;
});
}
}
}

View File

@ -1,12 +1,24 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/laundry.dart';
import '../services/location_service.dart';
class LaundryDetailScreen extends StatelessWidget {
class LaundryDetailScreen extends StatefulWidget {
final Laundry laundry;
const LaundryDetailScreen({super.key, required this.laundry});
@override
State<LaundryDetailScreen> createState() => _LaundryDetailScreenState();
}
class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
double? userLat;
double? userLng;
double? distance;
bool isLoadingLocation = false;
String? locationError;
@override
Widget build(BuildContext context) {
return Scaffold(
@ -61,7 +73,7 @@ class LaundryDetailScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
laundry.nama,
widget.laundry.nama,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
@ -78,8 +90,7 @@ class LaundryDetailScreen extends StatelessWidget {
),
const SizedBox(width: 6),
Text(
laundry.rating?.toStringAsFixed(1) ??
'N/A',
widget.laundry.rating.toStringAsFixed(1),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
@ -108,10 +119,15 @@ class LaundryDetailScreen extends StatelessWidget {
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildListDelegate([
// Status Card
// Status Card
_buildStatusCard(),
const SizedBox(height: 16),
// Location Detection Card
if (widget.laundry.latitude != null && widget.laundry.longitude != null)
_buildLocationCard(),
const SizedBox(height: 16),
// Info Section
_buildSection(
icon: Icons.info_outline,
@ -121,17 +137,17 @@ class LaundryDetailScreen extends StatelessWidget {
_buildInfoRow(
Icons.location_on,
'Alamat',
laundry.alamat,
widget.laundry.alamat,
),
_buildInfoRow(
Icons.access_time,
'Jam Operasional',
'${laundry.jamBuka} - ${laundry.jamTutup}',
'${widget.laundry.jamBuka} - ${widget.laundry.jamTutup}',
),
_buildInfoRow(
Icons.schedule,
'Estimasi Selesai',
'${laundry.estimasiSelesai} jam',
'${widget.laundry.estimasiSelesai} jam',
),
],
),
@ -146,14 +162,14 @@ class LaundryDetailScreen extends StatelessWidget {
children: [
_buildPriceCard(
'Laundry Kiloan',
laundry.formattedHargaKiloan,
widget.laundry.formattedHargaKiloan,
Icons.scale,
const Color(0xFF00BCD4),
),
const SizedBox(height: 12),
_buildPriceCard(
'Laundry Satuan',
laundry.formattedHargaSatuan,
widget.laundry.formattedHargaSatuan,
Icons.checkroom,
Colors.purple,
),
@ -163,12 +179,12 @@ class LaundryDetailScreen extends StatelessWidget {
const SizedBox(height: 24),
// Action Buttons
if (laundry.noWhatsapp != null) ...[
if (widget.laundry.noWhatsapp != null) ...[
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton.icon(
onPressed: () => _launchWhatsApp(laundry.noWhatsapp!),
onPressed: () => _launchWhatsApp(widget.laundry.noWhatsapp!),
icon: const Icon(Icons.message, size: 24),
label: const Text(
'Hubungi via WhatsApp',
@ -189,13 +205,13 @@ class LaundryDetailScreen extends StatelessWidget {
const SizedBox(height: 12),
],
if (laundry.latitude != null && laundry.longitude != null)
if (widget.laundry.latitude != null && widget.laundry.longitude != null)
SizedBox(
width: double.infinity,
height: 50,
child: OutlinedButton.icon(
onPressed: () =>
_launchMaps(laundry.latitude!, laundry.longitude!),
_launchMaps(widget.laundry.latitude!, widget.laundry.longitude!),
icon: const Icon(Icons.map, size: 24),
label: const Text(
'Lihat di Maps',
@ -227,8 +243,8 @@ class LaundryDetailScreen extends StatelessWidget {
}
Widget _buildStatusCard() {
Color statusColor = laundry.status == 'buka' ? Colors.green : Colors.red;
if (laundry.status == 'buka' && !laundry.isOpen) {
Color statusColor = widget.laundry.status == 'buka' ? Colors.green : Colors.red;
if (widget.laundry.status == 'buka' && !widget.laundry.isOpen) {
statusColor = Colors.orange;
}
@ -260,7 +276,7 @@ class LaundryDetailScreen extends StatelessWidget {
),
const SizedBox(height: 4),
Text(
laundry.statusText,
widget.laundry.statusText,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
@ -275,6 +291,193 @@ class LaundryDetailScreen extends StatelessWidget {
);
}
Widget _buildLocationCard() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.purple.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.purple.withValues(alpha: 0.3),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(
Icons.location_on,
color: Colors.purple,
size: 24,
),
const SizedBox(width: 12),
Text(
'Deteksi Lokasi Saya',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
],
),
if (isLoadingLocation)
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.purple),
),
),
],
),
const SizedBox(height: 12),
if (distance != null) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.check_circle, color: Colors.green, size: 20),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Jarak dari Lokasi Saya',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
Text(
distance! < 1
? '${(distance! * 1000).toStringAsFixed(0)} m'
: '${distance!.toStringAsFixed(2)} km',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
],
),
],
),
),
] else if (locationError != null) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.error, color: Colors.red, size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
locationError!,
style: const TextStyle(
fontSize: 13,
color: Colors.red,
),
),
),
],
),
),
],
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
height: 44,
child: ElevatedButton.icon(
onPressed: isLoadingLocation ? null : _detectLocation,
icon: Icon(
isLoadingLocation ? Icons.hourglass_bottom : Icons.my_location,
size: 20,
),
label: Text(
isLoadingLocation ? 'Mendeteksi...' : 'Deteksi Lokasi Saya',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.purple,
foregroundColor: Colors.white,
disabledBackgroundColor: Colors.grey,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
),
);
}
Future<void> _detectLocation() async {
setState(() {
isLoadingLocation = true;
locationError = null;
});
try {
final locationService = LocationService();
final isEnabled = await locationService.isLocationServiceEnabled();
if (!isEnabled) {
setState(() {
locationError = 'Layanan lokasi tidak aktif';
isLoadingLocation = false;
});
return;
}
final position = await locationService.getCurrentLocation();
if (position != null) {
final dist = LocationService.calculateDistance(
position.latitude,
position.longitude,
widget.laundry.latitude!,
widget.laundry.longitude!,
);
setState(() {
userLat = position.latitude;
userLng = position.longitude;
distance = dist;
isLoadingLocation = false;
});
} else {
setState(() {
locationError = 'Gagal mendapatkan lokasi Anda';
isLoadingLocation = false;
});
}
} catch (e) {
setState(() {
locationError = 'Error: ${e.toString()}';
isLoadingLocation = false;
});
}
}
Widget _buildSection({
required IconData icon,
required String title,

View File

@ -210,7 +210,9 @@ class _MobileHomeScreenState extends State<MobileHomeScreen> {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const LaundryListScreen(),
builder: (context) => const RecommendationScreen(
category: 'laundry',
),
),
);
},

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,78 @@
import 'package:geolocator/geolocator.dart';
import 'dart:math';
class LocationService {
static final LocationService _instance = LocationService._internal();
factory LocationService() => _instance;
LocationService._internal();
// Request location permission
Future<bool> requestLocationPermission() async {
final permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
final result = await Geolocator.requestPermission();
return result == LocationPermission.whileInUse ||
result == LocationPermission.always;
} else if (permission == LocationPermission.deniedForever) {
// Permission permanently denied, open app settings
await Geolocator.openLocationSettings();
return false;
}
return true;
}
// Get current location
Future<Position?> getCurrentLocation() async {
try {
// Check permission first
final hasPermission = await requestLocationPermission();
if (!hasPermission) return null;
final position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
timeLimit: const Duration(seconds: 10),
);
return position;
} catch (e) {
print('Error getting location: $e');
return null;
}
}
// Calculate distance between two coordinates (in km)
static double calculateDistance(
double lat1,
double lon1,
double lat2,
double lon2,
) {
const earthRadius = 6371; // km
final dLat = _degreesToRadians(lat2 - lat1);
final dLon = _degreesToRadians(lon2 - lon1);
final a = sin(dLat / 2) * sin(dLat / 2) +
cos(_degreesToRadians(lat1)) *
cos(_degreesToRadians(lat2)) *
sin(dLon / 2) *
sin(dLon / 2);
final c = 2 * atan2(sqrt(a), sqrt(1 - a));
final distance = earthRadius * c;
return distance;
}
static double _degreesToRadians(double degrees) {
return degrees * pi / 180;
}
// Check if location services are enabled
Future<bool> isLocationServiceEnabled() async {
return await Geolocator.isLocationServiceEnabled();
}
}

View File

@ -121,13 +121,13 @@ class KontrakanCard extends StatelessWidget {
vertical: 6,
),
decoration: BoxDecoration(
color: kontrakan.status == 'tersedia'
color: kontrakan.isAvailable
? Colors.green
: Colors.red,
borderRadius: BorderRadius.circular(20),
),
child: Text(
kontrakan.status == 'tersedia' ? 'Tersedia' : 'Penuh',
kontrakan.isAvailable ? 'Tersedia' : 'Penuh',
style: const TextStyle(
color: Colors.white,
fontSize: 12,

View File

@ -240,6 +240,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
intl:
dependency: "direct main"
description:
name: intl
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
url: "https://pub.dev"
source: hosted
version: "0.19.0"
leak_tracker:
dependency: transitive
description:

View File

@ -56,6 +56,9 @@ dependencies:
# Geolocation untuk deteksi lokasi user
geolocator: ^11.0.0
# Date formatting
intl: ^0.19.0
dev_dependencies:
flutter_test:
sdk: flutter