Final TA - Booking flow improvements
This commit is contained in:
parent
3e6188bfaa
commit
2a03df835f
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<String, dynamic> 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,9 +22,9 @@ class User {
|
|||
factory User.fromJson(Map<String, dynamic> 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'],
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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<BookingHistoryScreen>
|
|||
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<BookingHistoryScreen>
|
|||
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<BookingHistoryScreen>
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _cancelBooking(int bookingId) async {
|
||||
Future<void> _cancelBooking(Booking booking) async {
|
||||
final jenisLabel = booking.isSurvei ? 'Survei' : 'Pengajuan Sewa';
|
||||
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
|
|
@ -93,13 +107,18 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
|||
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<BookingHistoryScreen>
|
|||
],
|
||||
),
|
||||
);
|
||||
|
||||
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<BookingHistoryScreen>
|
|||
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<BookingHistoryScreen>
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _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<String, dynamic>.from(rawKontrakan),
|
||||
);
|
||||
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
BookingFormScreen(kontrakan: kontrakan, jenisPengajuan: 'sewa'),
|
||||
),
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
_loadBookings();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _markSurveyAsNotRenting(Booking booking) async {
|
||||
final confirm = await showDialog<bool>(
|
||||
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<void> _uploadPaymentProof(Booking booking) async {
|
||||
// Show source dialog
|
||||
final source = await showModalBottomSheet<ImageSource>(
|
||||
|
|
@ -356,7 +556,7 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Booking Saya',
|
||||
'Pengajuan Saya',
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
|
|
@ -366,7 +566,7 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
|||
),
|
||||
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<BookingHistoryScreen>
|
|||
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<BookingHistoryScreen>
|
|||
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<BookingHistoryScreen>
|
|||
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<BookingHistoryScreen>
|
|||
),
|
||||
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<BookingHistoryScreen>
|
|||
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<Uint8List?> future =
|
||||
_bookingService.getPaymentProofBytes(booking.id);
|
||||
final Future<Uint8List?> future = _bookingService
|
||||
.getPaymentProofBytes(booking.id);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => Dialog(
|
||||
|
|
@ -875,15 +1045,23 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
|||
),
|
||||
],
|
||||
|
||||
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<BookingHistoryScreen>
|
|||
),
|
||||
),
|
||||
],
|
||||
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<BookingHistoryScreen>
|
|||
);
|
||||
}
|
||||
|
||||
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<BookingHistoryScreen>
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -395,28 +395,7 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
|||
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<KontrakanDetailScreen> {
|
|||
),
|
||||
child: Text(
|
||||
widget.kontrakan.isAvailable
|
||||
? 'Ajukan Booking'
|
||||
? 'Ajukan Pengajuan'
|
||||
: 'Sedang Penuh',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
|
|
@ -449,6 +428,159 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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<User?> 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<String, dynamic>? userData;
|
||||
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
if (decoded['data'] is Map) {
|
||||
userData = Map<String, dynamic>.from(decoded['data']);
|
||||
} else if (decoded['user'] is Map) {
|
||||
userData = Map<String, dynamic>.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 {
|
||||
|
|
|
|||
|
|
@ -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<String, String> get _headers {
|
||||
final headers = {
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
|
|
@ -22,7 +24,6 @@ class BookingService {
|
|||
return headers;
|
||||
}
|
||||
|
||||
// Get booking history
|
||||
Future<List<Booking>> 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<String, dynamic>.from(item as Map)),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (e) {
|
||||
// Error getting booking history silently
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Create booking (with required payment proof image)
|
||||
Future<Map<String, dynamic>> createBooking({
|
||||
required int kontrakanId,
|
||||
required DateTime tanggalMulai,
|
||||
required int durasiBulan,
|
||||
String? catatan,
|
||||
File? paymentProof,
|
||||
}) async {
|
||||
Future<Map<String, dynamic>> _createPengajuan(
|
||||
Map<String, dynamic> 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<String, dynamic>.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<Map<String, dynamic>> createSurvey({
|
||||
required int kontrakanId,
|
||||
required DateTime tanggalSurvei,
|
||||
required String jamSurvei,
|
||||
String? catatan,
|
||||
}) async {
|
||||
final body = <String, dynamic>{
|
||||
'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<Map<String, dynamic>> 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)
|
||||
: <String, dynamic>{};
|
||||
|
||||
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<Map<String, dynamic>> 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<Map<String, dynamic>> 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<Booking?> 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<String, dynamic>.from(data['data'] as Map),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
// Error getting booking detail silently
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Upload payment proof image
|
||||
Future<Map<String, dynamic>> 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<String, dynamic>.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<Uint8List?> getPaymentProofBytes(int bookingId) async {
|
||||
try {
|
||||
final headers = <String, String>{
|
||||
'Accept': 'image/*',
|
||||
};
|
||||
final headers = <String, String>{'Accept': 'image/*'};
|
||||
|
||||
if (_authService.token != null) {
|
||||
headers['Authorization'] = 'Bearer ${_authService.token}';
|
||||
|
|
@ -253,7 +342,7 @@ class BookingService {
|
|||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,12 +7,16 @@
|
|||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
flutter_secure_storage_linux
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
#include <geolocator_windows/geolocator_windows.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_windows
|
||||
firebase_core
|
||||
flutter_secure_storage_windows
|
||||
geolocator_windows
|
||||
url_launcher_windows
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue