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/profile
/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 userId;
final int kontrakanId;
@ -7,7 +7,15 @@ class Booking {
final double totalBiaya;
final String status;
final String? catatan;
final dynamic kontrakan; // Can be Map or Kontrakan object
final dynamic kontrakan;
// Pembeda pengajuan survei dan sewa.
final String jenisPengajuan;
final DateTime? tanggalSurvei;
final String? jamSurvei;
final DateTime? surveyFollowUpExpiresAt;
// Pembayaran hanya digunakan pada pengajuan sewa.
final String paymentStatus;
final String? paymentProof;
@ -21,53 +29,107 @@ class Booking {
required this.status,
this.catatan,
this.kontrakan,
this.jenisPengajuan = 'sewa',
this.tanggalSurvei,
this.jamSurvei,
this.surveyFollowUpExpiresAt,
this.paymentStatus = 'unpaid',
this.paymentProof,
});
static DateTime? _parseDate(dynamic value) {
if (value == null) return null;
return DateTime.tryParse(value.toString());
}
factory Booking.fromJson(Map<String, dynamic> json) {
// Support both old field names and actual DB column names
final startDate = json['start_date'] ?? json['tanggal_mulai'];
final endDate = json['end_date'] ?? json['tanggal_selesai'];
final startDate =
json['start_date'] ?? json['tanggal_mulai'] ?? json['tanggal_survei'];
final endDate =
json['end_date'] ?? json['tanggal_selesai'] ?? json['tanggal_survei'];
final amount = json['amount'] ?? json['total_biaya'];
final notes = json['notes'] ?? json['catatan'];
final jenis = (json['jenis_pengajuan'] ?? 'sewa').toString().toLowerCase();
final parsedSurveyDate = _parseDate(json['tanggal_survei']);
final parsedStartDate =
_parseDate(startDate) ?? parsedSurveyDate ?? DateTime.now();
final parsedEndDate = _parseDate(endDate) ?? parsedStartDate;
return Booking(
id: int.tryParse(json['id']?.toString() ?? '0') ?? 0,
userId: int.tryParse(json['user_id']?.toString() ?? '0') ?? 0,
kontrakanId: int.tryParse(json['kontrakan_id']?.toString() ?? '0') ?? 0,
tanggalMulai: DateTime.parse(startDate),
tanggalSelesai: DateTime.parse(endDate),
tanggalMulai: parsedStartDate,
tanggalSelesai: parsedEndDate,
totalBiaya: double.tryParse(amount?.toString() ?? '0') ?? 0,
status: json['status'] ?? 'pending',
catatan: notes,
status: json['status']?.toString() ?? 'pending',
catatan: notes?.toString(),
kontrakan: json['kontrakan'],
paymentStatus: json['payment_status'] ?? 'unpaid',
paymentProof: json['payment_proof'],
jenisPengajuan: jenis,
tanggalSurvei: parsedSurveyDate,
jamSurvei: json['jam_survei']?.toString(),
surveyFollowUpExpiresAt: _parseDate(
json['survey_follow_up_expires_at'],
),
paymentStatus: json['payment_status']?.toString() ?? 'unpaid',
paymentProof: json['payment_proof']?.toString(),
);
}
// Format total biaya
bool get isSurvei => jenisPengajuan == 'survei';
bool get isSewa => !isSurvei;
bool get isSurveyFollowUpActive =>
isSurvei && status.toLowerCase() == 'confirmed';
String get jenisLabel {
return isSurvei ? 'Pengajuan Survei' : 'Pengajuan Sewa';
}
bool get canUploadPaymentProof {
return isSewa &&
status.toLowerCase() == 'confirmed' &&
paymentStatus == 'unpaid';
}
bool get isWaitingPaymentVerification {
return isSewa && paymentStatus == 'verification';
}
String get formattedTotalBiaya {
return 'Rp ${totalBiaya.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}';
if (isSurvei) return '-';
final nominal = totalBiaya.toStringAsFixed(0).replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
(Match match) => '${match[1]}.',
);
return 'Rp $nominal';
}
// Computed duration in months from start and end date
int get durasiBulan {
return ((tanggalSelesai.year - tanggalMulai.year) * 12 +
tanggalSelesai.month -
tanggalMulai.month)
.clamp(1, 99);
if (isSurvei) return 0;
final totalBulan =
(tanggalSelesai.year - tanggalMulai.year) * 12 +
tanggalSelesai.month -
tanggalMulai.month;
return totalBulan.clamp(1, 99).toInt();
}
// Status badge color
String get statusColor {
switch (status.toLowerCase()) {
case 'confirmed':
return 'green';
case 'active':
return 'blue';
case 'checked_in':
case 'active':
return 'green';
case 'completed':
case 'expired':
return 'gray';
case 'cancelled':
return 'red';
@ -76,21 +138,60 @@ class Booking {
}
}
// Status label Indonesia
String get statusLabel {
if (isSurvei) {
switch (status.toLowerCase()) {
case 'pending':
return 'Menunggu Konfirmasi Survei';
case 'confirmed':
return 'Survei Disetujui';
case 'completed':
return 'Survei Selesai';
case 'cancelled':
return 'Tidak Jadi Sewa';
case 'expired':
return 'Masa Tindak Lanjut Berakhir';
default:
return status;
}
}
switch (status.toLowerCase()) {
case 'pending':
return 'Menunggu';
return 'Menunggu Persetujuan Sewa';
case 'confirmed':
return 'Dikonfirmasi';
switch (paymentStatus.toLowerCase()) {
case 'paid':
return 'Sewa Disetujui';
case 'verification':
return 'Menunggu Verifikasi Pembayaran';
default:
return 'Menunggu Pembayaran';
}
case 'checked_in':
case 'active':
return 'Aktif';
return 'Sedang Ditempati';
case 'completed':
return 'Selesai';
return 'Sewa Selesai';
case 'cancelled':
return 'Dibatalkan';
return 'Pengajuan Sewa Dibatalkan';
default:
return status;
}
}
String get paymentStatusLabel {
if (isSurvei) return '-';
switch (paymentStatus.toLowerCase()) {
case 'paid':
return 'Lunas';
case 'verification':
return 'Menunggu Verifikasi';
case 'refunded':
return 'Dikembalikan';
default:
return 'Belum Bayar';
}
}
}

