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/>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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: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
|
||||
onPressed: widget.kontrakan.isAvailable
|
||||
? () {
|
||||
final authService = AuthService();
|
||||
if (!authService.isAuthenticated) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Fitur booking segera hadir')),
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -112,6 +123,11 @@ class LaundryDetailScreen extends StatelessWidget {
|
|||
_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,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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,
|
||||
),
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue