Final TA - Booking flow improvements

This commit is contained in:
micko samawa 2026-06-28 01:03:47 +07:00
parent 3e6188bfaa
commit 2a03df835f
13 changed files with 1641 additions and 804 deletions

13
spk_mobile/.gitignore vendored
View File

@ -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

View File

@ -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;
tanggalSelesai.month -
tanggalMulai.month) final totalBulan =
.clamp(1, 99); (tanggalSelesai.year - tanggalMulai.year) * 12 +
tanggalSelesai.month -
tanggalMulai.month;
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()) {
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()) { switch (status.toLowerCase()) {
case 'pending': case 'pending':
return 'Menunggu'; return 'Menunggu Persetujuan Sewa';
case 'confirmed': 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': case 'active':
return 'Aktif'; return 'Sedang Ditempati';
case 'completed': case 'completed':
return 'Selesai'; return 'Sewa Selesai';
case 'cancelled': case 'cancelled':
return 'Dibatalkan'; return 'Pengajuan Sewa Dibatalkan';
default: default:
return status; 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';
}
}
} }

View File

@ -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

View File

@ -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,119 +823,81 @@ 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)
Container( _buildSurveyScheduleCard(booking)
padding: const EdgeInsets.all(12), else ...[
decoration: BoxDecoration( Container(
color: const Color(0xFFF7F8FC), padding: const EdgeInsets.all(12),
borderRadius: BorderRadius.circular(12), decoration: BoxDecoration(
), color: const Color(0xFFF7F8FC),
child: Column( borderRadius: BorderRadius.circular(12),
children: [ ),
_buildDateRow( child: Column(
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,
children: [ children: [
Text( _buildDateRow(
'Total Harga', Icons.login_rounded,
style: TextStyle( 'Mulai',
fontSize: 12, _formatDate(booking.tanggalMulai),
color: Colors.grey[500],
fontWeight: FontWeight.w500,
),
), ),
const SizedBox(height: 2), Padding(
Text( padding: const EdgeInsets.symmetric(vertical: 8),
'Rp ${_formatPrice(booking.totalBiaya)}', child: Divider(height: 1, color: Colors.grey[200]),
style: const TextStyle( ),
fontSize: 20, _buildDateRow(
fontWeight: FontWeight.w800, Icons.logout_rounded,
color: Color(0xFF1565C0), '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( const SizedBox(height: 14),
horizontal: 12, Row(
vertical: 6, mainAxisAlignment: MainAxisAlignment.spaceBetween,
), children: [
decoration: BoxDecoration( Column(
color: booking.paymentStatus == 'paid' crossAxisAlignment: CrossAxisAlignment.start,
? const Color(0xFFE8F5E9)
: const Color(0xFFFFF3E0),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [ 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( Text(
booking.paymentStatus == 'paid' 'Estimasi Biaya',
? 'Lunas'
: 'Belum Bayar',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w600, color: Colors.grey[500],
color: booking.paymentStatus == 'paid' fontWeight: FontWeight.w500,
? const Color(0xFF2E7D32) ),
: const Color(0xFFF57C00), ),
const SizedBox(height: 2),
Text(
'Rp ${_formatPrice(booking.totalBiaya)}',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w800,
color: Color(0xFF1565C0),
), ),
), ),
], ],
), ),
), _buildPaymentChip(booking),
], ],
), ),
],
// 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)

View File

@ -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();

View File

@ -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
Uri.parse('${AppConfig.baseUrl}/user'), .get(
headers: { Uri.parse('${AppConfig.baseUrl}/user'),
'Content-Type': 'application/json', headers: {
'Accept': 'application/json', 'Content-Type': 'application/json',
'Authorization': 'Bearer $_token', 'Accept': 'application/json',
}, '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);
_currentUser = user;
return user; // 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) { } 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 {

View File

@ -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 {
'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) { } 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 {
'success': false,
'message': data['message'] ?? 'Gagal membatalkan booking',
};
} }
return {
'success': false,
'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;
} }
} }

View File

@ -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);

View File

@ -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
) )

View File

@ -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"))

View File

@ -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(

View File

@ -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
) )