View File

@ -22,9 +22,9 @@ class User {
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] ?? 0,
name: json['name'] ?? '',
email: json['email'] ?? '',
phone: json['phone'],
name: (json['name'] ?? json['nama'] ?? json['username'] ?? '').toString(),
email: (json['email'] ?? json['email_address'] ?? '').toString(),
phone: (json['phone'] ?? json['no_hp'] ?? json['no_telepon'])?.toString(),
role: json['role'] ?? 'user',
roleLabel: json['role_label'],
userType: json['user_type'],

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,8 @@ import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../services/booking_service.dart';
import 'booking_form_screen.dart';
import '../models/kontrakan.dart';
import '../models/booking.dart';
// ignore_for_file: deprecated_member_use
@ -54,12 +56,22 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
final bookings = await _bookingService.getBookingHistory();
if (!mounted) return;
setState(() {
_activeBookings = bookings
.where((b) => b.status == 'confirmed' || b.status == 'pending')
.toList();
_pastBookings = bookings
.where((b) => b.status == 'completed' || b.status == 'cancelled')
.toList();
_activeBookings = bookings.where((b) {
final status = b.status.toLowerCase();
return status == 'pending' ||
status == 'confirmed' ||
status == 'checked_in';
}).toList();
_pastBookings = bookings.where((b) {
final status = b.status.toLowerCase();
return status == 'completed' ||
status == 'cancelled' ||
status == 'expired';
}).toList();
_isLoading = false;
});
} catch (e) {
@ -68,7 +80,7 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Gagal memuat riwayat booking'),
content: const Text('Gagal memuat riwayat pengajuan'),
backgroundColor: Colors.red[700],
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
@ -80,7 +92,9 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
}
}
Future<void> _cancelBooking(int bookingId) async {
Future<void> _cancelBooking(Booking booking) async {
final jenisLabel = booking.isSurvei ? 'Survei' : 'Pengajuan Sewa';
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
@ -93,13 +107,18 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
size: 24,
),
const SizedBox(width: 10),
const Text(
'Batalkan Booking',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
Expanded(
child: Text(
'Batalkan $jenisLabel',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
],
),
content: const Text('Apakah Anda yakin ingin membatalkan booking ini?'),
content: Text('Apakah Anda yakin ingin membatalkan $jenisLabel ini?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
@ -120,10 +139,14 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
],
),
);
if (confirm != true) return;
try {
final result = await _bookingService.cancelBooking(bookingId);
final result = await _bookingService.cancelBooking(booking.id);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
@ -149,12 +172,16 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
margin: const EdgeInsets.all(16),
),
);
if (result['success'] == true) _loadBookings();
if (result['success'] == true) {
_loadBookings();
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Gagal membatalkan booking: $e'),
content: Text('Gagal membatalkan pengajuan: $e'),
backgroundColor: const Color(0xFFC62828),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
@ -166,6 +193,179 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
}
}
Future<void> _openSewaFromSurvey(Booking booking) async {
final rawKontrakan = booking.kontrakan;
if (rawKontrakan is! Map) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Data kontrakan tidak dapat dibuka. Silakan coba lagi.',
),
backgroundColor: Color(0xFFC62828),
),
);
return;
}
final kontrakan = Kontrakan.fromJson(
Map<String, dynamic>.from(rawKontrakan),
);
await Navigator.push(
context,
MaterialPageRoute(
builder: (_) =>
BookingFormScreen(kontrakan: kontrakan, jenisPengajuan: 'sewa'),
),
);
if (mounted) {
_loadBookings();
}
}
Future<void> _markSurveyAsNotRenting(Booking booking) async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: const Text(
'Tidak Jadi Sewa?',
style: TextStyle(fontWeight: FontWeight.w700),
),
content: const Text(
'Pengajuan survei akan ditutup dan dipindahkan ke riwayat. '
'Kontrakan tetap tersedia untuk pengguna lain.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Kembali'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFC62828),
foregroundColor: Colors.white,
),
child: const Text('Ya, Tidak Jadi'),
),
],
),
);
if (confirm != true) return;
final result = await _bookingService.cancelBooking(booking.id);
if (!mounted) return;
if (result['success'] == true) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Survei ditutup. Data pengajuan dipindahkan ke riwayat.',
),
backgroundColor: Color(0xFF2E7D32),
behavior: SnackBarBehavior.floating,
),
);
_loadBookings();
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result['message'] ?? 'Gagal menutup pengajuan survei.'),
backgroundColor: const Color(0xFFC62828),
behavior: SnackBarBehavior.floating,
),
);
}
Widget _buildSurveyFollowUpActions(Booking booking) {
final deadline = booking.surveyFollowUpExpiresAt;
final deadlineText = deadline == null
? 'Silakan ajukan sewa atau pilih Tidak Jadi Sewa dalam 2x24 jam.'
: 'Sisa waktu: ${_formatRemainingTime(deadline)}\n'
'Batas akhir: ${_formatDateTime(deadline)}';
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFFFF8E1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFFFCC80)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.timer_outlined, color: Color(0xFFF57C00), size: 18),
SizedBox(width: 8),
Expanded(
child: Text(
'Tindak Lanjut Survei',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: Color(0xFFE65100),
),
),
),
],
),
const SizedBox(height: 7),
Text(
deadlineText,
style: const TextStyle(
fontSize: 12,
height: 1.35,
color: Color(0xFF6D4C41),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => _openSewaFromSurvey(booking),
icon: const Icon(Icons.home_work_rounded, size: 18),
label: const Text('Ajukan Sewa'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1565C0),
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => _markSurveyAsNotRenting(booking),
icon: const Icon(Icons.cancel_outlined, size: 18),
label: const Text('Tidak Jadi Sewa'),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFC62828),
side: const BorderSide(color: Color(0xFFEF9A9A)),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
],
),
);
}
Future<void> _uploadPaymentProof(Booking booking) async {
// Show source dialog
final source = await showModalBottomSheet<ImageSource>(
@ -356,7 +556,7 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Booking Saya',
'Pengajuan Saya',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
@ -366,7 +566,7 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
),
SizedBox(height: 2),
Text(
'Pantau status aktif dan riwayat booking',
'Pantau status survei dan pengajuan sewa',
style: TextStyle(
fontSize: 13,
color: Colors.white70,
@ -441,7 +641,7 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
const SizedBox(height: 20),
Center(
child: Text(
isActive ? 'Belum ada booking aktif' : 'Belum ada riwayat',
isActive ? 'Belum ada pengajuan aktif' : 'Belum ada riwayat',
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
@ -453,8 +653,8 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
Center(
child: Text(
isActive
? 'Booking kontrakan Anda akan muncul di sini'
: 'Riwayat booking sebelumnya akan muncul di sini',
? 'Pengajuan survei atau sewa Anda akan muncul di sini'
: 'Riwayat pengajuan sebelumnya akan muncul di sini',
style: TextStyle(fontSize: 13, color: Colors.grey[500]),
textAlign: TextAlign.center,
),
@ -482,33 +682,41 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
String statusText;
IconData statusIcon;
switch (booking.status) {
switch (booking.status.toLowerCase()) {
case 'pending':
statusColor = const Color(0xFFF57C00);
statusText = 'Menunggu';
statusIcon = Icons.schedule_rounded;
break;
case 'confirmed':
statusColor = const Color(0xFF2E7D32);
statusText = 'Dikonfirmasi';
statusIcon = Icons.check_circle_rounded;
break;
case 'checked_in':
case 'active':
statusColor = const Color(0xFF1565C0);
statusIcon = Icons.home_rounded;
break;
case 'completed':
statusColor = const Color(0xFF1565C0);
statusText = 'Selesai';
statusIcon = Icons.done_all_rounded;
break;
case 'expired':
statusColor = Colors.grey.shade700;
statusIcon = Icons.timer_off_rounded;
break;
case 'cancelled':
statusColor = const Color(0xFFC62828);
statusText = 'Dibatalkan';
statusIcon = Icons.cancel_rounded;
break;
default:
statusColor = Colors.grey;
statusText = 'Unknown';
statusIcon = Icons.help_rounded;
}
statusText = booking.statusLabel;
// Get kontrakan name if available
String kontrakanName = 'Kontrakan';
if (booking.kontrakan is Map) {
@ -571,7 +779,7 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
),
const SizedBox(height: 2),
Text(
'ID: #${booking.id}',
"ID: #${booking.id}${booking.isSurvei ? 'Survei' : 'Sewa'}",
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
@ -615,119 +823,81 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: Column(
children: [
// Date info in a compact row
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFF7F8FC),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
_buildDateRow(
Icons.login_rounded,
'Mulai',
_formatDate(booking.tanggalMulai),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Divider(height: 1, color: Colors.grey[200]),
),
_buildDateRow(
Icons.logout_rounded,
'Selesai',
_formatDate(booking.tanggalSelesai),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Divider(height: 1, color: Colors.grey[200]),
),
_buildDateRow(
Icons.timelapse_rounded,
'Durasi',
'${booking.durasiBulan} Bulan',
),
],
),
),
const SizedBox(height: 14),
// Price and Payment
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
if (booking.isSurvei)
_buildSurveyScheduleCard(booking)
else ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFF7F8FC),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Text(
'Total Harga',
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
fontWeight: FontWeight.w500,
),
_buildDateRow(
Icons.login_rounded,
'Mulai',
_formatDate(booking.tanggalMulai),
),
const SizedBox(height: 2),
Text(
'Rp ${_formatPrice(booking.totalBiaya)}',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w800,
color: Color(0xFF1565C0),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Divider(height: 1, color: Colors.grey[200]),
),
_buildDateRow(
Icons.logout_rounded,
'Selesai',
_formatDate(booking.tanggalSelesai),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Divider(height: 1, color: Colors.grey[200]),
),
_buildDateRow(
Icons.timelapse_rounded,
'Durasi',
'${booking.durasiBulan} bulan',
),
],
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: booking.paymentStatus == 'paid'
? const Color(0xFFE8F5E9)
: const Color(0xFFFFF3E0),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
),
const SizedBox(height: 14),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
booking.paymentStatus == 'paid'
? Icons.check_circle_rounded
: Icons.pending_rounded,
size: 14,
color: booking.paymentStatus == 'paid'
? const Color(0xFF2E7D32)
: const Color(0xFFF57C00),
),
const SizedBox(width: 5),
Text(
booking.paymentStatus == 'paid'
? 'Lunas'
: 'Belum Bayar',
'Estimasi Biaya',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: booking.paymentStatus == 'paid'
? const Color(0xFF2E7D32)
: const Color(0xFFF57C00),
color: Colors.grey[500],
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
'Rp ${_formatPrice(booking.totalBiaya)}',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w800,
color: Color(0xFF1565C0),
),
),
],
),
),
],
),
_buildPaymentChip(booking),
],
),
],
// Payment proof
if (booking.paymentProof != null) ...[
if (!booking.isSurvei && booking.paymentProof != null) ...[
const SizedBox(height: 12),
GestureDetector(
onTap: () {
final Future<Uint8List?> future =
_bookingService.getPaymentProofBytes(booking.id);
final Future<Uint8List?> future = _bookingService
.getPaymentProofBytes(booking.id);
showDialog(
context: context,
builder: (ctx) => Dialog(
@ -875,15 +1045,23 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
),
],
if (booking.isSurveyFollowUpActive) ...[
const SizedBox(height: 14),
_buildSurveyFollowUpActions(booking),
],
// Actions
if (booking.status == 'pending') ...[
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => _cancelBooking(booking.id),
onPressed: () => _cancelBooking(booking),
icon: const Icon(Icons.close_rounded, size: 18),
label: const Text('Batalkan Booking'),
label: Text(
booking.isSurvei
? 'Batalkan Survei'
: 'Batalkan Pengajuan Sewa',
),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFC62828),
side: const BorderSide(color: Color(0xFFEF9A9A)),
@ -895,8 +1073,7 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
),
),
],
if (booking.status == 'confirmed' &&
booking.paymentStatus == 'unpaid') ...[
if (booking.canUploadPaymentProof) ...[
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
@ -937,6 +1114,107 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
);
}
Widget _buildSurveyScheduleCard(Booking booking) {
final tanggal = booking.tanggalSurvei ?? booking.tanggalMulai;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFE8F5E9),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFA5D6A7)),
),
child: Column(
children: [
_buildDateRow(
Icons.event_available_rounded,
'Jadwal Survei',
_formatDate(tanggal),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Divider(height: 1, color: Colors.green[100]),
),
_buildDateRow(
Icons.schedule_rounded,
'Jam Survei',
booking.jamSurvei ?? '-',
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Divider(height: 1, color: Colors.green[100]),
),
const Row(
children: [
Icon(
Icons.info_outline_rounded,
size: 15,
color: Color(0xFF2E7D32),
),
SizedBox(width: 7),
Expanded(
child: Text(
'Survei tidak memerlukan pembayaran.',
style: TextStyle(
fontSize: 12,
color: Color(0xFF2E7D32),
fontWeight: FontWeight.w500,
),
),
),
],
),
],
),
);
}
Widget _buildPaymentChip(Booking booking) {
final isPaid = booking.paymentStatus == 'paid';
final isVerification = booking.paymentStatus == 'verification';
final color = isPaid
? const Color(0xFF2E7D32)
: isVerification
? const Color(0xFF1565C0)
: const Color(0xFFF57C00);
final background = isPaid
? const Color(0xFFE8F5E9)
: isVerification
? const Color(0xFFE3F2FD)
: const Color(0xFFFFF3E0);
final icon = isPaid
? Icons.check_circle_rounded
: isVerification
? Icons.hourglass_top_rounded
: Icons.pending_rounded;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 5),
Text(
booking.paymentStatusLabel,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: color,
),
),
],
),
);
}
Widget _buildDateRow(IconData icon, String label, String value) {
return Row(
children: [
@ -981,6 +1259,35 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
return '${date.day} ${months[date.month - 1]} ${date.year}';
}
String _formatDateTime(DateTime date) {
final dateText = _formatDate(date);
final hour = date.hour.toString().padLeft(2, '0');
final minute = date.minute.toString().padLeft(2, '0');
return '$dateText, $hour:$minute';
}
String _formatRemainingTime(DateTime deadline) {
final remaining = deadline.difference(DateTime.now());
if (remaining.isNegative) {
return 'Waktu tindak lanjut telah habis';
}
final days = remaining.inDays;
final hours = remaining.inHours % 24;
final minutes = remaining.inMinutes % 60;
if (days > 0) {
return '$days Hari $hours Jam';
}
if (hours > 0) {
return '$hours Jam $minutes Menit';
}
return '$minutes Menit';
}
String _formatPrice(double price) {
return price
.toStringAsFixed(0)

View File

@ -395,28 +395,7 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
Expanded(
child: ElevatedButton(
onPressed: widget.kontrakan.isAvailable
? () {
final authService = AuthService();
if (!authService.isAuthenticated) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Silakan login terlebih dahulu untuk booking',
),
backgroundColor: Colors.red,
),
);
return;
}
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => BookingFormScreen(
kontrakan: widget.kontrakan,
),
),
);
}
? _showPengajuanOptions
: null,
style: ElevatedButton.styleFrom(
backgroundColor: widget.kontrakan.isAvailable
@ -433,7 +412,7 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
),
child: Text(
widget.kontrakan.isAvailable
? 'Ajukan Booking'
? 'Ajukan Pengajuan'
: 'Sedang Penuh',
style: const TextStyle(
fontSize: 15,
@ -449,6 +428,159 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
);
}
void _showPengajuanOptions() {
final authService = AuthService();
if (!authService.isAuthenticated) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Silakan login terlebih dahulu untuk mengajukan survei atau sewa',
),
backgroundColor: Colors.red,
),
);
return;
}
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(22)),
),
builder: (sheetContext) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 42,
height: 4,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(99),
),
),
const SizedBox(height: 16),
const Text(
'Pilih Jenis Pengajuan',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 6),
const Text(
'Pilih sesuai kebutuhan Anda.',
style: TextStyle(color: Colors.grey),
),
const SizedBox(height: 16),
_buildPengajuanOption(
icon: Icons.event_available,
color: const Color(0xFF2E7D32),
title: 'Ajukan Survei',
subtitle:
'Atur jadwal untuk melihat kontrakan. Tanpa pembayaran.',
onTap: () {
Navigator.pop(sheetContext);
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => BookingFormScreen(
kontrakan: widget.kontrakan,
jenisPengajuan: 'survei',
),
),
);
},
),
const SizedBox(height: 12),
_buildPengajuanOption(
icon: Icons.home_work_outlined,
color: const Color(0xFF1565C0),
title: 'Ajukan Sewa',
subtitle:
'Ajukan untuk menempati kontrakan. Bayar setelah disetujui.',
onTap: () {
Navigator.pop(sheetContext);
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => BookingFormScreen(
kontrakan: widget.kontrakan,
jenisPengajuan: 'sewa',
),
),
);
},
),
],
),
),
);
},
);
}
Widget _buildPengajuanOption({
required IconData icon,
required Color color,
required String title,
required String subtitle,
required VoidCallback onTap,
}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: color.withOpacity(0.06),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: color.withOpacity(0.25)),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(11),
decoration: BoxDecoration(
color: color.withOpacity(0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: color),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(height: 3),
Text(
subtitle,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF5A6B85),
height: 1.35,
),
),
],
),
),
Icon(Icons.arrow_forward_ios, size: 16, color: color),
],
),
),
);
}
Widget _buildGallery() {
if (!widget.kontrakan.hasPhoto) {
return _buildMissingPhoto();

View File

@ -1,4 +1,4 @@
import 'dart:async';
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
@ -21,7 +21,7 @@ class AuthService {
// HttpClient - lazy initialization
HttpClient? _httpClient;
// Secure storage for sensitive data (token & user)
// ✠Secure storage for sensitive data (token & user)
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(
@ -39,7 +39,7 @@ class AuthService {
if (_httpClient == null) {
_httpClient = HttpClient();
// Security: NEVER bypass certificate validation in release builds.
// ✠Security: NEVER bypass certificate validation in release builds.
// In debug builds, allow self-signed certs only for local dev hosts.
if (kDebugMode) {
_httpClient!.badCertificateCallback = (cert, host, port) {
@ -93,7 +93,7 @@ class AuthService {
_currentUser = User.fromJson(jsonDecode(userJson));
}
// Migration: if secure storage empty, try legacy SharedPreferences once.
// ✠Migration: if secure storage empty, try legacy SharedPreferences once.
if (_token == null) {
final prefs = await SharedPreferences.getInstance();
final legacyToken = prefs.getString(AppConfig.tokenKey);
@ -505,28 +505,60 @@ class AuthService {
}
}
// Get current user
// Get current user
Future<User?> getCurrentUser() async {
if (_token == null) return null;
if (_token == null) {
return _currentUser;
}
try {
final response = await http.get(
Uri.parse('${AppConfig.baseUrl}/user'),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': 'Bearer $_token',
},
);
final response = await http
.get(
Uri.parse('${AppConfig.baseUrl}/user'),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': 'Bearer $_token',
},
)
.timeout(AppConfig.connectionTimeout);
if (response.statusCode == 200) {
final user = User.fromJson(jsonDecode(response.body));
_currentUser = user;
return user;
if (response.statusCode == 200 && response.body.isNotEmpty) {
final decoded = jsonDecode(response.body);
// Mendukung respons API langsung atau respons yang dibungkus data/user.
Map<String, dynamic>? userData;
if (decoded is Map<String, dynamic>) {
if (decoded['data'] is Map) {
userData = Map<String, dynamic>.from(decoded['data']);
} else if (decoded['user'] is Map) {
userData = Map<String, dynamic>.from(decoded['user']);
} else {
userData = decoded;
}
}
if (userData != null) {
final user = User.fromJson(userData);
// Jangan menimpa data lokal dengan user kosong dari respons yang tidak valid.
if (user.name.trim().isNotEmpty || user.email.trim().isNotEmpty) {
_currentUser = user;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(AppConfig.userKey, jsonEncode(user.toJson()));
return user;
}
}
}
return null;
// Tetap tampilkan data yang tersimpan bila server tidak memberi profil valid.
return _currentUser;
} catch (e) {
return null;
return _currentUser;
}
}
@ -551,8 +583,10 @@ class AuthService {
if (response.statusCode == 200 && data['success'] == true) {
final user = User.fromJson(data['data']);
final prefs = await SharedPreferences.getInstance();
await prefs.setString(AppConfig.userKey, jsonEncode(user.toJson()));
await _secureStorage.write(
key: AppConfig.userKey,
value: jsonEncode(user.toJson()),
);
_currentUser = user;
return {'success': true, 'message': data['message']};
} else {

View File

@ -1,7 +1,9 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import '../config/app_config.dart';
import '../models/booking.dart';
import 'auth_service.dart';
@ -10,7 +12,7 @@ class BookingService {
final AuthService _authService = AuthService();
Map<String, String> get _headers {
final headers = {
final headers = <String, String>{
'Content-Type': 'application/json',
'Accept': 'application/json',
};
@ -22,7 +24,6 @@ class BookingService {
return headers;
}
// Get booking history
Future<List<Booking>> getBookingHistory() async {
try {
final response = await http.get(
@ -37,63 +38,138 @@ class BookingService {
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['success'] == true) {
final List items = data['data']['data'] ?? data['data'];
return items.map((json) => Booking.fromJson(json)).toList();
final List items = data['data']['data'] ?? data['data'] ?? [];
return items
.map(
(item) =>
Booking.fromJson(Map<String, dynamic>.from(item as Map)),
)
.toList();
}
}
return [];
} catch (e) {
// Error getting booking history silently
} catch (_) {
return [];
}
}
// Create booking (with required payment proof image)
Future<Map<String, dynamic>> createBooking({
required int kontrakanId,
required DateTime tanggalMulai,
required int durasiBulan,
String? catatan,
File? paymentProof,
}) async {
Future<Map<String, dynamic>> _createPengajuan(
Map<String, dynamic> body,
) async {
try {
// Validate payment proof is provided
if (paymentProof == null) {
return {'success': false, 'message': 'Bukti pembayaran wajib diunggah'};
}
final response = await http
.post(
Uri.parse('${AppConfig.baseUrl}/bookings'),
headers: _headers,
body: jsonEncode(body),
)
.timeout(const Duration(seconds: 30));
// Use multipart request with payment proof
final uri = Uri.parse('${AppConfig.baseUrl}/bookings');
final request = http.MultipartRequest('POST', uri);
if (_authService.token != null) {
request.headers['Authorization'] = 'Bearer ${_authService.token}';
request.headers['Accept'] = 'application/json';
}
request.fields['kontrakan_id'] = kontrakanId.toString();
request.fields['tanggal_mulai'] = tanggalMulai.toIso8601String().split(
'T',
)[0];
request.fields['durasi_bulan'] = durasiBulan.toString();
if (catatan != null) request.fields['catatan'] = catatan;
request.files.add(
await http.MultipartFile.fromPath('payment_proof', paymentProof.path),
);
final streamedResponse = await request.send().timeout(
const Duration(seconds: 30),
);
final response = await http.Response.fromStream(streamedResponse);
final data = jsonDecode(response.body);
if (response.statusCode == 401) {
await _authService.handleUnauthorized(response.statusCode);
return {
'success': false,
'message': 'Sesi expired, silakan login ulang',
'message': 'Sesi habis, silakan login ulang.',
};
}
if (response.statusCode == 201 && data['success'] == true) {
return {
'success': true,
'message': data['message'],
'booking': Booking.fromJson(
Map<String, dynamic>.from(data['data'] as Map),
),
};
}
return {
'success': false,
'message': data['message'] ?? 'Gagal mengirim pengajuan.',
'errors': data['errors'],
};
} catch (e) {
return {'success': false, 'message': 'Terjadi kesalahan: $e'};
}
}
Future<Map<String, dynamic>> createSurvey({
required int kontrakanId,
required DateTime tanggalSurvei,
required String jamSurvei,
String? catatan,
}) async {
final body = <String, dynamic>{
'kontrakan_id': kontrakanId,
'jenis_pengajuan': 'survei',
'tanggal_survei': tanggalSurvei.toIso8601String().split('T')[0],
'jam_survei': jamSurvei,
};
if (catatan != null && catatan.trim().isNotEmpty) {
body['catatan'] = catatan.trim();
}
return _createPengajuan(body);
}
Future<Map<String, dynamic>> createSewa({
required int kontrakanId,
required DateTime tanggalMulai,
required int durasiBulan,
String? catatan,
File? ktpPhoto,
}) async {
try {
final request = http.MultipartRequest(
'POST',
Uri.parse('${AppConfig.baseUrl}/bookings'),
);
if (_authService.token != null) {
request.headers['Authorization'] = 'Bearer ${_authService.token}';
}
request.headers['Accept'] = 'application/json';
request.fields['kontrakan_id'] = kontrakanId.toString();
request.fields['jenis_pengajuan'] = 'sewa';
request.fields['tanggal_mulai'] = tanggalMulai.toIso8601String().split(
'T',
)[0];
request.fields['durasi_bulan'] = durasiBulan.toString();
if (catatan != null && catatan.trim().isNotEmpty) {
request.fields['catatan'] = catatan.trim();
}
if (ktpPhoto != null) {
request.files.add(
await http.MultipartFile.fromPath('ktp_photo', ktpPhoto.path),
);
}
final streamedResponse = await request.send().timeout(
const Duration(seconds: 30),
);
final response = await http.Response.fromStream(streamedResponse);
final data = response.body.isNotEmpty
? jsonDecode(response.body)
: <String, dynamic>{};
if (response.statusCode == 401) {
await _authService.handleUnauthorized(response.statusCode);
return {
'success': false,
'message': 'Sesi habis, silakan login ulang.',
};
}
@ -103,19 +179,33 @@ class BookingService {
'message': data['message'],
'booking': Booking.fromJson(data['data']),
};
} else {
return {
'success': false,
'message': data['message'] ?? 'Gagal membuat booking',
'errors': data['errors'],
};
}
return {
'success': false,
'message': data['message'] ?? 'Gagal mengirim pengajuan sewa.',
'errors': data['errors'],
};
} catch (e) {
return {'success': false, 'message': 'Error: $e'};
return {'success': false, 'message': 'Terjadi kesalahan: $e'};
}
}
// Cancel booking
Future<Map<String, dynamic>> createBooking({
required int kontrakanId,
required DateTime tanggalMulai,
required int durasiBulan,
String? catatan,
File? paymentProof,
}) {
return createSewa(
kontrakanId: kontrakanId,
tanggalMulai: tanggalMulai,
durasiBulan: durasiBulan,
catatan: catatan,
);
}
Future<Map<String, dynamic>> cancelBooking(int bookingId) async {
try {
final response = await http.post(
@ -129,24 +219,23 @@ class BookingService {
await _authService.handleUnauthorized(response.statusCode);
return {
'success': false,
'message': 'Sesi expired, silakan login ulang',
'message': 'Sesi habis, silakan login ulang.',
};
}
if (response.statusCode == 200 && data['success'] == true) {
return {'success': true, 'message': data['message']};
} else {
return {
'success': false,
'message': data['message'] ?? 'Gagal membatalkan booking',
};
}
return {
'success': false,
'message': data['message'] ?? 'Gagal membatalkan pengajuan.',
};
} catch (e) {
return {'success': false, 'message': 'Error: $e'};
return {'success': false, 'message': 'Terjadi kesalahan: $e'};
}
}
// Get booking detail
Future<Booking?> getBookingById(int id) async {
try {
final response = await http.get(
@ -161,35 +250,35 @@ class BookingService {
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['success'] == true) {
return Booking.fromJson(data['data']);
return Booking.fromJson(
Map<String, dynamic>.from(data['data'] as Map),
);
}
}
return null;
} catch (e) {
// Error getting booking detail silently
} catch (_) {
return null;
}
}
// Upload payment proof image
Future<Map<String, dynamic>> uploadPaymentProof(
int bookingId,
File imageFile,
) async {
try {
final uri = Uri.parse(
'${AppConfig.baseUrl}/bookings/$bookingId/payment-proof',
final request = http.MultipartRequest(
'POST',
Uri.parse('${AppConfig.baseUrl}/bookings/$bookingId/payment-proof'),
);
final request = http.MultipartRequest('POST', uri);
// Add auth header
if (_authService.token != null) {
request.headers['Authorization'] = 'Bearer ${_authService.token}';
request.headers['Accept'] = 'application/json';
}
// Attach image file
request.files.add(
await http.MultipartFile.fromPath('payment_proof', imageFile.path),
);
@ -197,6 +286,7 @@ class BookingService {
final streamedResponse = await request.send().timeout(
const Duration(seconds: 30),
);
final response = await http.Response.fromStream(streamedResponse);
final data = jsonDecode(response.body);
@ -204,33 +294,32 @@ class BookingService {
await _authService.handleUnauthorized(response.statusCode);
return {
'success': false,
'message': 'Sesi expired, silakan login ulang',
'message': 'Sesi habis, silakan login ulang.',
};
}
if (response.statusCode == 200 && data['success'] == true) {
return {
'success': true,
'message': data['message'] ?? 'Bukti pembayaran berhasil diunggah',
'booking': Booking.fromJson(data['data']),
};
} else {
return {
'success': false,
'message': data['message'] ?? 'Gagal mengunggah bukti pembayaran',
'message': data['message'] ?? 'Bukti pembayaran berhasil diunggah.',
'booking': Booking.fromJson(
Map<String, dynamic>.from(data['data'] as Map),
),
};
}
return {
'success': false,
'message': data['message'] ?? 'Gagal mengunggah bukti pembayaran.',
};
} catch (e) {
return {'success': false, 'message': 'Error: $e'};
return {'success': false, 'message': 'Terjadi kesalahan: $e'};
}
}
// Fetch payment proof image bytes (secure, authenticated)
Future<Uint8List?> getPaymentProofBytes(int bookingId) async {
try {
final headers = <String, String>{
'Accept': 'image/*',
};
final headers = <String, String>{'Accept': 'image/*'};
if (_authService.token != null) {
headers['Authorization'] = 'Bearer ${_authService.token}';
@ -253,7 +342,7 @@ class BookingService {
}
return null;
} catch (e) {
} catch (_) {
return null;
}
}

View File

@ -7,12 +7,16 @@
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);

View File

@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
flutter_secure_storage_linux
url_launcher_linux
)

View File

@ -9,6 +9,7 @@ import file_selector_macos
import firebase_core
import firebase_messaging
import flutter_local_notifications
import flutter_secure_storage_macos
import geolocator_apple
import shared_preferences_foundation
import sqflite_darwin
@ -19,6 +20,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))

View File

@ -8,6 +8,7 @@
#include <file_selector_windows/file_selector_windows.h>
#include <firebase_core/firebase_core_plugin_c_api.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <geolocator_windows/geolocator_windows.h>
#include <url_launcher_windows/url_launcher_windows.h>
@ -16,6 +17,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FirebaseCorePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
GeolocatorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("GeolocatorWindows"));
UrlLauncherWindowsRegisterWithRegistrar(

View File

@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
firebase_core
flutter_secure_storage_windows
geolocator_windows
url_launcher_windows
)