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/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/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 id;
|
||||||
final int userId;
|
final int userId;
|
||||||
final int kontrakanId;
|
final int kontrakanId;
|
||||||
|
|
@ -7,7 +7,15 @@ class Booking {
|
||||||
final double totalBiaya;
|
final double totalBiaya;
|
||||||
final String status;
|
final String status;
|
||||||
final String? catatan;
|
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 paymentStatus;
|
||||||
final String? paymentProof;
|
final String? paymentProof;
|
||||||
|
|
||||||
|
|
@ -21,53 +29,107 @@ class Booking {
|
||||||
required this.status,
|
required this.status,
|
||||||
this.catatan,
|
this.catatan,
|
||||||
this.kontrakan,
|
this.kontrakan,
|
||||||
|
this.jenisPengajuan = 'sewa',
|
||||||
|
this.tanggalSurvei,
|
||||||
|
this.jamSurvei,
|
||||||
|
this.surveyFollowUpExpiresAt,
|
||||||
this.paymentStatus = 'unpaid',
|
this.paymentStatus = 'unpaid',
|
||||||
this.paymentProof,
|
this.paymentProof,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
static DateTime? _parseDate(dynamic value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
return DateTime.tryParse(value.toString());
|
||||||
|
}
|
||||||
|
|
||||||
factory Booking.fromJson(Map<String, dynamic> json) {
|
factory Booking.fromJson(Map<String, dynamic> json) {
|
||||||
// Support both old field names and actual DB column names
|
final startDate =
|
||||||
final startDate = json['start_date'] ?? json['tanggal_mulai'];
|
json['start_date'] ?? json['tanggal_mulai'] ?? json['tanggal_survei'];
|
||||||
final endDate = json['end_date'] ?? json['tanggal_selesai'];
|
final endDate =
|
||||||
|
json['end_date'] ?? json['tanggal_selesai'] ?? json['tanggal_survei'];
|
||||||
final amount = json['amount'] ?? json['total_biaya'];
|
final amount = json['amount'] ?? json['total_biaya'];
|
||||||
final notes = json['notes'] ?? json['catatan'];
|
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(
|
return Booking(
|
||||||
id: int.tryParse(json['id']?.toString() ?? '0') ?? 0,
|
id: int.tryParse(json['id']?.toString() ?? '0') ?? 0,
|
||||||
userId: int.tryParse(json['user_id']?.toString() ?? '0') ?? 0,
|
userId: int.tryParse(json['user_id']?.toString() ?? '0') ?? 0,
|
||||||
kontrakanId: int.tryParse(json['kontrakan_id']?.toString() ?? '0') ?? 0,
|
kontrakanId: int.tryParse(json['kontrakan_id']?.toString() ?? '0') ?? 0,
|
||||||
tanggalMulai: DateTime.parse(startDate),
|
tanggalMulai: parsedStartDate,
|
||||||
tanggalSelesai: DateTime.parse(endDate),
|
tanggalSelesai: parsedEndDate,
|
||||||
totalBiaya: double.tryParse(amount?.toString() ?? '0') ?? 0,
|
totalBiaya: double.tryParse(amount?.toString() ?? '0') ?? 0,
|
||||||
status: json['status'] ?? 'pending',
|
status: json['status']?.toString() ?? 'pending',
|
||||||
catatan: notes,
|
catatan: notes?.toString(),
|
||||||
kontrakan: json['kontrakan'],
|
kontrakan: json['kontrakan'],
|
||||||
paymentStatus: json['payment_status'] ?? 'unpaid',
|
jenisPengajuan: jenis,
|
||||||
paymentProof: json['payment_proof'],
|
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 {
|
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 {
|
int get durasiBulan {
|
||||||
return ((tanggalSelesai.year - tanggalMulai.year) * 12 +
|
if (isSurvei) return 0;
|
||||||
|
|
||||||
|
final totalBulan =
|
||||||
|
(tanggalSelesai.year - tanggalMulai.year) * 12 +
|
||||||
tanggalSelesai.month -
|
tanggalSelesai.month -
|
||||||
tanggalMulai.month)
|
tanggalMulai.month;
|
||||||
.clamp(1, 99);
|
|
||||||
|
return totalBulan.clamp(1, 99).toInt();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status badge color
|
|
||||||
String get statusColor {
|
String get statusColor {
|
||||||
switch (status.toLowerCase()) {
|
switch (status.toLowerCase()) {
|
||||||
case 'confirmed':
|
case 'confirmed':
|
||||||
return 'green';
|
|
||||||
case 'active':
|
|
||||||
return 'blue';
|
return 'blue';
|
||||||
|
case 'checked_in':
|
||||||
|
case 'active':
|
||||||
|
return 'green';
|
||||||
case 'completed':
|
case 'completed':
|
||||||
|
case 'expired':
|
||||||
return 'gray';
|
return 'gray';
|
||||||
case 'cancelled':
|
case 'cancelled':
|
||||||
return 'red';
|
return 'red';
|
||||||
|
|
@ -76,21 +138,60 @@ class Booking {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status label Indonesia
|
|
||||||
String get statusLabel {
|
String get statusLabel {
|
||||||
|
if (isSurvei) {
|
||||||
switch (status.toLowerCase()) {
|
switch (status.toLowerCase()) {
|
||||||
case 'pending':
|
case 'pending':
|
||||||
return 'Menunggu';
|
return 'Menunggu Konfirmasi Survei';
|
||||||
case 'confirmed':
|
case 'confirmed':
|
||||||
return 'Dikonfirmasi';
|
return 'Survei Disetujui';
|
||||||
case 'active':
|
|
||||||
return 'Aktif';
|
|
||||||
case 'completed':
|
case 'completed':
|
||||||
return 'Selesai';
|
return 'Survei Selesai';
|
||||||
case 'cancelled':
|
case 'cancelled':
|
||||||
return 'Dibatalkan';
|
return 'Tidak Jadi Sewa';
|
||||||
|
case 'expired':
|
||||||
|
return 'Masa Tindak Lanjut Berakhir';
|
||||||
default:
|
default:
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch (status.toLowerCase()) {
|
||||||
|
case 'pending':
|
||||||
|
return 'Menunggu Persetujuan Sewa';
|
||||||
|
case 'confirmed':
|
||||||
|
switch (paymentStatus.toLowerCase()) {
|
||||||
|
case 'paid':
|
||||||
|
return 'Sewa Disetujui';
|
||||||
|
case 'verification':
|
||||||
|
return 'Menunggu Verifikasi Pembayaran';
|
||||||
|
default:
|
||||||
|
return 'Menunggu Pembayaran';
|
||||||
|
}
|
||||||
|
case 'checked_in':
|
||||||
|
case 'active':
|
||||||
|
return 'Sedang Ditempati';
|
||||||
|
case 'completed':
|
||||||
|
return 'Sewa Selesai';
|
||||||
|
case 'cancelled':
|
||||||
|
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) {
|
factory User.fromJson(Map<String, dynamic> json) {
|
||||||
return User(
|
return User(
|
||||||
id: json['id'] ?? 0,
|
id: json['id'] ?? 0,
|
||||||
name: json['name'] ?? '',
|
name: (json['name'] ?? json['nama'] ?? json['username'] ?? '').toString(),
|
||||||
email: json['email'] ?? '',
|
email: (json['email'] ?? json['email_address'] ?? '').toString(),
|
||||||
phone: json['phone'],
|
phone: (json['phone'] ?? json['no_hp'] ?? json['no_telepon'])?.toString(),
|
||||||
role: json['role'] ?? 'user',
|
role: json['role'] ?? 'user',
|
||||||
roleLabel: json['role_label'],
|
roleLabel: json['role_label'],
|
||||||
userType: json['user_type'],
|
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:flutter/material.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import '../services/booking_service.dart';
|
import '../services/booking_service.dart';
|
||||||
|
import 'booking_form_screen.dart';
|
||||||
|
import '../models/kontrakan.dart';
|
||||||
import '../models/booking.dart';
|
import '../models/booking.dart';
|
||||||
|
|
||||||
// ignore_for_file: deprecated_member_use
|
// ignore_for_file: deprecated_member_use
|
||||||
|
|
@ -54,12 +56,22 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
||||||
final bookings = await _bookingService.getBookingHistory();
|
final bookings = await _bookingService.getBookingHistory();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_activeBookings = bookings
|
_activeBookings = bookings.where((b) {
|
||||||
.where((b) => b.status == 'confirmed' || b.status == 'pending')
|
final status = b.status.toLowerCase();
|
||||||
.toList();
|
|
||||||
_pastBookings = bookings
|
return status == 'pending' ||
|
||||||
.where((b) => b.status == 'completed' || b.status == 'cancelled')
|
status == 'confirmed' ||
|
||||||
.toList();
|
status == 'checked_in';
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
_pastBookings = bookings.where((b) {
|
||||||
|
final status = b.status.toLowerCase();
|
||||||
|
|
||||||
|
return status == 'completed' ||
|
||||||
|
status == 'cancelled' ||
|
||||||
|
status == 'expired';
|
||||||
|
}).toList();
|
||||||
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -68,7 +80,7 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: const Text('Gagal memuat riwayat booking'),
|
content: const Text('Gagal memuat riwayat pengajuan'),
|
||||||
backgroundColor: Colors.red[700],
|
backgroundColor: Colors.red[700],
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
shape: RoundedRectangleBorder(
|
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>(
|
final confirm = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
|
|
@ -93,13 +107,18 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
||||||
size: 24,
|
size: 24,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
const Text(
|
Expanded(
|
||||||
'Batalkan Booking',
|
child: Text(
|
||||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
'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: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(ctx, false),
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
|
@ -120,10 +139,14 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirm != true) return;
|
if (confirm != true) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final result = await _bookingService.cancelBooking(bookingId);
|
final result = await _bookingService.cancelBooking(booking.id);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Row(
|
content: Row(
|
||||||
|
|
@ -149,12 +172,16 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
||||||
margin: const EdgeInsets.all(16),
|
margin: const EdgeInsets.all(16),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (result['success'] == true) _loadBookings();
|
|
||||||
|
if (result['success'] == true) {
|
||||||
|
_loadBookings();
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Gagal membatalkan booking: $e'),
|
content: Text('Gagal membatalkan pengajuan: $e'),
|
||||||
backgroundColor: const Color(0xFFC62828),
|
backgroundColor: const Color(0xFFC62828),
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
shape: RoundedRectangleBorder(
|
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 {
|
Future<void> _uploadPaymentProof(Booking booking) async {
|
||||||
// Show source dialog
|
// Show source dialog
|
||||||
final source = await showModalBottomSheet<ImageSource>(
|
final source = await showModalBottomSheet<ImageSource>(
|
||||||
|
|
@ -356,7 +556,7 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Booking Saya',
|
'Pengajuan Saya',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 22,
|
fontSize: 22,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
|
@ -366,7 +566,7 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
||||||
),
|
),
|
||||||
SizedBox(height: 2),
|
SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
'Pantau status aktif dan riwayat booking',
|
'Pantau status survei dan pengajuan sewa',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: Colors.white70,
|
color: Colors.white70,
|
||||||
|
|
@ -441,7 +641,7 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Center(
|
Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
isActive ? 'Belum ada booking aktif' : 'Belum ada riwayat',
|
isActive ? 'Belum ada pengajuan aktif' : 'Belum ada riwayat',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
|
@ -453,8 +653,8 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
||||||
Center(
|
Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
isActive
|
isActive
|
||||||
? 'Booking kontrakan Anda akan muncul di sini'
|
? 'Pengajuan survei atau sewa Anda akan muncul di sini'
|
||||||
: 'Riwayat booking sebelumnya akan muncul di sini',
|
: 'Riwayat pengajuan sebelumnya akan muncul di sini',
|
||||||
style: TextStyle(fontSize: 13, color: Colors.grey[500]),
|
style: TextStyle(fontSize: 13, color: Colors.grey[500]),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
|
|
@ -482,33 +682,41 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
||||||
String statusText;
|
String statusText;
|
||||||
IconData statusIcon;
|
IconData statusIcon;
|
||||||
|
|
||||||
switch (booking.status) {
|
switch (booking.status.toLowerCase()) {
|
||||||
case 'pending':
|
case 'pending':
|
||||||
statusColor = const Color(0xFFF57C00);
|
statusColor = const Color(0xFFF57C00);
|
||||||
statusText = 'Menunggu';
|
|
||||||
statusIcon = Icons.schedule_rounded;
|
statusIcon = Icons.schedule_rounded;
|
||||||
break;
|
break;
|
||||||
case 'confirmed':
|
case 'confirmed':
|
||||||
statusColor = const Color(0xFF2E7D32);
|
statusColor = const Color(0xFF2E7D32);
|
||||||
statusText = 'Dikonfirmasi';
|
|
||||||
statusIcon = Icons.check_circle_rounded;
|
statusIcon = Icons.check_circle_rounded;
|
||||||
break;
|
break;
|
||||||
|
case 'checked_in':
|
||||||
|
case 'active':
|
||||||
|
statusColor = const Color(0xFF1565C0);
|
||||||
|
statusIcon = Icons.home_rounded;
|
||||||
|
break;
|
||||||
case 'completed':
|
case 'completed':
|
||||||
statusColor = const Color(0xFF1565C0);
|
statusColor = const Color(0xFF1565C0);
|
||||||
statusText = 'Selesai';
|
|
||||||
statusIcon = Icons.done_all_rounded;
|
statusIcon = Icons.done_all_rounded;
|
||||||
break;
|
break;
|
||||||
|
case 'expired':
|
||||||
|
statusColor = Colors.grey.shade700;
|
||||||
|
statusIcon = Icons.timer_off_rounded;
|
||||||
|
break;
|
||||||
|
|
||||||
case 'cancelled':
|
case 'cancelled':
|
||||||
statusColor = const Color(0xFFC62828);
|
statusColor = const Color(0xFFC62828);
|
||||||
statusText = 'Dibatalkan';
|
|
||||||
statusIcon = Icons.cancel_rounded;
|
statusIcon = Icons.cancel_rounded;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
statusColor = Colors.grey;
|
statusColor = Colors.grey;
|
||||||
statusText = 'Unknown';
|
|
||||||
statusIcon = Icons.help_rounded;
|
statusIcon = Icons.help_rounded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
statusText = booking.statusLabel;
|
||||||
|
|
||||||
// Get kontrakan name if available
|
// Get kontrakan name if available
|
||||||
String kontrakanName = 'Kontrakan';
|
String kontrakanName = 'Kontrakan';
|
||||||
if (booking.kontrakan is Map) {
|
if (booking.kontrakan is Map) {
|
||||||
|
|
@ -571,7 +779,7 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
'ID: #${booking.id}',
|
"ID: #${booking.id} • ${booking.isSurvei ? 'Survei' : 'Sewa'}",
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: Colors.grey[500],
|
color: Colors.grey[500],
|
||||||
|
|
@ -615,7 +823,9 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Date info in a compact row
|
if (booking.isSurvei)
|
||||||
|
_buildSurveyScheduleCard(booking)
|
||||||
|
else ...[
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -645,15 +855,12 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
||||||
_buildDateRow(
|
_buildDateRow(
|
||||||
Icons.timelapse_rounded,
|
Icons.timelapse_rounded,
|
||||||
'Durasi',
|
'Durasi',
|
||||||
'${booking.durasiBulan} Bulan',
|
'${booking.durasiBulan} bulan',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
|
|
||||||
// Price and Payment
|
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -661,7 +868,7 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Total Harga',
|
'Estimasi Biaya',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: Colors.grey[500],
|
color: Colors.grey[500],
|
||||||
|
|
@ -679,55 +886,18 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Container(
|
_buildPaymentChip(booking),
|
||||||
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,
|
|
||||||
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',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: booking.paymentStatus == 'paid'
|
|
||||||
? const Color(0xFF2E7D32)
|
|
||||||
: const Color(0xFFF57C00),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
|
||||||
|
|
||||||
// Payment proof
|
// Payment proof
|
||||||
if (booking.paymentProof != null) ...[
|
if (!booking.isSurvei && booking.paymentProof != null) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
final Future<Uint8List?> future =
|
final Future<Uint8List?> future = _bookingService
|
||||||
_bookingService.getPaymentProofBytes(booking.id);
|
.getPaymentProofBytes(booking.id);
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => Dialog(
|
builder: (ctx) => Dialog(
|
||||||
|
|
@ -875,15 +1045,23 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
if (booking.isSurveyFollowUpActive) ...[
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
_buildSurveyFollowUpActions(booking),
|
||||||
|
],
|
||||||
// Actions
|
// Actions
|
||||||
if (booking.status == 'pending') ...[
|
if (booking.status == 'pending') ...[
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: () => _cancelBooking(booking.id),
|
onPressed: () => _cancelBooking(booking),
|
||||||
icon: const Icon(Icons.close_rounded, size: 18),
|
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(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: const Color(0xFFC62828),
|
foregroundColor: const Color(0xFFC62828),
|
||||||
side: const BorderSide(color: Color(0xFFEF9A9A)),
|
side: const BorderSide(color: Color(0xFFEF9A9A)),
|
||||||
|
|
@ -895,8 +1073,7 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (booking.status == 'confirmed' &&
|
if (booking.canUploadPaymentProof) ...[
|
||||||
booking.paymentStatus == 'unpaid') ...[
|
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
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) {
|
Widget _buildDateRow(IconData icon, String label, String value) {
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -981,6 +1259,35 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
||||||
return '${date.day} ${months[date.month - 1]} ${date.year}';
|
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) {
|
String _formatPrice(double price) {
|
||||||
return price
|
return price
|
||||||
.toStringAsFixed(0)
|
.toStringAsFixed(0)
|
||||||
|
|
|
||||||
|
|
@ -395,28 +395,7 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: widget.kontrakan.isAvailable
|
onPressed: widget.kontrakan.isAvailable
|
||||||
? () {
|
? _showPengajuanOptions
|
||||||
final authService = AuthService();
|
|
||||||
if (!authService.isAuthenticated) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text(
|
|
||||||
'Silakan login terlebih dahulu untuk booking',
|
|
||||||
),
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => BookingFormScreen(
|
|
||||||
kontrakan: widget.kontrakan,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
: null,
|
: null,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: widget.kontrakan.isAvailable
|
backgroundColor: widget.kontrakan.isAvailable
|
||||||
|
|
@ -433,7 +412,7 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
widget.kontrakan.isAvailable
|
widget.kontrakan.isAvailable
|
||||||
? 'Ajukan Booking'
|
? 'Ajukan Pengajuan'
|
||||||
: 'Sedang Penuh',
|
: 'Sedang Penuh',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 15,
|
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() {
|
Widget _buildGallery() {
|
||||||
if (!widget.kontrakan.hasPhoto) {
|
if (!widget.kontrakan.hasPhoto) {
|
||||||
return _buildMissingPhoto();
|
return _buildMissingPhoto();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
@ -21,7 +21,7 @@ class AuthService {
|
||||||
// HttpClient - lazy initialization
|
// HttpClient - lazy initialization
|
||||||
HttpClient? _httpClient;
|
HttpClient? _httpClient;
|
||||||
|
|
||||||
// ✅ Secure storage for sensitive data (token & user)
|
// ✅ Secure storage for sensitive data (token & user)
|
||||||
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(
|
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(
|
||||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||||
iOptions: IOSOptions(
|
iOptions: IOSOptions(
|
||||||
|
|
@ -39,7 +39,7 @@ class AuthService {
|
||||||
if (_httpClient == null) {
|
if (_httpClient == null) {
|
||||||
_httpClient = HttpClient();
|
_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.
|
// In debug builds, allow self-signed certs only for local dev hosts.
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
_httpClient!.badCertificateCallback = (cert, host, port) {
|
_httpClient!.badCertificateCallback = (cert, host, port) {
|
||||||
|
|
@ -93,7 +93,7 @@ class AuthService {
|
||||||
_currentUser = User.fromJson(jsonDecode(userJson));
|
_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) {
|
if (_token == null) {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final legacyToken = prefs.getString(AppConfig.tokenKey);
|
final legacyToken = prefs.getString(AppConfig.tokenKey);
|
||||||
|
|
@ -505,28 +505,60 @@ class AuthService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get current user
|
||||||
// Get current user
|
// Get current user
|
||||||
Future<User?> getCurrentUser() async {
|
Future<User?> getCurrentUser() async {
|
||||||
if (_token == null) return null;
|
if (_token == null) {
|
||||||
|
return _currentUser;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await http.get(
|
final response = await http
|
||||||
|
.get(
|
||||||
Uri.parse('${AppConfig.baseUrl}/user'),
|
Uri.parse('${AppConfig.baseUrl}/user'),
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
'Authorization': 'Bearer $_token',
|
'Authorization': 'Bearer $_token',
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
|
.timeout(AppConfig.connectionTimeout);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200 && response.body.isNotEmpty) {
|
||||||
final user = User.fromJson(jsonDecode(response.body));
|
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;
|
_currentUser = user;
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(AppConfig.userKey, jsonEncode(user.toJson()));
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
return null;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tetap tampilkan data yang tersimpan bila server tidak memberi profil valid.
|
||||||
|
return _currentUser;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return null;
|
return _currentUser;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -551,8 +583,10 @@ class AuthService {
|
||||||
|
|
||||||
if (response.statusCode == 200 && data['success'] == true) {
|
if (response.statusCode == 200 && data['success'] == true) {
|
||||||
final user = User.fromJson(data['data']);
|
final user = User.fromJson(data['data']);
|
||||||
final prefs = await SharedPreferences.getInstance();
|
await _secureStorage.write(
|
||||||
await prefs.setString(AppConfig.userKey, jsonEncode(user.toJson()));
|
key: AppConfig.userKey,
|
||||||
|
value: jsonEncode(user.toJson()),
|
||||||
|
);
|
||||||
_currentUser = user;
|
_currentUser = user;
|
||||||
return {'success': true, 'message': data['message']};
|
return {'success': true, 'message': data['message']};
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../config/app_config.dart';
|
import '../config/app_config.dart';
|
||||||
import '../models/booking.dart';
|
import '../models/booking.dart';
|
||||||
import 'auth_service.dart';
|
import 'auth_service.dart';
|
||||||
|
|
@ -10,7 +12,7 @@ class BookingService {
|
||||||
final AuthService _authService = AuthService();
|
final AuthService _authService = AuthService();
|
||||||
|
|
||||||
Map<String, String> get _headers {
|
Map<String, String> get _headers {
|
||||||
final headers = {
|
final headers = <String, String>{
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
};
|
};
|
||||||
|
|
@ -22,7 +24,6 @@ class BookingService {
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get booking history
|
|
||||||
Future<List<Booking>> getBookingHistory() async {
|
Future<List<Booking>> getBookingHistory() async {
|
||||||
try {
|
try {
|
||||||
final response = await http.get(
|
final response = await http.get(
|
||||||
|
|
@ -37,63 +38,138 @@ class BookingService {
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final data = jsonDecode(response.body);
|
final data = jsonDecode(response.body);
|
||||||
|
|
||||||
if (data['success'] == true) {
|
if (data['success'] == true) {
|
||||||
final List items = data['data']['data'] ?? data['data'];
|
final List items = data['data']['data'] ?? data['data'] ?? [];
|
||||||
return items.map((json) => Booking.fromJson(json)).toList();
|
|
||||||
|
return items
|
||||||
|
.map(
|
||||||
|
(item) =>
|
||||||
|
Booking.fromJson(Map<String, dynamic>.from(item as Map)),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
} catch (e) {
|
} catch (_) {
|
||||||
// Error getting booking history silently
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create booking (with required payment proof image)
|
Future<Map<String, dynamic>> _createPengajuan(
|
||||||
Future<Map<String, dynamic>> createBooking({
|
Map<String, dynamic> body,
|
||||||
required int kontrakanId,
|
) async {
|
||||||
required DateTime tanggalMulai,
|
|
||||||
required int durasiBulan,
|
|
||||||
String? catatan,
|
|
||||||
File? paymentProof,
|
|
||||||
}) async {
|
|
||||||
try {
|
try {
|
||||||
// Validate payment proof is provided
|
final response = await http
|
||||||
if (paymentProof == null) {
|
.post(
|
||||||
return {'success': false, 'message': 'Bukti pembayaran wajib diunggah'};
|
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);
|
final data = jsonDecode(response.body);
|
||||||
|
|
||||||
if (response.statusCode == 401) {
|
if (response.statusCode == 401) {
|
||||||
await _authService.handleUnauthorized(response.statusCode);
|
await _authService.handleUnauthorized(response.statusCode);
|
||||||
return {
|
return {
|
||||||
'success': false,
|
'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'],
|
'message': data['message'],
|
||||||
'booking': Booking.fromJson(data['data']),
|
'booking': Booking.fromJson(data['data']),
|
||||||
};
|
};
|
||||||
} else {
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'success': false,
|
'success': false,
|
||||||
'message': data['message'] ?? 'Gagal membuat booking',
|
'message': data['message'] ?? 'Gagal mengirim pengajuan sewa.',
|
||||||
'errors': data['errors'],
|
'errors': data['errors'],
|
||||||
};
|
};
|
||||||
}
|
|
||||||
} catch (e) {
|
} 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 {
|
Future<Map<String, dynamic>> cancelBooking(int bookingId) async {
|
||||||
try {
|
try {
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
|
|
@ -129,24 +219,23 @@ class BookingService {
|
||||||
await _authService.handleUnauthorized(response.statusCode);
|
await _authService.handleUnauthorized(response.statusCode);
|
||||||
return {
|
return {
|
||||||
'success': false,
|
'success': false,
|
||||||
'message': 'Sesi expired, silakan login ulang',
|
'message': 'Sesi habis, silakan login ulang.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.statusCode == 200 && data['success'] == true) {
|
if (response.statusCode == 200 && data['success'] == true) {
|
||||||
return {'success': true, 'message': data['message']};
|
return {'success': true, 'message': data['message']};
|
||||||
} else {
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'success': false,
|
'success': false,
|
||||||
'message': data['message'] ?? 'Gagal membatalkan booking',
|
'message': data['message'] ?? 'Gagal membatalkan pengajuan.',
|
||||||
};
|
};
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {'success': false, 'message': 'Error: $e'};
|
return {'success': false, 'message': 'Terjadi kesalahan: $e'};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get booking detail
|
|
||||||
Future<Booking?> getBookingById(int id) async {
|
Future<Booking?> getBookingById(int id) async {
|
||||||
try {
|
try {
|
||||||
final response = await http.get(
|
final response = await http.get(
|
||||||
|
|
@ -161,35 +250,35 @@ class BookingService {
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final data = jsonDecode(response.body);
|
final data = jsonDecode(response.body);
|
||||||
|
|
||||||
if (data['success'] == true) {
|
if (data['success'] == true) {
|
||||||
return Booking.fromJson(data['data']);
|
return Booking.fromJson(
|
||||||
|
Map<String, dynamic>.from(data['data'] as Map),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
} catch (e) {
|
} catch (_) {
|
||||||
// Error getting booking detail silently
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload payment proof image
|
|
||||||
Future<Map<String, dynamic>> uploadPaymentProof(
|
Future<Map<String, dynamic>> uploadPaymentProof(
|
||||||
int bookingId,
|
int bookingId,
|
||||||
File imageFile,
|
File imageFile,
|
||||||
) async {
|
) async {
|
||||||
try {
|
try {
|
||||||
final uri = Uri.parse(
|
final request = http.MultipartRequest(
|
||||||
'${AppConfig.baseUrl}/bookings/$bookingId/payment-proof',
|
'POST',
|
||||||
|
Uri.parse('${AppConfig.baseUrl}/bookings/$bookingId/payment-proof'),
|
||||||
);
|
);
|
||||||
final request = http.MultipartRequest('POST', uri);
|
|
||||||
|
|
||||||
// Add auth header
|
|
||||||
if (_authService.token != null) {
|
if (_authService.token != null) {
|
||||||
request.headers['Authorization'] = 'Bearer ${_authService.token}';
|
request.headers['Authorization'] = 'Bearer ${_authService.token}';
|
||||||
request.headers['Accept'] = 'application/json';
|
request.headers['Accept'] = 'application/json';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach image file
|
|
||||||
request.files.add(
|
request.files.add(
|
||||||
await http.MultipartFile.fromPath('payment_proof', imageFile.path),
|
await http.MultipartFile.fromPath('payment_proof', imageFile.path),
|
||||||
);
|
);
|
||||||
|
|
@ -197,6 +286,7 @@ class BookingService {
|
||||||
final streamedResponse = await request.send().timeout(
|
final streamedResponse = await request.send().timeout(
|
||||||
const Duration(seconds: 30),
|
const Duration(seconds: 30),
|
||||||
);
|
);
|
||||||
|
|
||||||
final response = await http.Response.fromStream(streamedResponse);
|
final response = await http.Response.fromStream(streamedResponse);
|
||||||
final data = jsonDecode(response.body);
|
final data = jsonDecode(response.body);
|
||||||
|
|
||||||
|
|
@ -204,33 +294,32 @@ class BookingService {
|
||||||
await _authService.handleUnauthorized(response.statusCode);
|
await _authService.handleUnauthorized(response.statusCode);
|
||||||
return {
|
return {
|
||||||
'success': false,
|
'success': false,
|
||||||
'message': 'Sesi expired, silakan login ulang',
|
'message': 'Sesi habis, silakan login ulang.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.statusCode == 200 && data['success'] == true) {
|
if (response.statusCode == 200 && data['success'] == true) {
|
||||||
return {
|
return {
|
||||||
'success': true,
|
'success': true,
|
||||||
'message': data['message'] ?? 'Bukti pembayaran berhasil diunggah',
|
'message': data['message'] ?? 'Bukti pembayaran berhasil diunggah.',
|
||||||
'booking': Booking.fromJson(data['data']),
|
'booking': Booking.fromJson(
|
||||||
};
|
Map<String, dynamic>.from(data['data'] as Map),
|
||||||
} else {
|
),
|
||||||
return {
|
|
||||||
'success': false,
|
|
||||||
'message': data['message'] ?? 'Gagal mengunggah bukti pembayaran',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': false,
|
||||||
|
'message': data['message'] ?? 'Gagal mengunggah bukti pembayaran.',
|
||||||
|
};
|
||||||
} catch (e) {
|
} 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 {
|
Future<Uint8List?> getPaymentProofBytes(int bookingId) async {
|
||||||
try {
|
try {
|
||||||
final headers = <String, String>{
|
final headers = <String, String>{'Accept': 'image/*'};
|
||||||
'Accept': 'image/*',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (_authService.token != null) {
|
if (_authService.token != null) {
|
||||||
headers['Authorization'] = 'Bearer ${_authService.token}';
|
headers['Authorization'] = 'Bearer ${_authService.token}';
|
||||||
|
|
@ -253,7 +342,7 @@ class BookingService {
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
} catch (e) {
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,16 @@
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <file_selector_linux/file_selector_plugin.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>
|
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
||||||
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
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 =
|
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
file_selector_linux
|
file_selector_linux
|
||||||
|
flutter_secure_storage_linux
|
||||||
url_launcher_linux
|
url_launcher_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import file_selector_macos
|
||||||
import firebase_core
|
import firebase_core
|
||||||
import firebase_messaging
|
import firebase_messaging
|
||||||
import flutter_local_notifications
|
import flutter_local_notifications
|
||||||
|
import flutter_secure_storage_macos
|
||||||
import geolocator_apple
|
import geolocator_apple
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import sqflite_darwin
|
import sqflite_darwin
|
||||||
|
|
@ -19,6 +20,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||||
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
||||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
|
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||||
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
#include <file_selector_windows/file_selector_windows.h>
|
#include <file_selector_windows/file_selector_windows.h>
|
||||||
#include <firebase_core/firebase_core_plugin_c_api.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 <geolocator_windows/geolocator_windows.h>
|
||||||
#include <url_launcher_windows/url_launcher_windows.h>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
||||||
|
|
@ -16,6 +17,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||||
|
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||||
GeolocatorWindowsRegisterWithRegistrar(
|
GeolocatorWindowsRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("GeolocatorWindows"));
|
registry->GetRegistrarForPlugin("GeolocatorWindows"));
|
||||||
UrlLauncherWindowsRegisterWithRegistrar(
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
file_selector_windows
|
file_selector_windows
|
||||||
firebase_core
|
firebase_core
|
||||||
|
flutter_secure_storage_windows
|
||||||
geolocator_windows
|
geolocator_windows
|
||||||
url_launcher_windows
|
url_launcher_windows
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue