From 2a03df835f67429abbf51738ca5dacf8e73ae694 Mon Sep 17 00:00:00 2001 From: micko samawa Date: Sun, 28 Jun 2026 01:03:47 +0700 Subject: [PATCH] Final TA - Booking flow improvements --- spk_mobile/.gitignore | 13 + spk_mobile/lib/models/booking.dart | 155 ++- spk_mobile/lib/models/user.dart | 6 +- .../lib/screens/booking_form_screen.dart | 1194 ++++++++++------- .../lib/screens/booking_history_screen.dart | 559 ++++++-- .../lib/screens/kontrakan_detail_screen.dart | 178 ++- spk_mobile/lib/services/auth_service.dart | 76 +- spk_mobile/lib/services/booking_service.dart | 253 ++-- .../flutter/generated_plugin_registrant.cc | 4 + .../linux/flutter/generated_plugins.cmake | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 2 + .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 1 + 13 files changed, 1641 insertions(+), 804 deletions(-) diff --git a/spk_mobile/.gitignore b/spk_mobile/.gitignore index 3820a95..8425c3c 100644 --- a/spk_mobile/.gitignore +++ b/spk_mobile/.gitignore @@ -43,3 +43,16 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Backup files +*.bak +*.bak-* + +# PowerShell scripts +*.ps1 + +# Build output +build_output.txt + +# IDE metadata +.metadata diff --git a/spk_mobile/lib/models/booking.dart b/spk_mobile/lib/models/booking.dart index 8a4d76e..1369fec 100644 --- a/spk_mobile/lib/models/booking.dart +++ b/spk_mobile/lib/models/booking.dart @@ -1,4 +1,4 @@ -class Booking { +class Booking { final int id; final int userId; final int kontrakanId; @@ -7,7 +7,15 @@ class Booking { final double totalBiaya; final String status; final String? catatan; - final dynamic kontrakan; // Can be Map or Kontrakan object + final dynamic kontrakan; + + // Pembeda pengajuan survei dan sewa. + final String jenisPengajuan; + final DateTime? tanggalSurvei; + final String? jamSurvei; + final DateTime? surveyFollowUpExpiresAt; + + // Pembayaran hanya digunakan pada pengajuan sewa. final String paymentStatus; final String? paymentProof; @@ -21,53 +29,107 @@ class Booking { required this.status, this.catatan, this.kontrakan, + this.jenisPengajuan = 'sewa', + this.tanggalSurvei, + this.jamSurvei, + this.surveyFollowUpExpiresAt, this.paymentStatus = 'unpaid', this.paymentProof, }); + static DateTime? _parseDate(dynamic value) { + if (value == null) return null; + return DateTime.tryParse(value.toString()); + } + factory Booking.fromJson(Map json) { - // Support both old field names and actual DB column names - final startDate = json['start_date'] ?? json['tanggal_mulai']; - final endDate = json['end_date'] ?? json['tanggal_selesai']; + final startDate = + json['start_date'] ?? json['tanggal_mulai'] ?? json['tanggal_survei']; + final endDate = + json['end_date'] ?? json['tanggal_selesai'] ?? json['tanggal_survei']; final amount = json['amount'] ?? json['total_biaya']; final notes = json['notes'] ?? json['catatan']; + final jenis = (json['jenis_pengajuan'] ?? 'sewa').toString().toLowerCase(); + + final parsedSurveyDate = _parseDate(json['tanggal_survei']); + final parsedStartDate = + _parseDate(startDate) ?? parsedSurveyDate ?? DateTime.now(); + final parsedEndDate = _parseDate(endDate) ?? parsedStartDate; + return Booking( id: int.tryParse(json['id']?.toString() ?? '0') ?? 0, userId: int.tryParse(json['user_id']?.toString() ?? '0') ?? 0, kontrakanId: int.tryParse(json['kontrakan_id']?.toString() ?? '0') ?? 0, - tanggalMulai: DateTime.parse(startDate), - tanggalSelesai: DateTime.parse(endDate), + tanggalMulai: parsedStartDate, + tanggalSelesai: parsedEndDate, totalBiaya: double.tryParse(amount?.toString() ?? '0') ?? 0, - status: json['status'] ?? 'pending', - catatan: notes, + status: json['status']?.toString() ?? 'pending', + catatan: notes?.toString(), kontrakan: json['kontrakan'], - paymentStatus: json['payment_status'] ?? 'unpaid', - paymentProof: json['payment_proof'], + jenisPengajuan: jenis, + tanggalSurvei: parsedSurveyDate, + jamSurvei: json['jam_survei']?.toString(), + surveyFollowUpExpiresAt: _parseDate( + json['survey_follow_up_expires_at'], + ), + paymentStatus: json['payment_status']?.toString() ?? 'unpaid', + paymentProof: json['payment_proof']?.toString(), ); } - // Format total biaya + bool get isSurvei => jenisPengajuan == 'survei'; + + bool get isSewa => !isSurvei; + + bool get isSurveyFollowUpActive => + isSurvei && status.toLowerCase() == 'confirmed'; + + String get jenisLabel { + return isSurvei ? 'Pengajuan Survei' : 'Pengajuan Sewa'; + } + + bool get canUploadPaymentProof { + return isSewa && + status.toLowerCase() == 'confirmed' && + paymentStatus == 'unpaid'; + } + + bool get isWaitingPaymentVerification { + return isSewa && paymentStatus == 'verification'; + } + String get formattedTotalBiaya { - return 'Rp ${totalBiaya.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}'; + if (isSurvei) return '-'; + + final nominal = totalBiaya.toStringAsFixed(0).replaceAllMapped( + RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), + (Match match) => '${match[1]}.', + ); + + return 'Rp $nominal'; } - // Computed duration in months from start and end date int get durasiBulan { - return ((tanggalSelesai.year - tanggalMulai.year) * 12 + - tanggalSelesai.month - - tanggalMulai.month) - .clamp(1, 99); + if (isSurvei) return 0; + + final totalBulan = + (tanggalSelesai.year - tanggalMulai.year) * 12 + + tanggalSelesai.month - + tanggalMulai.month; + + return totalBulan.clamp(1, 99).toInt(); } - // Status badge color String get statusColor { switch (status.toLowerCase()) { case 'confirmed': - return 'green'; - case 'active': return 'blue'; + case 'checked_in': + case 'active': + return 'green'; case 'completed': + case 'expired': return 'gray'; case 'cancelled': return 'red'; @@ -76,21 +138,60 @@ class Booking { } } - // Status label Indonesia String get statusLabel { + if (isSurvei) { + switch (status.toLowerCase()) { + case 'pending': + return 'Menunggu Konfirmasi Survei'; + case 'confirmed': + return 'Survei Disetujui'; + case 'completed': + return 'Survei Selesai'; + case 'cancelled': + return 'Tidak Jadi Sewa'; + case 'expired': + return 'Masa Tindak Lanjut Berakhir'; + default: + return status; + } + } + switch (status.toLowerCase()) { case 'pending': - return 'Menunggu'; + return 'Menunggu Persetujuan Sewa'; case 'confirmed': - return 'Dikonfirmasi'; + switch (paymentStatus.toLowerCase()) { + case 'paid': + return 'Sewa Disetujui'; + case 'verification': + return 'Menunggu Verifikasi Pembayaran'; + default: + return 'Menunggu Pembayaran'; + } + case 'checked_in': case 'active': - return 'Aktif'; + return 'Sedang Ditempati'; case 'completed': - return 'Selesai'; + return 'Sewa Selesai'; case 'cancelled': - return 'Dibatalkan'; + return 'Pengajuan Sewa Dibatalkan'; default: return status; } } + + String get paymentStatusLabel { + if (isSurvei) return '-'; + + switch (paymentStatus.toLowerCase()) { + case 'paid': + return 'Lunas'; + case 'verification': + return 'Menunggu Verifikasi'; + case 'refunded': + return 'Dikembalikan'; + default: + return 'Belum Bayar'; + } + } } diff --git a/spk_mobile/lib/models/user.dart b/spk_mobile/lib/models/user.dart index 623564c..47fa628 100644 --- a/spk_mobile/lib/models/user.dart +++ b/spk_mobile/lib/models/user.dart @@ -22,9 +22,9 @@ class User { factory User.fromJson(Map json) { return User( id: json['id'] ?? 0, - name: json['name'] ?? '', - email: json['email'] ?? '', - phone: json['phone'], + name: (json['name'] ?? json['nama'] ?? json['username'] ?? '').toString(), + email: (json['email'] ?? json['email_address'] ?? '').toString(), + phone: (json['phone'] ?? json['no_hp'] ?? json['no_telepon'])?.toString(), role: json['role'] ?? 'user', roleLabel: json['role_label'], userType: json['user_type'], diff --git a/spk_mobile/lib/screens/booking_form_screen.dart b/spk_mobile/lib/screens/booking_form_screen.dart index 36db4e6..610f3c6 100644 --- a/spk_mobile/lib/screens/booking_form_screen.dart +++ b/spk_mobile/lib/screens/booking_form_screen.dart @@ -1,17 +1,26 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:intl/intl.dart' as intl; -import 'package:intl/date_symbol_data_local.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:intl/intl.dart' as intl; + import '../models/kontrakan.dart'; -import '../services/booking_service.dart'; import '../services/auth_service.dart'; +import '../services/booking_service.dart'; import 'improved_home_screen.dart'; class BookingFormScreen extends StatefulWidget { final Kontrakan kontrakan; - const BookingFormScreen({super.key, required this.kontrakan}); + // survei = hanya meminta jadwal kunjungan. + // sewa = meminta tanggal mulai tinggal dan durasi sewa. + final String jenisPengajuan; + + const BookingFormScreen({ + super.key, + required this.kontrakan, + this.jenisPengajuan = 'sewa', + }); @override State createState() => _BookingFormScreenState(); @@ -23,10 +32,15 @@ class _BookingFormScreenState extends State { final _catatanController = TextEditingController(); final _imagePicker = ImagePicker(); - DateTime? _tanggalMulai; + DateTime? _tanggal; + TimeOfDay? _jamSurvei; int _durasiBulan = 6; bool _isSubmitting = false; - File? _paymentProofImage; + File? _ktpPhoto; + + // Rate limiting: cooldown 10 detik antar submit + DateTime? _lastSubmitTime; + static const _cooldownDuration = Duration(seconds: 10); final _currencyFormat = intl.NumberFormat.currency( locale: 'id_ID', @@ -34,19 +48,29 @@ class _BookingFormScreenState extends State { decimalDigits: 0, ); - // Harga kontrakan disimpan sebagai harga tahunan, jadi total disesuaikan - // proporsional terhadap durasi sewa (6 bulan atau 12 bulan). + bool get _isSurvei => widget.jenisPengajuan.toLowerCase() == 'survei'; + double get _totalBiaya => widget.kontrakan.harga * (_durasiBulan / 12); String get _durasiLabel => _durasiBulan == 12 ? '1 tahun' : '6 bulan'; + String get _judulForm => _isSurvei ? 'Ajukan Survei' : 'Ajukan Sewa'; + + // Sanitasi input untuk mencegah XSS + String _sanitizeInput(String input) { + // Hapus karakter HTML berbahaya + return input + .replaceAll(RegExp(r'<[^>]*>'), '') // Hapus HTML tags + .replaceAll(RegExp(r'&[^;]+;'), '') // Hapus HTML entities + .replaceAll(RegExp(r'javascript:'), '') // Hapus javascript protocol + .replaceAll(RegExp(r'on\w+\s*='), '') // Hapus event handlers + .trim(); + } + @override void initState() { super.initState(); - // Initialize Indonesian locale for date formatting - initializeDateFormatting('id_ID', null).catchError((_) { - // Ignore if already initialized - }); + initializeDateFormatting('id_ID', null); } @override @@ -57,16 +81,18 @@ class _BookingFormScreenState extends State { Future _selectDate() async { final now = DateTime.now(); + final maxDate = now.add(const Duration(days: 180)); // 6 bulan ke depan + final picked = await showDatePicker( context: context, - initialDate: _tanggalMulai ?? now.add(const Duration(days: 1)), + initialDate: _tanggal ?? now.add(const Duration(days: 1)), firstDate: now.add(const Duration(days: 1)), - lastDate: now.add(const Duration(days: 365)), + lastDate: maxDate, builder: (context, child) { return Theme( data: Theme.of(context).copyWith( colorScheme: const ColorScheme.light( - primary: Color(0xFF667eea), + primary: Color(0xFF667EEA), onPrimary: Colors.white, surface: Colors.white, onSurface: Colors.black87, @@ -78,45 +104,78 @@ class _BookingFormScreenState extends State { ); if (picked != null && mounted) { - setState(() => _tanggalMulai = picked); + setState(() => _tanggal = picked); } } - Future _pickPaymentProof() async { + Future _selectSurveyTime() async { + final picked = await showTimePicker( + context: context, + initialTime: _jamSurvei ?? const TimeOfDay(hour: 10, minute: 0), + 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 && mounted) { + setState(() => _jamSurvei = picked); + } + } + + String _formatJam(TimeOfDay value) { + final hour = value.hour.toString().padLeft(2, '0'); + final minute = value.minute.toString().padLeft(2, '0'); + return '$hour:$minute'; + } + + Future _pickKtpPhoto() async { final source = await showModalBottomSheet( context: context, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), - builder: (ctx) => SafeArea( + builder: (sheetContext) => SafeArea( child: Padding( padding: const EdgeInsets.all(16), child: Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( - 'Unggah Bukti Pembayaran', + 'Upload Foto KTP', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), const SizedBox(height: 8), const Text( - 'Pilih foto struk transfer atau bukti pembayaran', + 'Pilih foto KTP untuk melengkapi pengajuan sewa', + textAlign: TextAlign.center, style: TextStyle(color: Colors.grey), ), - const SizedBox(height: 16), + const SizedBox(height: 14), ListTile( leading: const Icon( - Icons.photo_library, - color: Color(0xFF667eea), + Icons.photo_library_rounded, + color: Color(0xFF667EEA), ), title: const Text('Pilih dari Galeri'), - onTap: () => Navigator.pop(ctx, ImageSource.gallery), + onTap: () => Navigator.pop(sheetContext, ImageSource.gallery), ), ListTile( - leading: const Icon(Icons.camera_alt, color: Color(0xFF667eea)), + leading: const Icon( + Icons.camera_alt_rounded, + color: Color(0xFF667EEA), + ), title: const Text('Ambil Foto'), - onTap: () => Navigator.pop(ctx, ImageSource.camera), + onTap: () => Navigator.pop(sheetContext, ImageSource.camera), ), ], ), @@ -135,34 +194,91 @@ class _BookingFormScreenState extends State { ); if (picked != null && mounted) { - setState(() => _paymentProofImage = File(picked.path)); + final file = File(picked.path); + + // Validasi ukuran file (max 5MB) + final fileSize = await file.length(); + const maxSize = 5 * 1024 * 1024; // 5MB in bytes + + if (fileSize > maxSize) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Ukuran foto KTP terlalu besar. Maksimal 5MB.'), + backgroundColor: Colors.red, + ), + ); + return; + } + + // Validasi tipe file (hanya jpeg/png) + final extension = picked.path.toLowerCase(); + if (!extension.endsWith('.jpg') && + !extension.endsWith('.jpeg') && + !extension.endsWith('.png')) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Format file tidak didukung. Gunakan JPG atau PNG.', + ), + backgroundColor: Colors.red, + ), + ); + return; + } + + setState(() => _ktpPhoto = file); } } catch (e) { if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Gagal memilih gambar: $e'), + content: Text('Gagal memilih foto KTP: $e'), backgroundColor: Colors.red, ), ); } } - Future _submitBooking() async { - if (_tanggalMulai == null) { + Future _submitPengajuan() async { + // Rate limiting check + if (_lastSubmitTime != null) { + final timeSinceLastSubmit = DateTime.now().difference(_lastSubmitTime!); + if (timeSinceLastSubmit < _cooldownDuration) { + final remainingSeconds = + _cooldownDuration.inSeconds - timeSinceLastSubmit.inSeconds; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Mohon tunggu $remainingSeconds detik sebelum mengirim lagi.', + ), + backgroundColor: Colors.orange, + ), + ); + return; + } + } + + if (_tanggal == null) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Pilih tanggal mulai terlebih dahulu'), + SnackBar( + content: Text( + _isSurvei + ? 'Pilih tanggal survei terlebih dahulu.' + : 'Pilih tanggal mulai sewa terlebih dahulu.', + ), backgroundColor: Colors.red, ), ); return; } - if (_paymentProofImage == null) { + if (_isSurvei && _jamSurvei == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Bukti pembayaran wajib diunggah'), + content: Text('Pilih jam survei terlebih dahulu.'), backgroundColor: Colors.red, ), ); @@ -172,23 +288,32 @@ class _BookingFormScreenState extends State { if (!_authService.isAuthenticated) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Anda harus login terlebih dahulu'), + content: Text('Anda harus login terlebih dahulu.'), backgroundColor: Colors.red, ), ); return; } - // Confirmation dialog + // Set timestamp submit + _lastSubmitTime = DateTime.now(); + final confirmed = await showDialog( context: context, - builder: (ctx) => AlertDialog( + builder: (dialogContext) => AlertDialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - title: const Row( + title: Row( children: [ - Icon(Icons.bookmark_add, color: Color(0xFF667eea)), - SizedBox(width: 8), - Text('Konfirmasi Booking'), + Icon( + _isSurvei ? Icons.event_available : Icons.home_work_outlined, + color: const Color(0xFF667EEA), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _isSurvei ? 'Konfirmasi Survei' : 'Konfirmasi Pengajuan Sewa', + ), + ), ], ), content: Column( @@ -198,39 +323,54 @@ class _BookingFormScreenState extends State { _buildConfirmRow('Kontrakan', widget.kontrakan.nama), const SizedBox(height: 8), _buildConfirmRow( - 'Tanggal Mulai', - intl.DateFormat('dd MMMM yyyy', 'id_ID').format(_tanggalMulai!), + _isSurvei ? 'Tanggal Survei' : 'Mulai Menempati', + intl.DateFormat('dd MMMM yyyy', 'id_ID').format(_tanggal!), ), - const SizedBox(height: 8), - _buildConfirmRow('Durasi', _durasiLabel), - const SizedBox(height: 8), - _buildConfirmRow( - 'Total Biaya', - _currencyFormat.format(_totalBiaya), - ), - if (_catatanController.text.isNotEmpty) ...[ + if (_isSurvei) ...[ const SizedBox(height: 8), - _buildConfirmRow('Catatan', _catatanController.text), + _buildConfirmRow('Jam Survei', _formatJam(_jamSurvei!)), + ] else ...[ + const SizedBox(height: 8), + _buildConfirmRow('Durasi Sewa', _durasiLabel), + const SizedBox(height: 8), + _buildConfirmRow( + 'Estimasi Biaya', + _currencyFormat.format(_totalBiaya), + ), + ], + if (_catatanController.text.trim().isNotEmpty) ...[ + const SizedBox(height: 8), + _buildConfirmRow('Catatan', _catatanController.text.trim()), + ], + if (!_isSurvei && _ktpPhoto != null) ...[ + const SizedBox(height: 8), + _buildConfirmRow('Foto KTP', 'Foto terlampir'), + ], + if (!_isSurvei) ...[ + const SizedBox(height: 12), + const Text( + 'Pembayaran dilakukan setelah pengajuan sewa disetujui pemilik kontrakan.', + style: TextStyle( + fontSize: 12, + color: Color(0xFF5A6B85), + height: 1.4, + ), + ), ], - const SizedBox(height: 8), - _buildConfirmRow('Bukti Bayar', 'Foto terlampir'), ], ), actions: [ TextButton( - onPressed: () => Navigator.pop(ctx, false), + onPressed: () => Navigator.pop(dialogContext, false), child: const Text('Batal'), ), ElevatedButton( - onPressed: () => Navigator.pop(ctx, true), + onPressed: () => Navigator.pop(dialogContext, true), style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF667eea), + backgroundColor: const Color(0xFF667EEA), foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), ), - child: const Text('Konfirmasi'), + child: const Text('Kirim'), ), ], ), @@ -241,35 +381,43 @@ class _BookingFormScreenState extends State { setState(() => _isSubmitting = true); try { - final result = await _bookingService.createBooking( - kontrakanId: widget.kontrakan.id, - tanggalMulai: _tanggalMulai!, - durasiBulan: _durasiBulan, - catatan: _catatanController.text.isNotEmpty - ? _catatanController.text - : null, - paymentProof: _paymentProofImage, - ); + final result = _isSurvei + ? await _bookingService.createSurvey( + kontrakanId: widget.kontrakan.id, + tanggalSurvei: _tanggal!, + jamSurvei: _formatJam(_jamSurvei!), + catatan: _catatanController.text.trim().isEmpty + ? null + : _sanitizeInput(_catatanController.text.trim()), + ) + : await _bookingService.createSewa( + kontrakanId: widget.kontrakan.id, + tanggalMulai: _tanggal!, + durasiBulan: _durasiBulan, + catatan: _catatanController.text.trim().isEmpty + ? null + : _sanitizeInput(_catatanController.text.trim()), + ktpPhoto: _ktpPhoto, + ); if (!mounted) return; setState(() => _isSubmitting = false); if (result['success'] == true) { - showDialog( + await showDialog( context: context, barrierDismissible: false, - builder: (ctx) => AlertDialog( + builder: (dialogContext) => 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), + color: Colors.green.withOpacity(0.10), shape: BoxShape.circle, ), child: const Icon( @@ -279,40 +427,38 @@ class _BookingFormScreenState extends State { ), ), const SizedBox(height: 16), - const Text( - 'Booking Berhasil!', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + Text( + _isSurvei + ? 'Pengajuan Survei Terkirim' + : 'Pengajuan Sewa Terkirim', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), ), const SizedBox(height: 8), Text( - 'Booking Anda sedang menunggu konfirmasi dari pemilik kontrakan.', + _isSurvei + ? 'Silakan tunggu konfirmasi jadwal dari pemilik kontrakan.' + : 'Silakan tunggu persetujuan pemilik. Pembayaran dilakukan setelah pengajuan disetujui.', textAlign: TextAlign.center, - style: TextStyle(fontSize: 14, color: Colors.grey[600]), + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + height: 1.4, + ), ), - const SizedBox(height: 8), ], ), actions: [ SizedBox( width: double.infinity, child: ElevatedButton( - onPressed: () { - Navigator.pop(ctx); // Close dialog - Navigator.pushAndRemoveUntil( - context, - MaterialPageRoute( - builder: (_) => const ImprovedHomeScreen(), - ), - (route) => false, - ); - }, + onPressed: () => Navigator.pop(dialogContext), style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF667eea), + backgroundColor: const Color(0xFF667EEA), foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), ), child: const Text('OK'), ), @@ -320,10 +466,17 @@ class _BookingFormScreenState extends State { ], ), ); + + if (!mounted) return; + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (_) => const ImprovedHomeScreen()), + (route) => false, + ); } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(result['message'] ?? 'Gagal membuat booking'), + content: Text(result['message'] ?? 'Gagal mengirim pengajuan.'), backgroundColor: Colors.red, ), ); @@ -333,7 +486,7 @@ class _BookingFormScreenState extends State { setState(() => _isSubmitting = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error: ${e.toString()}'), + content: Text('Terjadi kesalahan: $e'), backgroundColor: Colors.red, ), ); @@ -345,7 +498,7 @@ class _BookingFormScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( - width: 100, + width: 108, child: Text( label, style: TextStyle(fontSize: 13, color: Colors.grey[600]), @@ -361,432 +514,45 @@ class _BookingFormScreenState extends State { ); } - @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( + Widget _buildChoiceField({ + required IconData icon, + required String value, + required VoidCallback onTap, + String? hint, + }) { + final isSelected = hint == null; + + return InkWell( + onTap: onTap, + 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: isSelected ? const Color(0xFF667EEA) : Colors.grey[300]!, + ), + ), + child: Row( 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, + Icon( + icon, + color: isSelected ? const Color(0xFF667EEA) : Colors.grey[400], + ), + const SizedBox(width: 12), + Expanded( + child: Text( + hint ?? value, + style: TextStyle( + fontSize: 15, + color: isSelected ? Colors.black87 : Colors.grey[500], + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, ), ), - 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}/tahun', - 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 - ? intl.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: 'Pilih durasi 6 bulan atau 1 tahun', - 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( - 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: const [ - DropdownMenuItem( - value: 6, - child: Text('6 bulan'), - ), - DropdownMenuItem( - value: 12, - child: Text('1 tahun'), - ), - ], - 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), - - // Bukti Pembayaran - _buildFormCard( - icon: Icons.receipt, - title: 'Bukti Pembayaran', - subtitle: - 'Upload foto struk/bukti transfer pembayaran (Wajib)', - child: Column( - children: [ - if (_paymentProofImage != null) ...[ - Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.file( - _paymentProofImage!, - width: double.infinity, - height: 200, - fit: BoxFit.cover, - ), - ), - Positioned( - top: 8, - right: 8, - child: InkWell( - onTap: () => - setState(() => _paymentProofImage = null), - child: Container( - padding: const EdgeInsets.all(6), - decoration: const BoxDecoration( - color: Colors.red, - shape: BoxShape.circle, - ), - child: const Icon( - Icons.close, - color: Colors.white, - size: 18, - ), - ), - ), - ), - ], - ), - const SizedBox(height: 12), - ], - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: _pickPaymentProof, - icon: Icon( - _paymentProofImage != null - ? Icons.change_circle - : Icons.upload_file, - color: const Color(0xFF667eea), - ), - label: Text( - _paymentProofImage != null - ? 'Ganti Foto' - : 'Pilih Foto Bukti Pembayaran', - style: const TextStyle(color: Color(0xFF667eea)), - ), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Color(0xFF667eea)), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric(vertical: 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 tahun', - widget.kontrakan.formattedHarga, - ), - const SizedBox(height: 6), - _buildBiayaRow('Durasi sewa', _durasiLabel), - 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), - ], - ), ), + const Icon(Icons.arrow_drop_down, color: Color(0xFF667EEA)), ], ), ), @@ -818,7 +584,7 @@ class _BookingFormScreenState extends State { children: [ Row( children: [ - Icon(icon, size: 20, color: const Color(0xFF667eea)), + Icon(icon, size: 20, color: const Color(0xFF667EEA)), const SizedBox(width: 8), Text( title, @@ -854,4 +620,388 @@ class _BookingFormScreenState extends State { ], ); } + + @override + Widget build(BuildContext context) { + final dateText = _tanggal == null + ? 'Pilih tanggal...' + : intl.DateFormat('dd MMMM yyyy', 'id_ID').format(_tanggal!); + + final timeText = _jamSurvei == null + ? 'Pilih jam...' + : _formatJam(_jamSurvei!); + + return Scaffold( + backgroundColor: const Color(0xFFF5F5F5), + appBar: AppBar( + title: Text(_judulForm), + backgroundColor: const Color(0xFF667EEA), + foregroundColor: Colors.white, + elevation: 0, + ), + body: SingleChildScrollView( + child: Column( + children: [ + 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), + Text( + widget.kontrakan.alamat, + style: const TextStyle(fontSize: 13, color: Colors.white70), + ), + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.20), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '${widget.kontrakan.formattedHarga}/tahun', + style: const TextStyle( + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: _isSurvei + ? const Color(0xFFE8F5E9) + : const Color(0xFFE3F2FD), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _isSurvei + ? const Color(0xFF43A047) + : const Color(0xFF1565C0), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + _isSurvei + ? Icons.event_available + : Icons.home_work_outlined, + color: _isSurvei + ? const Color(0xFF2E7D32) + : const Color(0xFF1565C0), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + _isSurvei + ? 'Survei digunakan untuk melihat kontrakan terlebih dahulu. Tidak ada pembayaran pada tahap ini.' + : 'Pengajuan sewa digunakan saat Anda sudah ingin menempati kontrakan. Pembayaran dilakukan setelah disetujui pemilik.', + style: const TextStyle(fontSize: 13, height: 1.4), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _buildFormCard( + icon: Icons.calendar_today, + title: _isSurvei ? 'Tanggal Survei' : 'Tanggal Mulai Sewa', + subtitle: _isSurvei + ? 'Pilih tanggal untuk melihat kontrakan' + : 'Pilih tanggal mulai menempati kontrakan', + child: _buildChoiceField( + icon: Icons.date_range, + value: dateText, + hint: _tanggal == null ? dateText : null, + onTap: _selectDate, + ), + ), + if (_isSurvei) ...[ + const SizedBox(height: 16), + _buildFormCard( + icon: Icons.access_time, + title: 'Jam Survei', + subtitle: 'Pilih perkiraan waktu kunjungan', + child: _buildChoiceField( + icon: Icons.schedule, + value: timeText, + hint: _jamSurvei == null ? timeText : null, + onTap: _selectSurveyTime, + ), + ), + ] else ...[ + const SizedBox(height: 16), + _buildFormCard( + icon: Icons.timelapse, + title: 'Durasi Sewa', + subtitle: 'Pilih durasi sewa yang diinginkan', + 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.30), + ), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _durasiBulan, + isExpanded: true, + items: const [ + DropdownMenuItem( + value: 6, + child: Text('6 bulan'), + ), + DropdownMenuItem( + value: 12, + child: Text('1 tahun'), + ), + ], + onChanged: (value) { + if (value != null) { + setState(() => _durasiBulan = value); + } + }, + ), + ), + ), + ), + ], + if (!_isSurvei) ...[ + const SizedBox(height: 16), + _buildFormCard( + icon: Icons.badge_outlined, + title: 'Upload Foto KTP', + subtitle: + 'Pilih foto KTP untuk melengkapi pengajuan sewa', + child: Column( + children: [ + if (_ktpPhoto != null) ...[ + Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.file( + _ktpPhoto!, + width: double.infinity, + height: 180, + fit: BoxFit.cover, + ), + ), + Positioned( + top: 8, + right: 8, + child: InkWell( + onTap: () { + setState(() => _ktpPhoto = null); + }, + child: Container( + padding: const EdgeInsets.all(6), + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.close, + color: Colors.white, + size: 18, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + ], + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _pickKtpPhoto, + icon: Icon( + _ktpPhoto == null + ? Icons.upload_file_rounded + : Icons.change_circle_rounded, + ), + label: Text( + _ktpPhoto == null + ? 'Pilih Foto KTP' + : 'Ganti Foto KTP', + ), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF667EEA), + side: const BorderSide( + color: Color(0xFF667EEA), + ), + padding: const EdgeInsets.symmetric( + vertical: 13, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + ), + ], + const SizedBox(height: 16), + _buildFormCard( + icon: Icons.note_alt_outlined, + title: 'Catatan (Opsional)', + subtitle: _isSurvei + ? 'Contoh: Saya ingin survei bersama orang tua.' + : 'Contoh: Saya ingin mulai menempati bulan depan.', + child: TextField( + controller: _catatanController, + maxLines: 3, + decoration: InputDecoration( + hintText: 'Tulis catatan untuk pemilik kontrakan...', + 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), + ), + ), + ), + ), + ), + if (!_isSurvei) ...[ + const SizedBox(height: 16), + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: const Color(0xFF667EEA).withOpacity(0.20), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Ringkasan Estimasi Sewa', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + ), + ), + const SizedBox(height: 12), + _buildBiayaRow( + 'Harga per tahun', + widget.kontrakan.formattedHarga, + ), + const SizedBox(height: 6), + _buildBiayaRow('Durasi', _durasiLabel), + const Divider(height: 22), + _buildBiayaRow( + 'Estimasi biaya', + _currencyFormat.format(_totalBiaya), + ), + ], + ), + ), + ], + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: _isSubmitting ? null : _submitPengajuan, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF667EEA), + foregroundColor: Colors.white, + disabledBackgroundColor: Colors.grey[400], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _isSubmitting + ? const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ), + SizedBox(width: 12), + Text('Mengirim...'), + ], + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + _isSurvei + ? Icons.event_available + : Icons.home_work_outlined, + ), + const SizedBox(width: 8), + Text( + _isSurvei + ? 'Kirim Pengajuan Survei' + : 'Kirim Pengajuan Sewa', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ], + ), + ), + ); + } } diff --git a/spk_mobile/lib/screens/booking_history_screen.dart b/spk_mobile/lib/screens/booking_history_screen.dart index 0658811..c47f9df 100644 --- a/spk_mobile/lib/screens/booking_history_screen.dart +++ b/spk_mobile/lib/screens/booking_history_screen.dart @@ -3,6 +3,8 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import '../services/booking_service.dart'; +import 'booking_form_screen.dart'; +import '../models/kontrakan.dart'; import '../models/booking.dart'; // ignore_for_file: deprecated_member_use @@ -54,12 +56,22 @@ class _BookingHistoryScreenState extends State final bookings = await _bookingService.getBookingHistory(); if (!mounted) return; setState(() { - _activeBookings = bookings - .where((b) => b.status == 'confirmed' || b.status == 'pending') - .toList(); - _pastBookings = bookings - .where((b) => b.status == 'completed' || b.status == 'cancelled') - .toList(); + _activeBookings = bookings.where((b) { + final status = b.status.toLowerCase(); + + return status == 'pending' || + status == 'confirmed' || + status == 'checked_in'; + }).toList(); + + _pastBookings = bookings.where((b) { + final status = b.status.toLowerCase(); + + return status == 'completed' || + status == 'cancelled' || + status == 'expired'; + }).toList(); + _isLoading = false; }); } catch (e) { @@ -68,7 +80,7 @@ class _BookingHistoryScreenState extends State setState(() => _isLoading = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: const Text('Gagal memuat riwayat booking'), + content: const Text('Gagal memuat riwayat pengajuan'), backgroundColor: Colors.red[700], behavior: SnackBarBehavior.floating, shape: RoundedRectangleBorder( @@ -80,7 +92,9 @@ class _BookingHistoryScreenState extends State } } - Future _cancelBooking(int bookingId) async { + Future _cancelBooking(Booking booking) async { + final jenisLabel = booking.isSurvei ? 'Survei' : 'Pengajuan Sewa'; + final confirm = await showDialog( context: context, builder: (ctx) => AlertDialog( @@ -93,13 +107,18 @@ class _BookingHistoryScreenState extends State size: 24, ), const SizedBox(width: 10), - const Text( - 'Batalkan Booking', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + Expanded( + child: Text( + 'Batalkan $jenisLabel', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), ), ], ), - content: const Text('Apakah Anda yakin ingin membatalkan booking ini?'), + content: Text('Apakah Anda yakin ingin membatalkan $jenisLabel ini?'), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), @@ -120,10 +139,14 @@ class _BookingHistoryScreenState extends State ], ), ); + if (confirm != true) return; + try { - final result = await _bookingService.cancelBooking(bookingId); + final result = await _bookingService.cancelBooking(booking.id); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Row( @@ -149,12 +172,16 @@ class _BookingHistoryScreenState extends State margin: const EdgeInsets.all(16), ), ); - if (result['success'] == true) _loadBookings(); + + if (result['success'] == true) { + _loadBookings(); + } } catch (e) { if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Gagal membatalkan booking: $e'), + content: Text('Gagal membatalkan pengajuan: $e'), backgroundColor: const Color(0xFFC62828), behavior: SnackBarBehavior.floating, shape: RoundedRectangleBorder( @@ -166,6 +193,179 @@ class _BookingHistoryScreenState extends State } } + Future _openSewaFromSurvey(Booking booking) async { + final rawKontrakan = booking.kontrakan; + + if (rawKontrakan is! Map) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Data kontrakan tidak dapat dibuka. Silakan coba lagi.', + ), + backgroundColor: Color(0xFFC62828), + ), + ); + return; + } + + final kontrakan = Kontrakan.fromJson( + Map.from(rawKontrakan), + ); + + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + BookingFormScreen(kontrakan: kontrakan, jenisPengajuan: 'sewa'), + ), + ); + + if (mounted) { + _loadBookings(); + } + } + + Future _markSurveyAsNotRenting(Booking booking) async { + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: const Text( + 'Tidak Jadi Sewa?', + style: TextStyle(fontWeight: FontWeight.w700), + ), + content: const Text( + 'Pengajuan survei akan ditutup dan dipindahkan ke riwayat. ' + 'Kontrakan tetap tersedia untuk pengguna lain.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Kembali'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFC62828), + foregroundColor: Colors.white, + ), + child: const Text('Ya, Tidak Jadi'), + ), + ], + ), + ); + + if (confirm != true) return; + + final result = await _bookingService.cancelBooking(booking.id); + + if (!mounted) return; + + if (result['success'] == true) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Survei ditutup. Data pengajuan dipindahkan ke riwayat.', + ), + backgroundColor: Color(0xFF2E7D32), + behavior: SnackBarBehavior.floating, + ), + ); + _loadBookings(); + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(result['message'] ?? 'Gagal menutup pengajuan survei.'), + backgroundColor: const Color(0xFFC62828), + behavior: SnackBarBehavior.floating, + ), + ); + } + + Widget _buildSurveyFollowUpActions(Booking booking) { + final deadline = booking.surveyFollowUpExpiresAt; + final deadlineText = deadline == null + ? 'Silakan ajukan sewa atau pilih Tidak Jadi Sewa dalam 2x24 jam.' + : 'Sisa waktu: ${_formatRemainingTime(deadline)}\n' + 'Batas akhir: ${_formatDateTime(deadline)}'; + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF8E1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFFCC80)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.timer_outlined, color: Color(0xFFF57C00), size: 18), + SizedBox(width: 8), + Expanded( + child: Text( + 'Tindak Lanjut Survei', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFFE65100), + ), + ), + ), + ], + ), + const SizedBox(height: 7), + Text( + deadlineText, + style: const TextStyle( + fontSize: 12, + height: 1.35, + color: Color(0xFF6D4C41), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => _openSewaFromSurvey(booking), + icon: const Icon(Icons.home_work_rounded, size: 18), + label: const Text('Ajukan Sewa'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF1565C0), + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => _markSurveyAsNotRenting(booking), + icon: const Icon(Icons.cancel_outlined, size: 18), + label: const Text('Tidak Jadi Sewa'), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFFC62828), + side: const BorderSide(color: Color(0xFFEF9A9A)), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + ); + } + Future _uploadPaymentProof(Booking booking) async { // Show source dialog final source = await showModalBottomSheet( @@ -356,7 +556,7 @@ class _BookingHistoryScreenState extends State crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Booking Saya', + 'Pengajuan Saya', style: TextStyle( fontSize: 22, fontWeight: FontWeight.w700, @@ -366,7 +566,7 @@ class _BookingHistoryScreenState extends State ), SizedBox(height: 2), Text( - 'Pantau status aktif dan riwayat booking', + 'Pantau status survei dan pengajuan sewa', style: TextStyle( fontSize: 13, color: Colors.white70, @@ -441,7 +641,7 @@ class _BookingHistoryScreenState extends State const SizedBox(height: 20), Center( child: Text( - isActive ? 'Belum ada booking aktif' : 'Belum ada riwayat', + isActive ? 'Belum ada pengajuan aktif' : 'Belum ada riwayat', style: const TextStyle( fontSize: 17, fontWeight: FontWeight.w600, @@ -453,8 +653,8 @@ class _BookingHistoryScreenState extends State Center( child: Text( isActive - ? 'Booking kontrakan Anda akan muncul di sini' - : 'Riwayat booking sebelumnya akan muncul di sini', + ? 'Pengajuan survei atau sewa Anda akan muncul di sini' + : 'Riwayat pengajuan sebelumnya akan muncul di sini', style: TextStyle(fontSize: 13, color: Colors.grey[500]), textAlign: TextAlign.center, ), @@ -482,33 +682,41 @@ class _BookingHistoryScreenState extends State String statusText; IconData statusIcon; - switch (booking.status) { + switch (booking.status.toLowerCase()) { case 'pending': statusColor = const Color(0xFFF57C00); - statusText = 'Menunggu'; statusIcon = Icons.schedule_rounded; break; case 'confirmed': statusColor = const Color(0xFF2E7D32); - statusText = 'Dikonfirmasi'; statusIcon = Icons.check_circle_rounded; break; + case 'checked_in': + case 'active': + statusColor = const Color(0xFF1565C0); + statusIcon = Icons.home_rounded; + break; case 'completed': statusColor = const Color(0xFF1565C0); - statusText = 'Selesai'; statusIcon = Icons.done_all_rounded; break; + case 'expired': + statusColor = Colors.grey.shade700; + statusIcon = Icons.timer_off_rounded; + break; + case 'cancelled': statusColor = const Color(0xFFC62828); - statusText = 'Dibatalkan'; statusIcon = Icons.cancel_rounded; break; + default: statusColor = Colors.grey; - statusText = 'Unknown'; statusIcon = Icons.help_rounded; } + statusText = booking.statusLabel; + // Get kontrakan name if available String kontrakanName = 'Kontrakan'; if (booking.kontrakan is Map) { @@ -571,7 +779,7 @@ class _BookingHistoryScreenState extends State ), const SizedBox(height: 2), Text( - 'ID: #${booking.id}', + "ID: #${booking.id} • ${booking.isSurvei ? 'Survei' : 'Sewa'}", style: TextStyle( fontSize: 12, color: Colors.grey[500], @@ -615,119 +823,81 @@ class _BookingHistoryScreenState extends State padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), child: Column( children: [ - // Date info in a compact row - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFFF7F8FC), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - children: [ - _buildDateRow( - Icons.login_rounded, - 'Mulai', - _formatDate(booking.tanggalMulai), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Divider(height: 1, color: Colors.grey[200]), - ), - _buildDateRow( - Icons.logout_rounded, - 'Selesai', - _formatDate(booking.tanggalSelesai), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Divider(height: 1, color: Colors.grey[200]), - ), - _buildDateRow( - Icons.timelapse_rounded, - 'Durasi', - '${booking.durasiBulan} Bulan', - ), - ], - ), - ), - - const SizedBox(height: 14), - - // Price and Payment - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, + if (booking.isSurvei) + _buildSurveyScheduleCard(booking) + else ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF7F8FC), + borderRadius: BorderRadius.circular(12), + ), + child: Column( children: [ - Text( - 'Total Harga', - style: TextStyle( - fontSize: 12, - color: Colors.grey[500], - fontWeight: FontWeight.w500, - ), + _buildDateRow( + Icons.login_rounded, + 'Mulai', + _formatDate(booking.tanggalMulai), ), - const SizedBox(height: 2), - Text( - 'Rp ${_formatPrice(booking.totalBiaya)}', - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w800, - color: Color(0xFF1565C0), - ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Divider(height: 1, color: Colors.grey[200]), + ), + _buildDateRow( + Icons.logout_rounded, + 'Selesai', + _formatDate(booking.tanggalSelesai), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Divider(height: 1, color: Colors.grey[200]), + ), + _buildDateRow( + Icons.timelapse_rounded, + 'Durasi', + '${booking.durasiBulan} bulan', ), ], ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: booking.paymentStatus == 'paid' - ? const Color(0xFFE8F5E9) - : const Color(0xFFFFF3E0), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, + ), + const SizedBox(height: 14), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - booking.paymentStatus == 'paid' - ? Icons.check_circle_rounded - : Icons.pending_rounded, - size: 14, - color: booking.paymentStatus == 'paid' - ? const Color(0xFF2E7D32) - : const Color(0xFFF57C00), - ), - const SizedBox(width: 5), Text( - booking.paymentStatus == 'paid' - ? 'Lunas' - : 'Belum Bayar', + 'Estimasi Biaya', style: TextStyle( fontSize: 12, - fontWeight: FontWeight.w600, - color: booking.paymentStatus == 'paid' - ? const Color(0xFF2E7D32) - : const Color(0xFFF57C00), + color: Colors.grey[500], + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Rp ${_formatPrice(booking.totalBiaya)}', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w800, + color: Color(0xFF1565C0), ), ), ], ), - ), - ], - ), + _buildPaymentChip(booking), + ], + ), + ], // Payment proof - if (booking.paymentProof != null) ...[ + if (!booking.isSurvei && booking.paymentProof != null) ...[ const SizedBox(height: 12), GestureDetector( onTap: () { - final Future future = - _bookingService.getPaymentProofBytes(booking.id); + final Future future = _bookingService + .getPaymentProofBytes(booking.id); showDialog( context: context, builder: (ctx) => Dialog( @@ -875,15 +1045,23 @@ class _BookingHistoryScreenState extends State ), ], + if (booking.isSurveyFollowUpActive) ...[ + const SizedBox(height: 14), + _buildSurveyFollowUpActions(booking), + ], // Actions if (booking.status == 'pending') ...[ const SizedBox(height: 14), SizedBox( width: double.infinity, child: OutlinedButton.icon( - onPressed: () => _cancelBooking(booking.id), + onPressed: () => _cancelBooking(booking), icon: const Icon(Icons.close_rounded, size: 18), - label: const Text('Batalkan Booking'), + label: Text( + booking.isSurvei + ? 'Batalkan Survei' + : 'Batalkan Pengajuan Sewa', + ), style: OutlinedButton.styleFrom( foregroundColor: const Color(0xFFC62828), side: const BorderSide(color: Color(0xFFEF9A9A)), @@ -895,8 +1073,7 @@ class _BookingHistoryScreenState extends State ), ), ], - if (booking.status == 'confirmed' && - booking.paymentStatus == 'unpaid') ...[ + if (booking.canUploadPaymentProof) ...[ const SizedBox(height: 14), SizedBox( width: double.infinity, @@ -937,6 +1114,107 @@ class _BookingHistoryScreenState extends State ); } + Widget _buildSurveyScheduleCard(Booking booking) { + final tanggal = booking.tanggalSurvei ?? booking.tanggalMulai; + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFE8F5E9), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFA5D6A7)), + ), + child: Column( + children: [ + _buildDateRow( + Icons.event_available_rounded, + 'Jadwal Survei', + _formatDate(tanggal), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Divider(height: 1, color: Colors.green[100]), + ), + _buildDateRow( + Icons.schedule_rounded, + 'Jam Survei', + booking.jamSurvei ?? '-', + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Divider(height: 1, color: Colors.green[100]), + ), + const Row( + children: [ + Icon( + Icons.info_outline_rounded, + size: 15, + color: Color(0xFF2E7D32), + ), + SizedBox(width: 7), + Expanded( + child: Text( + 'Survei tidak memerlukan pembayaran.', + style: TextStyle( + fontSize: 12, + color: Color(0xFF2E7D32), + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildPaymentChip(Booking booking) { + final isPaid = booking.paymentStatus == 'paid'; + final isVerification = booking.paymentStatus == 'verification'; + + final color = isPaid + ? const Color(0xFF2E7D32) + : isVerification + ? const Color(0xFF1565C0) + : const Color(0xFFF57C00); + + final background = isPaid + ? const Color(0xFFE8F5E9) + : isVerification + ? const Color(0xFFE3F2FD) + : const Color(0xFFFFF3E0); + + final icon = isPaid + ? Icons.check_circle_rounded + : isVerification + ? Icons.hourglass_top_rounded + : Icons.pending_rounded; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 5), + Text( + booking.paymentStatusLabel, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ], + ), + ); + } + Widget _buildDateRow(IconData icon, String label, String value) { return Row( children: [ @@ -981,6 +1259,35 @@ class _BookingHistoryScreenState extends State return '${date.day} ${months[date.month - 1]} ${date.year}'; } + String _formatDateTime(DateTime date) { + final dateText = _formatDate(date); + final hour = date.hour.toString().padLeft(2, '0'); + final minute = date.minute.toString().padLeft(2, '0'); + return '$dateText, $hour:$minute'; + } + + String _formatRemainingTime(DateTime deadline) { + final remaining = deadline.difference(DateTime.now()); + + if (remaining.isNegative) { + return 'Waktu tindak lanjut telah habis'; + } + + final days = remaining.inDays; + final hours = remaining.inHours % 24; + final minutes = remaining.inMinutes % 60; + + if (days > 0) { + return '$days Hari $hours Jam'; + } + + if (hours > 0) { + return '$hours Jam $minutes Menit'; + } + + return '$minutes Menit'; + } + String _formatPrice(double price) { return price .toStringAsFixed(0) diff --git a/spk_mobile/lib/screens/kontrakan_detail_screen.dart b/spk_mobile/lib/screens/kontrakan_detail_screen.dart index e8b4700..3268e5b 100644 --- a/spk_mobile/lib/screens/kontrakan_detail_screen.dart +++ b/spk_mobile/lib/screens/kontrakan_detail_screen.dart @@ -395,28 +395,7 @@ class _KontrakanDetailScreenState extends State { Expanded( child: ElevatedButton( onPressed: widget.kontrakan.isAvailable - ? () { - final authService = AuthService(); - if (!authService.isAuthenticated) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Silakan login terlebih dahulu untuk booking', - ), - backgroundColor: Colors.red, - ), - ); - return; - } - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => BookingFormScreen( - kontrakan: widget.kontrakan, - ), - ), - ); - } + ? _showPengajuanOptions : null, style: ElevatedButton.styleFrom( backgroundColor: widget.kontrakan.isAvailable @@ -433,7 +412,7 @@ class _KontrakanDetailScreenState extends State { ), child: Text( widget.kontrakan.isAvailable - ? 'Ajukan Booking' + ? 'Ajukan Pengajuan' : 'Sedang Penuh', style: const TextStyle( fontSize: 15, @@ -449,6 +428,159 @@ class _KontrakanDetailScreenState extends State { ); } + void _showPengajuanOptions() { + final authService = AuthService(); + + if (!authService.isAuthenticated) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Silakan login terlebih dahulu untuk mengajukan survei atau sewa', + ), + backgroundColor: Colors.red, + ), + ); + return; + } + + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(22)), + ), + builder: (sheetContext) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 42, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(99), + ), + ), + const SizedBox(height: 16), + const Text( + 'Pilih Jenis Pengajuan', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 6), + const Text( + 'Pilih sesuai kebutuhan Anda.', + style: TextStyle(color: Colors.grey), + ), + const SizedBox(height: 16), + _buildPengajuanOption( + icon: Icons.event_available, + color: const Color(0xFF2E7D32), + title: 'Ajukan Survei', + subtitle: + 'Atur jadwal untuk melihat kontrakan. Tanpa pembayaran.', + onTap: () { + Navigator.pop(sheetContext); + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => BookingFormScreen( + kontrakan: widget.kontrakan, + jenisPengajuan: 'survei', + ), + ), + ); + }, + ), + const SizedBox(height: 12), + _buildPengajuanOption( + icon: Icons.home_work_outlined, + color: const Color(0xFF1565C0), + title: 'Ajukan Sewa', + subtitle: + 'Ajukan untuk menempati kontrakan. Bayar setelah disetujui.', + onTap: () { + Navigator.pop(sheetContext); + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => BookingFormScreen( + kontrakan: widget.kontrakan, + jenisPengajuan: 'sewa', + ), + ), + ); + }, + ), + ], + ), + ), + ); + }, + ); + } + + Widget _buildPengajuanOption({ + required IconData icon, + required Color color, + required String title, + required String subtitle, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: color.withOpacity(0.06), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: color.withOpacity(0.25)), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(11), + decoration: BoxDecoration( + color: color.withOpacity(0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, color: color), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: color, + ), + ), + const SizedBox(height: 3), + Text( + subtitle, + style: const TextStyle( + fontSize: 12, + color: Color(0xFF5A6B85), + height: 1.35, + ), + ), + ], + ), + ), + Icon(Icons.arrow_forward_ios, size: 16, color: color), + ], + ), + ), + ); + } + Widget _buildGallery() { if (!widget.kontrakan.hasPhoto) { return _buildMissingPhoto(); diff --git a/spk_mobile/lib/services/auth_service.dart b/spk_mobile/lib/services/auth_service.dart index 494b029..e29b7b2 100644 --- a/spk_mobile/lib/services/auth_service.dart +++ b/spk_mobile/lib/services/auth_service.dart @@ -1,4 +1,4 @@ -import 'dart:async'; +import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; @@ -21,7 +21,7 @@ class AuthService { // HttpClient - lazy initialization HttpClient? _httpClient; - // ✅ Secure storage for sensitive data (token & user) + // ✅ Secure storage for sensitive data (token & user) static const FlutterSecureStorage _secureStorage = FlutterSecureStorage( aOptions: AndroidOptions(encryptedSharedPreferences: true), iOptions: IOSOptions( @@ -39,7 +39,7 @@ class AuthService { if (_httpClient == null) { _httpClient = HttpClient(); - // ✅ Security: NEVER bypass certificate validation in release builds. + // ✅ Security: NEVER bypass certificate validation in release builds. // In debug builds, allow self-signed certs only for local dev hosts. if (kDebugMode) { _httpClient!.badCertificateCallback = (cert, host, port) { @@ -93,7 +93,7 @@ class AuthService { _currentUser = User.fromJson(jsonDecode(userJson)); } - // ✅ Migration: if secure storage empty, try legacy SharedPreferences once. + // ✅ Migration: if secure storage empty, try legacy SharedPreferences once. if (_token == null) { final prefs = await SharedPreferences.getInstance(); final legacyToken = prefs.getString(AppConfig.tokenKey); @@ -505,28 +505,60 @@ class AuthService { } } + // Get current user // Get current user Future getCurrentUser() async { - if (_token == null) return null; + if (_token == null) { + return _currentUser; + } try { - final response = await http.get( - Uri.parse('${AppConfig.baseUrl}/user'), - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'Authorization': 'Bearer $_token', - }, - ); + final response = await http + .get( + Uri.parse('${AppConfig.baseUrl}/user'), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': 'Bearer $_token', + }, + ) + .timeout(AppConfig.connectionTimeout); - if (response.statusCode == 200) { - final user = User.fromJson(jsonDecode(response.body)); - _currentUser = user; - return user; + if (response.statusCode == 200 && response.body.isNotEmpty) { + final decoded = jsonDecode(response.body); + + // Mendukung respons API langsung atau respons yang dibungkus data/user. + Map? userData; + + if (decoded is Map) { + if (decoded['data'] is Map) { + userData = Map.from(decoded['data']); + } else if (decoded['user'] is Map) { + userData = Map.from(decoded['user']); + } else { + userData = decoded; + } + } + + if (userData != null) { + final user = User.fromJson(userData); + + // Jangan menimpa data lokal dengan user kosong dari respons yang tidak valid. + if (user.name.trim().isNotEmpty || user.email.trim().isNotEmpty) { + _currentUser = user; + + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(AppConfig.userKey, jsonEncode(user.toJson())); + + return user; + } + } } - return null; + + // Tetap tampilkan data yang tersimpan bila server tidak memberi profil valid. + return _currentUser; } catch (e) { - return null; + return _currentUser; } } @@ -551,8 +583,10 @@ class AuthService { if (response.statusCode == 200 && data['success'] == true) { final user = User.fromJson(data['data']); - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(AppConfig.userKey, jsonEncode(user.toJson())); + await _secureStorage.write( + key: AppConfig.userKey, + value: jsonEncode(user.toJson()), + ); _currentUser = user; return {'success': true, 'message': data['message']}; } else { diff --git a/spk_mobile/lib/services/booking_service.dart b/spk_mobile/lib/services/booking_service.dart index dad0b7a..7202acf 100644 --- a/spk_mobile/lib/services/booking_service.dart +++ b/spk_mobile/lib/services/booking_service.dart @@ -1,7 +1,9 @@ import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; + import 'package:http/http.dart' as http; + import '../config/app_config.dart'; import '../models/booking.dart'; import 'auth_service.dart'; @@ -10,7 +12,7 @@ class BookingService { final AuthService _authService = AuthService(); Map get _headers { - final headers = { + final headers = { 'Content-Type': 'application/json', 'Accept': 'application/json', }; @@ -22,7 +24,6 @@ class BookingService { return headers; } - // Get booking history Future> getBookingHistory() async { try { final response = await http.get( @@ -37,63 +38,138 @@ class BookingService { if (response.statusCode == 200) { final data = jsonDecode(response.body); + if (data['success'] == true) { - final List items = data['data']['data'] ?? data['data']; - return items.map((json) => Booking.fromJson(json)).toList(); + final List items = data['data']['data'] ?? data['data'] ?? []; + + return items + .map( + (item) => + Booking.fromJson(Map.from(item as Map)), + ) + .toList(); } } + return []; - } catch (e) { - // Error getting booking history silently + } catch (_) { return []; } } - // Create booking (with required payment proof image) - Future> createBooking({ - required int kontrakanId, - required DateTime tanggalMulai, - required int durasiBulan, - String? catatan, - File? paymentProof, - }) async { + Future> _createPengajuan( + Map body, + ) async { try { - // Validate payment proof is provided - if (paymentProof == null) { - return {'success': false, 'message': 'Bukti pembayaran wajib diunggah'}; - } + final response = await http + .post( + Uri.parse('${AppConfig.baseUrl}/bookings'), + headers: _headers, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); - // Use multipart request with payment proof - final uri = Uri.parse('${AppConfig.baseUrl}/bookings'); - final request = http.MultipartRequest('POST', uri); - - if (_authService.token != null) { - request.headers['Authorization'] = 'Bearer ${_authService.token}'; - request.headers['Accept'] = 'application/json'; - } - - request.fields['kontrakan_id'] = kontrakanId.toString(); - request.fields['tanggal_mulai'] = tanggalMulai.toIso8601String().split( - 'T', - )[0]; - request.fields['durasi_bulan'] = durasiBulan.toString(); - if (catatan != null) request.fields['catatan'] = catatan; - - request.files.add( - await http.MultipartFile.fromPath('payment_proof', paymentProof.path), - ); - - final streamedResponse = await request.send().timeout( - const Duration(seconds: 30), - ); - final response = await http.Response.fromStream(streamedResponse); final data = jsonDecode(response.body); if (response.statusCode == 401) { await _authService.handleUnauthorized(response.statusCode); return { 'success': false, - 'message': 'Sesi expired, silakan login ulang', + 'message': 'Sesi habis, silakan login ulang.', + }; + } + + if (response.statusCode == 201 && data['success'] == true) { + return { + 'success': true, + 'message': data['message'], + 'booking': Booking.fromJson( + Map.from(data['data'] as Map), + ), + }; + } + + return { + 'success': false, + 'message': data['message'] ?? 'Gagal mengirim pengajuan.', + 'errors': data['errors'], + }; + } catch (e) { + return {'success': false, 'message': 'Terjadi kesalahan: $e'}; + } + } + + Future> createSurvey({ + required int kontrakanId, + required DateTime tanggalSurvei, + required String jamSurvei, + String? catatan, + }) async { + final body = { + 'kontrakan_id': kontrakanId, + 'jenis_pengajuan': 'survei', + 'tanggal_survei': tanggalSurvei.toIso8601String().split('T')[0], + 'jam_survei': jamSurvei, + }; + + if (catatan != null && catatan.trim().isNotEmpty) { + body['catatan'] = catatan.trim(); + } + + return _createPengajuan(body); + } + + Future> createSewa({ + required int kontrakanId, + required DateTime tanggalMulai, + required int durasiBulan, + String? catatan, + File? ktpPhoto, + }) async { + try { + final request = http.MultipartRequest( + 'POST', + Uri.parse('${AppConfig.baseUrl}/bookings'), + ); + + if (_authService.token != null) { + request.headers['Authorization'] = 'Bearer ${_authService.token}'; + } + + request.headers['Accept'] = 'application/json'; + + request.fields['kontrakan_id'] = kontrakanId.toString(); + request.fields['jenis_pengajuan'] = 'sewa'; + request.fields['tanggal_mulai'] = tanggalMulai.toIso8601String().split( + 'T', + )[0]; + request.fields['durasi_bulan'] = durasiBulan.toString(); + + if (catatan != null && catatan.trim().isNotEmpty) { + request.fields['catatan'] = catatan.trim(); + } + + if (ktpPhoto != null) { + request.files.add( + await http.MultipartFile.fromPath('ktp_photo', ktpPhoto.path), + ); + } + + final streamedResponse = await request.send().timeout( + const Duration(seconds: 30), + ); + + final response = await http.Response.fromStream(streamedResponse); + + final data = response.body.isNotEmpty + ? jsonDecode(response.body) + : {}; + + if (response.statusCode == 401) { + await _authService.handleUnauthorized(response.statusCode); + return { + 'success': false, + 'message': 'Sesi habis, silakan login ulang.', }; } @@ -103,19 +179,33 @@ class BookingService { 'message': data['message'], 'booking': Booking.fromJson(data['data']), }; - } else { - return { - 'success': false, - 'message': data['message'] ?? 'Gagal membuat booking', - 'errors': data['errors'], - }; } + + return { + 'success': false, + 'message': data['message'] ?? 'Gagal mengirim pengajuan sewa.', + 'errors': data['errors'], + }; } catch (e) { - return {'success': false, 'message': 'Error: $e'}; + return {'success': false, 'message': 'Terjadi kesalahan: $e'}; } } - // Cancel booking + Future> createBooking({ + required int kontrakanId, + required DateTime tanggalMulai, + required int durasiBulan, + String? catatan, + File? paymentProof, + }) { + return createSewa( + kontrakanId: kontrakanId, + tanggalMulai: tanggalMulai, + durasiBulan: durasiBulan, + catatan: catatan, + ); + } + Future> cancelBooking(int bookingId) async { try { final response = await http.post( @@ -129,24 +219,23 @@ class BookingService { await _authService.handleUnauthorized(response.statusCode); return { 'success': false, - 'message': 'Sesi expired, silakan login ulang', + 'message': 'Sesi habis, silakan login ulang.', }; } if (response.statusCode == 200 && data['success'] == true) { return {'success': true, 'message': data['message']}; - } else { - return { - 'success': false, - 'message': data['message'] ?? 'Gagal membatalkan booking', - }; } + + return { + 'success': false, + 'message': data['message'] ?? 'Gagal membatalkan pengajuan.', + }; } catch (e) { - return {'success': false, 'message': 'Error: $e'}; + return {'success': false, 'message': 'Terjadi kesalahan: $e'}; } } - // Get booking detail Future getBookingById(int id) async { try { final response = await http.get( @@ -161,35 +250,35 @@ class BookingService { if (response.statusCode == 200) { final data = jsonDecode(response.body); + if (data['success'] == true) { - return Booking.fromJson(data['data']); + return Booking.fromJson( + Map.from(data['data'] as Map), + ); } } + return null; - } catch (e) { - // Error getting booking detail silently + } catch (_) { return null; } } - // Upload payment proof image Future> uploadPaymentProof( int bookingId, File imageFile, ) async { try { - final uri = Uri.parse( - '${AppConfig.baseUrl}/bookings/$bookingId/payment-proof', + final request = http.MultipartRequest( + 'POST', + Uri.parse('${AppConfig.baseUrl}/bookings/$bookingId/payment-proof'), ); - final request = http.MultipartRequest('POST', uri); - // Add auth header if (_authService.token != null) { request.headers['Authorization'] = 'Bearer ${_authService.token}'; request.headers['Accept'] = 'application/json'; } - // Attach image file request.files.add( await http.MultipartFile.fromPath('payment_proof', imageFile.path), ); @@ -197,6 +286,7 @@ class BookingService { final streamedResponse = await request.send().timeout( const Duration(seconds: 30), ); + final response = await http.Response.fromStream(streamedResponse); final data = jsonDecode(response.body); @@ -204,33 +294,32 @@ class BookingService { await _authService.handleUnauthorized(response.statusCode); return { 'success': false, - 'message': 'Sesi expired, silakan login ulang', + 'message': 'Sesi habis, silakan login ulang.', }; } if (response.statusCode == 200 && data['success'] == true) { return { 'success': true, - 'message': data['message'] ?? 'Bukti pembayaran berhasil diunggah', - 'booking': Booking.fromJson(data['data']), - }; - } else { - return { - 'success': false, - 'message': data['message'] ?? 'Gagal mengunggah bukti pembayaran', + 'message': data['message'] ?? 'Bukti pembayaran berhasil diunggah.', + 'booking': Booking.fromJson( + Map.from(data['data'] as Map), + ), }; } + + return { + 'success': false, + 'message': data['message'] ?? 'Gagal mengunggah bukti pembayaran.', + }; } catch (e) { - return {'success': false, 'message': 'Error: $e'}; + return {'success': false, 'message': 'Terjadi kesalahan: $e'}; } } - // Fetch payment proof image bytes (secure, authenticated) Future getPaymentProofBytes(int bookingId) async { try { - final headers = { - 'Accept': 'image/*', - }; + final headers = {'Accept': 'image/*'}; if (_authService.token != null) { headers['Authorization'] = 'Bearer ${_authService.token}'; @@ -253,7 +342,7 @@ class BookingService { } return null; - } catch (e) { + } catch (_) { return null; } } diff --git a/spk_mobile/linux/flutter/generated_plugin_registrant.cc b/spk_mobile/linux/flutter/generated_plugin_registrant.cc index 7299b5c..3ccd551 100644 --- a/spk_mobile/linux/flutter/generated_plugin_registrant.cc +++ b/spk_mobile/linux/flutter/generated_plugin_registrant.cc @@ -7,12 +7,16 @@ #include "generated_plugin_registrant.h" #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/spk_mobile/linux/flutter/generated_plugins.cmake b/spk_mobile/linux/flutter/generated_plugins.cmake index 786ff5c..9ce94c4 100644 --- a/spk_mobile/linux/flutter/generated_plugins.cmake +++ b/spk_mobile/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_linux + flutter_secure_storage_linux url_launcher_linux ) diff --git a/spk_mobile/macos/Flutter/GeneratedPluginRegistrant.swift b/spk_mobile/macos/Flutter/GeneratedPluginRegistrant.swift index 61ad38c..d6f0762 100644 --- a/spk_mobile/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/spk_mobile/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,6 +9,7 @@ import file_selector_macos import firebase_core import firebase_messaging import flutter_local_notifications +import flutter_secure_storage_macos import geolocator_apple import shared_preferences_foundation import sqflite_darwin @@ -19,6 +20,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) diff --git a/spk_mobile/windows/flutter/generated_plugin_registrant.cc b/spk_mobile/windows/flutter/generated_plugin_registrant.cc index b762e91..0492de9 100644 --- a/spk_mobile/windows/flutter/generated_plugin_registrant.cc +++ b/spk_mobile/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -16,6 +17,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FileSelectorWindows")); FirebaseCorePluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); GeolocatorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("GeolocatorWindows")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/spk_mobile/windows/flutter/generated_plugins.cmake b/spk_mobile/windows/flutter/generated_plugins.cmake index 4427958..8b5c8b2 100644 --- a/spk_mobile/windows/flutter/generated_plugins.cmake +++ b/spk_mobile/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows firebase_core + flutter_secure_storage_windows geolocator_windows url_launcher_windows )