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:
parent
fd956653ea
commit
5fe31899db
|
|
@ -45,5 +45,9 @@
|
||||||
<true/>
|
<true/>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<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>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ class Kontrakan {
|
||||||
final List<Galeri> galeri;
|
final List<Galeri> galeri;
|
||||||
final double? avgRating;
|
final double? avgRating;
|
||||||
final int? totalReviews;
|
final int? totalReviews;
|
||||||
|
final double? latitude;
|
||||||
|
final double? longitude;
|
||||||
|
|
||||||
Kontrakan({
|
Kontrakan({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
|
@ -27,6 +29,8 @@ class Kontrakan {
|
||||||
this.galeri = const [],
|
this.galeri = const [],
|
||||||
this.avgRating,
|
this.avgRating,
|
||||||
this.totalReviews,
|
this.totalReviews,
|
||||||
|
this.latitude,
|
||||||
|
this.longitude,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Kontrakan.fromJson(Map<String, dynamic> json) {
|
factory Kontrakan.fromJson(Map<String, dynamic> json) {
|
||||||
|
|
@ -57,9 +61,18 @@ class Kontrakan {
|
||||||
? double.tryParse(json['avg_rating'].toString())
|
? double.tryParse(json['avg_rating'].toString())
|
||||||
: null,
|
: null,
|
||||||
totalReviews: json['total_reviews'],
|
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
|
// Get primary photo URL
|
||||||
String get primaryPhoto {
|
String get primaryPhoto {
|
||||||
if (galeri.isNotEmpty) {
|
if (galeri.isNotEmpty) {
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,26 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import '../models/kontrakan.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;
|
final Kontrakan kontrakan;
|
||||||
|
|
||||||
const KontrakanDetailScreen({super.key, required this.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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|
@ -24,13 +38,13 @@ class KontrakanDetailScreen extends StatelessWidget {
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 250,
|
height: 250,
|
||||||
child: PageView.builder(
|
child: PageView.builder(
|
||||||
itemCount: kontrakan.galeri.isEmpty
|
itemCount: widget.kontrakan.galeri.isEmpty
|
||||||
? 1
|
? 1
|
||||||
: kontrakan.galeri.length,
|
: widget.kontrakan.galeri.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final imageUrl = kontrakan.galeri.isEmpty
|
final imageUrl = widget.kontrakan.galeri.isEmpty
|
||||||
? kontrakan.primaryPhoto
|
? widget.kontrakan.primaryPhoto
|
||||||
: kontrakan.galeri[index].foto;
|
: widget.kontrakan.galeri[index].foto;
|
||||||
|
|
||||||
return CachedNetworkImage(
|
return CachedNetworkImage(
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
|
|
@ -56,7 +70,7 @@ class KontrakanDetailScreen extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
// Title & Price
|
// Title & Price
|
||||||
Text(
|
Text(
|
||||||
kontrakan.nama,
|
widget.kontrakan.nama,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 22,
|
fontSize: 22,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
|
@ -65,7 +79,7 @@ class KontrakanDetailScreen extends StatelessWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'${kontrakan.formattedHarga}/bulan',
|
'${widget.kontrakan.formattedHarga}/bulan',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
|
@ -75,12 +89,18 @@ class KontrakanDetailScreen extends StatelessWidget {
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Location
|
// Location
|
||||||
_buildInfoRow(Icons.location_on, kontrakan.alamat),
|
_buildInfoRow(Icons.location_on, widget.kontrakan.alamat),
|
||||||
_buildInfoRow(
|
_buildInfoRow(
|
||||||
Icons.directions_walk,
|
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),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
|
@ -97,7 +117,7 @@ class KontrakanDetailScreen extends StatelessWidget {
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: kontrakan.fasilitasList.map((f) {
|
children: widget.kontrakan.fasilitasList.map((f) {
|
||||||
return Chip(
|
return Chip(
|
||||||
label: Text(f),
|
label: Text(f),
|
||||||
backgroundColor: Colors.blue[50],
|
backgroundColor: Colors.blue[50],
|
||||||
|
|
@ -105,7 +125,7 @@ class KontrakanDetailScreen extends StatelessWidget {
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
|
|
||||||
if (kontrakan.deskripsi != null) ...[
|
if (widget.kontrakan.deskripsi != null) ...[
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
const Text(
|
const Text(
|
||||||
'Deskripsi',
|
'Deskripsi',
|
||||||
|
|
@ -117,7 +137,7 @@ class KontrakanDetailScreen extends StatelessWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
kontrakan.deskripsi!,
|
widget.kontrakan.deskripsi!,
|
||||||
style: const TextStyle(fontSize: 14, height: 1.5),
|
style: const TextStyle(fontSize: 14, height: 1.5),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -135,7 +155,7 @@ class KontrakanDetailScreen extends StatelessWidget {
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withOpacity(0.1),
|
color: Colors.black.withValues(alpha: 0.1),
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
offset: const Offset(0, -2),
|
offset: const Offset(0, -2),
|
||||||
),
|
),
|
||||||
|
|
@ -143,23 +163,41 @@ class KontrakanDetailScreen extends StatelessWidget {
|
||||||
),
|
),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: widget.kontrakan.isAvailable
|
||||||
// TODO: Navigate to booking screen
|
? () {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
final authService = AuthService();
|
||||||
const SnackBar(content: Text('Fitur booking segera hadir')),
|
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(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: const Color(0xFF1565C0),
|
backgroundColor: widget.kontrakan.isAvailable
|
||||||
|
? const Color(0xFF667eea)
|
||||||
|
: Colors.grey,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
|
disabledBackgroundColor: Colors.grey[400],
|
||||||
|
disabledForegroundColor: Colors.white70,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: Text(
|
||||||
'Booking Sekarang',
|
widget.kontrakan.isAvailable ? 'Booking Sekarang' : 'Kontrakan Tidak Tersedia',
|
||||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,24 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import '../models/laundry.dart';
|
import '../models/laundry.dart';
|
||||||
|
import '../services/location_service.dart';
|
||||||
|
|
||||||
class LaundryDetailScreen extends StatelessWidget {
|
class LaundryDetailScreen extends StatefulWidget {
|
||||||
final Laundry laundry;
|
final Laundry laundry;
|
||||||
|
|
||||||
const LaundryDetailScreen({super.key, required this.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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|
@ -61,7 +73,7 @@ class LaundryDetailScreen extends StatelessWidget {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
laundry.nama,
|
widget.laundry.nama,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
|
@ -78,8 +90,7 @@ class LaundryDetailScreen extends StatelessWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
laundry.rating?.toStringAsFixed(1) ??
|
widget.laundry.rating.toStringAsFixed(1),
|
||||||
'N/A',
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
|
@ -108,10 +119,15 @@ class LaundryDetailScreen extends StatelessWidget {
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
sliver: SliverList(
|
sliver: SliverList(
|
||||||
delegate: SliverChildListDelegate([
|
delegate: SliverChildListDelegate([
|
||||||
// Status Card
|
// Status Card
|
||||||
_buildStatusCard(),
|
_buildStatusCard(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Location Detection Card
|
||||||
|
if (widget.laundry.latitude != null && widget.laundry.longitude != null)
|
||||||
|
_buildLocationCard(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Info Section
|
// Info Section
|
||||||
_buildSection(
|
_buildSection(
|
||||||
icon: Icons.info_outline,
|
icon: Icons.info_outline,
|
||||||
|
|
@ -121,17 +137,17 @@ class LaundryDetailScreen extends StatelessWidget {
|
||||||
_buildInfoRow(
|
_buildInfoRow(
|
||||||
Icons.location_on,
|
Icons.location_on,
|
||||||
'Alamat',
|
'Alamat',
|
||||||
laundry.alamat,
|
widget.laundry.alamat,
|
||||||
),
|
),
|
||||||
_buildInfoRow(
|
_buildInfoRow(
|
||||||
Icons.access_time,
|
Icons.access_time,
|
||||||
'Jam Operasional',
|
'Jam Operasional',
|
||||||
'${laundry.jamBuka} - ${laundry.jamTutup}',
|
'${widget.laundry.jamBuka} - ${widget.laundry.jamTutup}',
|
||||||
),
|
),
|
||||||
_buildInfoRow(
|
_buildInfoRow(
|
||||||
Icons.schedule,
|
Icons.schedule,
|
||||||
'Estimasi Selesai',
|
'Estimasi Selesai',
|
||||||
'${laundry.estimasiSelesai} jam',
|
'${widget.laundry.estimasiSelesai} jam',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -146,14 +162,14 @@ class LaundryDetailScreen extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
_buildPriceCard(
|
_buildPriceCard(
|
||||||
'Laundry Kiloan',
|
'Laundry Kiloan',
|
||||||
laundry.formattedHargaKiloan,
|
widget.laundry.formattedHargaKiloan,
|
||||||
Icons.scale,
|
Icons.scale,
|
||||||
const Color(0xFF00BCD4),
|
const Color(0xFF00BCD4),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildPriceCard(
|
_buildPriceCard(
|
||||||
'Laundry Satuan',
|
'Laundry Satuan',
|
||||||
laundry.formattedHargaSatuan,
|
widget.laundry.formattedHargaSatuan,
|
||||||
Icons.checkroom,
|
Icons.checkroom,
|
||||||
Colors.purple,
|
Colors.purple,
|
||||||
),
|
),
|
||||||
|
|
@ -163,12 +179,12 @@ class LaundryDetailScreen extends StatelessWidget {
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Action Buttons
|
// Action Buttons
|
||||||
if (laundry.noWhatsapp != null) ...[
|
if (widget.laundry.noWhatsapp != null) ...[
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 50,
|
height: 50,
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: () => _launchWhatsApp(laundry.noWhatsapp!),
|
onPressed: () => _launchWhatsApp(widget.laundry.noWhatsapp!),
|
||||||
icon: const Icon(Icons.message, size: 24),
|
icon: const Icon(Icons.message, size: 24),
|
||||||
label: const Text(
|
label: const Text(
|
||||||
'Hubungi via WhatsApp',
|
'Hubungi via WhatsApp',
|
||||||
|
|
@ -189,13 +205,13 @@ class LaundryDetailScreen extends StatelessWidget {
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
],
|
],
|
||||||
|
|
||||||
if (laundry.latitude != null && laundry.longitude != null)
|
if (widget.laundry.latitude != null && widget.laundry.longitude != null)
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 50,
|
height: 50,
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
_launchMaps(laundry.latitude!, laundry.longitude!),
|
_launchMaps(widget.laundry.latitude!, widget.laundry.longitude!),
|
||||||
icon: const Icon(Icons.map, size: 24),
|
icon: const Icon(Icons.map, size: 24),
|
||||||
label: const Text(
|
label: const Text(
|
||||||
'Lihat di Maps',
|
'Lihat di Maps',
|
||||||
|
|
@ -227,8 +243,8 @@ class LaundryDetailScreen extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStatusCard() {
|
Widget _buildStatusCard() {
|
||||||
Color statusColor = laundry.status == 'buka' ? Colors.green : Colors.red;
|
Color statusColor = widget.laundry.status == 'buka' ? Colors.green : Colors.red;
|
||||||
if (laundry.status == 'buka' && !laundry.isOpen) {
|
if (widget.laundry.status == 'buka' && !widget.laundry.isOpen) {
|
||||||
statusColor = Colors.orange;
|
statusColor = Colors.orange;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -260,7 +276,7 @@ class LaundryDetailScreen extends StatelessWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
laundry.statusText,
|
widget.laundry.statusText,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.bold,
|
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({
|
Widget _buildSection({
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
required String title,
|
required String title,
|
||||||
|
|
|
||||||
|
|
@ -210,7 +210,9 @@ class _MobileHomeScreenState extends State<MobileHomeScreen> {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => const LaundryListScreen(),
|
builder: (context) => const RecommendationScreen(
|
||||||
|
category: 'laundry',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -121,13 +121,13 @@ class KontrakanCard extends StatelessWidget {
|
||||||
vertical: 6,
|
vertical: 6,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: kontrakan.status == 'tersedia'
|
color: kontrakan.isAvailable
|
||||||
? Colors.green
|
? Colors.green
|
||||||
: Colors.red,
|
: Colors.red,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
kontrakan.status == 'tersedia' ? 'Tersedia' : 'Penuh',
|
kontrakan.isAvailable ? 'Tersedia' : 'Penuh',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
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:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,9 @@ dependencies:
|
||||||
|
|
||||||
# Geolocation untuk deteksi lokasi user
|
# Geolocation untuk deteksi lokasi user
|
||||||
geolocator: ^11.0.0
|
geolocator: ^11.0.0
|
||||||
|
|
||||||
|
# Date formatting
|
||||||
|
intl: ^0.19.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue