first commit

This commit is contained in:
Andreas Malvino
2025-06-02 22:39:03 +07:00
commit e7090af3da
245 changed files with 49210 additions and 0 deletions

View File

@ -0,0 +1,75 @@
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import '../../../data/providers/aset_provider.dart';
import '../../../data/providers/auth_provider.dart';
import '../controllers/order_sewa_aset_controller.dart';
class OrderSewaAsetBinding extends Bindings {
@override
void dependencies() {
debugPrint('⚡ OrderSewaAsetBinding: dependencies called');
final box = GetStorage();
// Ensure providers are registered
if (!Get.isRegistered<AsetProvider>()) {
debugPrint('⚡ Registering AsetProvider');
Get.put(AsetProvider(), permanent: true);
}
if (!Get.isRegistered<AuthProvider>()) {
debugPrint('⚡ Registering AuthProvider');
Get.put(AuthProvider(), permanent: true);
}
// Check if we have the asetId in arguments
final args = Get.arguments;
debugPrint('⚡ Arguments received in binding: $args');
String? asetId;
if (args != null && args.containsKey('asetId') && args['asetId'] != null) {
asetId = args['asetId'].toString();
if (asetId.isNotEmpty) {
debugPrint('✅ Valid asetId found in arguments: $asetId');
// Simpan ID di storage untuk digunakan saat hot reload
box.write('current_aset_id', asetId);
debugPrint('💾 Saved asetId to GetStorage in binding: $asetId');
} else {
debugPrint('⚠️ Warning: Empty asetId found in arguments');
}
} else {
debugPrint(
'⚠️ Warning: No valid asetId found in arguments, checking storage',
);
// Cek apakah ada ID tersimpan di storage
if (box.hasData('current_aset_id')) {
asetId = box.read<String>('current_aset_id');
debugPrint('📦 Found asetId in GetStorage: $asetId');
}
}
// Only delete the existing controller if we're not in a hot reload situation
if (Get.isRegistered<OrderSewaAsetController>()) {
// Check if we're going through a hot reload by looking at the controller's state
final existingController = Get.find<OrderSewaAsetController>();
if (existingController.aset.value == null) {
// Controller exists but doesn't have data, likely a fresh navigation or reload
debugPrint('⚡ Removing old OrderSewaAsetController without data');
Get.delete<OrderSewaAsetController>(force: true);
// Use put instead of lazyPut to ensure controller is created immediately
debugPrint('⚡ Creating new OrderSewaAsetController');
Get.put(OrderSewaAsetController());
} else {
// Controller exists and has data, leave it alone during hot reload
debugPrint(
'🔥 Hot reload detected, preserving existing controller with data',
);
}
} else {
// No controller exists, create a new one
debugPrint('⚡ Creating new OrderSewaAsetController (first time)');
Get.put(OrderSewaAsetController());
}
}
}

View File

@ -0,0 +1,22 @@
import 'package:get/get.dart';
import '../controllers/order_sewa_paket_controller.dart';
import '../../../data/providers/aset_provider.dart';
import '../../../data/providers/sewa_provider.dart';
class OrderSewaPaketBinding extends Bindings {
@override
void dependencies() {
// Ensure providers are registered
if (!Get.isRegistered<AsetProvider>()) {
Get.put(AsetProvider());
}
if (!Get.isRegistered<SewaProvider>()) {
Get.put(SewaProvider());
}
Get.lazyPut<OrderSewaPaketController>(
() => OrderSewaPaketController(),
);
}
}

View File

@ -0,0 +1,9 @@
import 'package:get/get.dart';
import '../controllers/pembayaran_sewa_controller.dart';
class PembayaranSewaBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<PembayaranSewaController>(() => PembayaranSewaController());
}
}

View File

@ -0,0 +1,16 @@
import 'package:get/get.dart';
import '../controllers/sewa_aset_controller.dart';
import '../../../data/providers/aset_provider.dart';
class SewaAsetBinding extends Bindings {
@override
void dependencies() {
// Register AsetProvider if not already registered
if (!Get.isRegistered<AsetProvider>()) {
Get.put(AsetProvider(), permanent: true);
}
// Register SewaAsetController
Get.lazyPut<SewaAsetController>(() => SewaAsetController());
}
}

View File

@ -0,0 +1,34 @@
import 'package:get/get.dart';
import '../controllers/warga_sewa_controller.dart';
import '../controllers/warga_dashboard_controller.dart';
import '../../../services/navigation_service.dart';
import '../../../data/providers/auth_provider.dart';
import '../../../data/providers/aset_provider.dart';
class WargaSewaBinding extends Bindings {
@override
void dependencies() {
// Ensure NavigationService is registered and set to Sewa tab
if (Get.isRegistered<NavigationService>()) {
final navService = Get.find<NavigationService>();
navService.setNavIndex(1); // Set to Sewa tab
}
// Ensure AuthProvider is registered
if (!Get.isRegistered<AuthProvider>()) {
Get.put(AuthProvider(), permanent: true);
}
// Ensure AsetProvider is registered
if (!Get.isRegistered<AsetProvider>()) {
Get.put(AsetProvider(), permanent: true);
}
// Register WargaDashboardController if not already registered
if (!Get.isRegistered<WargaDashboardController>()) {
Get.put(WargaDashboardController());
}
Get.lazyPut<WargaSewaController>(() => WargaSewaController());
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,443 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:intl/intl.dart';
import 'package:flutter_logs/flutter_logs.dart';
import '../../../data/models/paket_model.dart';
import '../../../data/providers/aset_provider.dart';
import '../../../data/providers/sewa_provider.dart';
import '../../../services/service_manager.dart';
import '../../../services/navigation_service.dart';
class OrderSewaPaketController extends GetxController {
// Dependencies
final AsetProvider asetProvider = Get.find<AsetProvider>();
final SewaProvider sewaProvider = Get.find<SewaProvider>();
final NavigationService navigationService = ServiceManager().navigationService;
// State variables
final paket = Rx<PaketModel?>(null);
final paketImages = RxList<String>([]);
final isLoading = RxBool(true);
final isPhotosLoading = RxBool(true);
final selectedSatuanWaktu = Rx<Map<String, dynamic>?>(null);
final selectedDate = RxString('');
final selectedStartDate = Rx<DateTime?>(null);
final selectedEndDate = Rx<DateTime?>(null);
final selectedStartTime = RxInt(-1);
final selectedEndTime = RxInt(-1);
final formattedDateRange = RxString('');
final formattedTimeRange = RxString('');
final totalPrice = RxDouble(0.0);
final kuantitas = RxInt(1);
final isSubmitting = RxBool(false);
// Format currency
final currencyFormat = NumberFormat.currency(
locale: 'id',
symbol: 'Rp',
decimalDigits: 0,
);
@override
void onInit() {
super.onInit();
FlutterLogs.logInfo("OrderSewaPaketController", "onInit", "Initializing OrderSewaPaketController");
// Get the paket ID from arguments
final Map<String, dynamic> args = Get.arguments ?? {};
final String? paketId = args['id'];
if (paketId != null) {
loadPaketData(paketId);
} else {
debugPrint('❌ No paket ID provided in arguments');
isLoading.value = false;
}
}
// Handle hot reload - restore state if needed
void handleHotReload() {
if (paket.value == null) {
final Map<String, dynamic> args = Get.arguments ?? {};
final String? paketId = args['id'];
if (paketId != null) {
// Try to get from cache first
final cachedPaket = GetStorage().read('cached_paket_$paketId');
if (cachedPaket != null) {
debugPrint('🔄 Hot reload: Restoring paket from cache');
paket.value = cachedPaket;
loadPaketPhotos(paketId);
initializePriceOptions();
} else {
loadPaketData(paketId);
}
}
}
}
// Load paket data from API
Future<void> loadPaketData(String id) async {
try {
isLoading.value = true;
debugPrint('🔍 Loading paket data for ID: $id');
// First check if we have it in cache
final cachedPaket = GetStorage().read('cached_paket_$id');
if (cachedPaket != null) {
debugPrint('✅ Found cached paket data');
paket.value = cachedPaket;
await loadPaketPhotos(id);
initializePriceOptions();
} else {
// Get all pakets and filter for the one we need
final List<dynamic> allPakets = await asetProvider.getPakets();
final rawPaket = allPakets.firstWhere(
(paket) => paket['id'] == id,
orElse: () => null,
);
// Declare loadedPaket outside the if block for wider scope
PaketModel? loadedPaket;
if (rawPaket != null) {
// Convert to PaketModel
try {
// Handle Map directly - pakets from getPakets() are always maps
loadedPaket = PaketModel.fromMap(rawPaket);
debugPrint('✅ Successfully converted paket to PaketModel');
} catch (e) {
debugPrint('❌ Error converting paket map to PaketModel: $e');
// Fallback using our helper methods
loadedPaket = PaketModel(
id: getPaketId(rawPaket),
nama: getPaketNama(rawPaket),
deskripsi: getPaketDeskripsi(rawPaket),
harga: getPaketHarga(rawPaket),
kuantitas: getPaketKuantitas(rawPaket),
foto_paket: getPaketMainPhoto(rawPaket),
satuanWaktuSewa: getPaketSatuanWaktuSewa(rawPaket),
);
debugPrint('✅ Created PaketModel using helper methods');
}
// Update the state with the loaded paket
if (loadedPaket != null) {
debugPrint('✅ Loaded paket: ${loadedPaket.nama}');
paket.value = loadedPaket;
// Cache for future use
GetStorage().write('cached_paket_$id', loadedPaket);
// Load photos for this paket
await loadPaketPhotos(id);
// Set initial pricing option
initializePriceOptions();
// Ensure we have at least one photo if available
if (paketImages.isEmpty) {
String? mainPhoto = getPaketMainPhoto(paket.value);
if (mainPhoto != null && mainPhoto.isNotEmpty) {
paketImages.add(mainPhoto);
debugPrint('✅ Added main paket photo: $mainPhoto');
}
}
}
} else {
debugPrint('❌ No paket found with id: $id');
}
}
// Calculate the total price if we have a paket loaded
if (paket.value != null) {
calculateTotalPrice();
debugPrint('💰 Total price calculated: ${totalPrice.value}');
}
} catch (e) {
debugPrint('❌ Error loading paket data: $e');
} finally {
isLoading.value = false;
}
}
// Helper methods to safely access paket properties
String? getPaketId(dynamic paket) {
if (paket == null) return null;
try {
return paket.id ?? paket['id'];
} catch (_) {
return null;
}
}
String? getPaketNama(dynamic paket) {
if (paket == null) return null;
try {
return paket.nama ?? paket['nama'];
} catch (_) {
return null;
}
}
String? getPaketDeskripsi(dynamic paket) {
if (paket == null) return null;
try {
return paket.deskripsi ?? paket['deskripsi'];
} catch (_) {
return null;
}
}
double getPaketHarga(dynamic paket) {
if (paket == null) return 0.0;
try {
var harga = paket.harga ?? paket['harga'] ?? 0;
return double.tryParse(harga.toString()) ?? 0.0;
} catch (_) {
return 0.0;
}
}
int getPaketKuantitas(dynamic paket) {
if (paket == null) return 1;
try {
var qty = paket.kuantitas ?? paket['kuantitas'] ?? 1;
return int.tryParse(qty.toString()) ?? 1;
} catch (_) {
return 1;
}
}
String? getPaketMainPhoto(dynamic paket) {
if (paket == null) return null;
try {
return paket.foto_paket ?? paket['foto_paket'];
} catch (_) {
return null;
}
}
List<dynamic> getPaketSatuanWaktuSewa(dynamic paket) {
if (paket == null) return [];
try {
return paket.satuanWaktuSewa ?? paket['satuanWaktuSewa'] ?? [];
} catch (_) {
return [];
}
}
// Load photos for the paket
Future<void> loadPaketPhotos(String paketId) async {
try {
isPhotosLoading.value = true;
final photos = await asetProvider.getFotoPaket(paketId);
if (photos != null && photos.isNotEmpty) {
paketImages.clear();
for (var photo in photos) {
try {
if (photo.fotoPaket != null && photo.fotoPaket.isNotEmpty) {
paketImages.add(photo.fotoPaket);
} else if (photo.fotoAset != null && photo.fotoAset.isNotEmpty) {
paketImages.add(photo.fotoAset);
}
} catch (e) {
var fotoUrl = photo['foto_paket'] ?? photo['foto_aset'];
if (fotoUrl != null && fotoUrl.isNotEmpty) {
paketImages.add(fotoUrl);
}
}
}
}
} finally {
isPhotosLoading.value = false;
}
}
// Initialize price options
void initializePriceOptions() {
if (paket.value == null) return;
final satuanWaktuSewa = getPaketSatuanWaktuSewa(paket.value);
if (satuanWaktuSewa.isNotEmpty) {
// Default to the first option
selectSatuanWaktu(satuanWaktuSewa.first);
}
}
// Select satuan waktu
void selectSatuanWaktu(Map<String, dynamic> satuanWaktu) {
selectedSatuanWaktu.value = satuanWaktu;
// Reset date and time selections
selectedStartDate.value = null;
selectedEndDate.value = null;
selectedStartTime.value = -1;
selectedEndTime.value = -1;
selectedDate.value = '';
formattedDateRange.value = '';
formattedTimeRange.value = '';
calculateTotalPrice();
}
// Check if the rental is daily
bool isDailyRental() {
final namaSatuan = selectedSatuanWaktu.value?['nama_satuan_waktu'] ?? '';
return namaSatuan.toString().toLowerCase().contains('hari');
}
// Select date range for daily rental
void selectDateRange(DateTime start, DateTime end) {
selectedStartDate.value = start;
selectedEndDate.value = end;
// Format the date range
final formatter = DateFormat('d MMM yyyy', 'id');
if (start.year == end.year && start.month == end.month && start.day == end.day) {
formattedDateRange.value = formatter.format(start);
} else {
formattedDateRange.value = '${formatter.format(start)} - ${formatter.format(end)}';
}
selectedDate.value = formatter.format(start);
calculateTotalPrice();
}
// Select date for hourly rental
void selectDate(DateTime date) {
selectedStartDate.value = date;
selectedDate.value = DateFormat('d MMM yyyy', 'id').format(date);
calculateTotalPrice();
}
// Select time range for hourly rental
void selectTimeRange(int start, int end) {
selectedStartTime.value = start;
selectedEndTime.value = end;
// Format the time range
final startTime = '$start:00';
final endTime = '$end:00';
formattedTimeRange.value = '$startTime - $endTime';
calculateTotalPrice();
}
// Calculate total price
void calculateTotalPrice() {
if (selectedSatuanWaktu.value == null) {
totalPrice.value = 0.0;
return;
}
final basePrice = double.tryParse(selectedSatuanWaktu.value!['harga'].toString()) ?? 0.0;
if (isDailyRental()) {
if (selectedStartDate.value != null && selectedEndDate.value != null) {
final days = selectedEndDate.value!.difference(selectedStartDate.value!).inDays + 1;
totalPrice.value = basePrice * days;
} else {
totalPrice.value = basePrice;
}
} else {
if (selectedStartTime.value >= 0 && selectedEndTime.value >= 0) {
final hours = selectedEndTime.value - selectedStartTime.value;
totalPrice.value = basePrice * hours;
} else {
totalPrice.value = basePrice;
}
}
// Multiply by quantity
totalPrice.value *= kuantitas.value;
}
// Format price as currency
String formatPrice(double price) {
return currencyFormat.format(price);
}
// Submit order
Future<void> submitOrder() async {
try {
if (paket.value == null || selectedSatuanWaktu.value == null) {
Get.snackbar(
'Error',
'Data paket tidak lengkap',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
return;
}
if ((isDailyRental() && (selectedStartDate.value == null || selectedEndDate.value == null)) ||
(!isDailyRental() && (selectedStartDate.value == null || selectedStartTime.value < 0 || selectedEndTime.value < 0))) {
Get.snackbar(
'Error',
'Silakan pilih waktu sewa',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
return;
}
isSubmitting.value = true;
// Prepare order data
final Map<String, dynamic> orderData = {
'id_paket': paket.value!.id,
'id_satuan_waktu_sewa': selectedSatuanWaktu.value!['id'],
'tanggal_mulai': selectedStartDate.value!.toIso8601String(),
'tanggal_selesai': selectedEndDate.value?.toIso8601String() ?? selectedStartDate.value!.toIso8601String(),
'jam_mulai': isDailyRental() ? null : selectedStartTime.value,
'jam_selesai': isDailyRental() ? null : selectedEndTime.value,
'total_harga': totalPrice.value,
'kuantitas': kuantitas.value,
};
// Submit the order
final result = await sewaProvider.createPaketOrder(orderData);
if (result != null) {
Get.snackbar(
'Sukses',
'Pesanan berhasil dibuat',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.green,
colorText: Colors.white,
);
// Navigate to payment page
navigationService.navigateToPembayaranSewa(result['id']);
} else {
Get.snackbar(
'Error',
'Gagal membuat pesanan',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
}
} catch (e) {
debugPrint('❌ Error submitting order: $e');
Get.snackbar(
'Error',
'Terjadi kesalahan: $e',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
} finally {
isSubmitting.value = false;
}
}
// Handle back button press
void onBackPressed() {
navigationService.navigateToSewaAset();
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,471 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import '../../../data/providers/aset_provider.dart';
import '../../../data/models/aset_model.dart';
import '../../../routes/app_routes.dart';
import '../../../data/models/pesanan_model.dart';
import '../../../data/models/satuan_waktu_model.dart';
import '../../../data/models/satuan_waktu_sewa_model.dart';
import '../../../data/providers/auth_provider.dart';
import '../../../data/providers/pesanan_provider.dart';
import '../../../services/navigation_service.dart';
import '../../../services/service_manager.dart';
import 'package:get_storage/get_storage.dart';
class SewaAsetController extends GetxController
with GetSingleTickerProviderStateMixin {
final AsetProvider _asetProvider = Get.find<AsetProvider>();
final AuthProvider authProvider = Get.find<AuthProvider>();
final PesananProvider pesananProvider = Get.put(PesananProvider());
final NavigationService navigationService = Get.find<NavigationService>();
final box = GetStorage();
// Tab controller
late TabController tabController;
// Reactive tab index
final currentTabIndex = 0.obs;
// State variables
final asets = <AsetModel>[].obs;
final filteredAsets = <AsetModel>[].obs;
// Paket-related variables
final pakets = RxList<dynamic>([]);
final filteredPakets = RxList<dynamic>([]);
final isLoadingPakets = false.obs;
final isLoading = true.obs;
// Search controller
final TextEditingController searchController = TextEditingController();
// Reactive variables
final isOrdering = false.obs;
final selectedAset = Rx<AsetModel?>(null);
final selectedSatuanWaktuSewa = Rx<SatuanWaktuSewaModel?>(null);
final selectedDurasi = 1.obs;
final totalHarga = 0.obs;
final selectedDate = DateTime.now().obs;
final selectedTime = '08:00'.obs;
final satuanWaktuDropdownItems =
<DropdownMenuItem<SatuanWaktuSewaModel>>[].obs;
// Flag untuk menangani hot reload
final hasInitialized = false.obs;
@override
void onInit() {
super.onInit();
debugPrint('🚀 SewaAsetController: onInit called');
// Initialize tab controller
tabController = TabController(length: 2, vsync: this);
// Listen for tab changes
tabController.addListener(() {
currentTabIndex.value = tabController.index;
// Load packages data when switching to package tab for the first time
if (currentTabIndex.value == 1 && pakets.isEmpty) {
loadPakets();
}
});
loadAsets();
searchController.addListener(() {
if (currentTabIndex.value == 0) {
filterAsets(searchController.text);
} else {
filterPakets(searchController.text);
}
});
hasInitialized.value = true;
}
@override
void onReady() {
super.onReady();
debugPrint('🚀 SewaAsetController: onReady called');
}
@override
void onClose() {
debugPrint('🧹 SewaAsetController: onClose called');
searchController.dispose();
tabController.dispose();
super.onClose();
}
// Method untuk menangani hot reload
void handleHotReload() {
debugPrint('🔥 Hot reload detected in SewaAsetController');
if (!hasInitialized.value) {
debugPrint('🔄 Reinitializing SewaAsetController after hot reload');
loadAsets();
if (currentTabIndex.value == 1) {
loadPakets();
}
hasInitialized.value = true;
}
}
// Method untuk menangani tombol back
void onBackPressed() {
debugPrint('🔙 Back button pressed in SewaAsetView');
navigationService.backFromSewaAset();
}
Future<void> loadAsets() async {
try {
isLoading.value = true;
final sewaAsets = await _asetProvider.getSewaAsets();
// Debug data satuan waktu sewa yang diterima
debugPrint('===== DEBUG ASET & SATUAN WAKTU SEWA =====');
for (var aset in sewaAsets) {
debugPrint('Aset: ${aset.nama} (ID: ${aset.id})');
if (aset.satuanWaktuSewa.isEmpty) {
debugPrint(' - Tidak ada satuan waktu sewa yang terkait');
} else {
debugPrint(
' - Memiliki ${aset.satuanWaktuSewa.length} satuan waktu sewa:',
);
for (var sws in aset.satuanWaktuSewa) {
debugPrint(' * ID: ${sws['id']}');
debugPrint(' Aset ID: ${sws['aset_id']}');
debugPrint(' Satuan Waktu ID: ${sws['satuan_waktu_id']}');
debugPrint(' Harga: ${sws['harga']}');
debugPrint(' Nama Satuan Waktu: ${sws['nama_satuan_waktu']}');
debugPrint(' -----');
}
}
debugPrint('=====================================');
}
asets.assignAll(sewaAsets);
filteredAsets.assignAll(sewaAsets);
// Tambahkan log info tentang jumlah aset yang berhasil dimuat
debugPrint('Loaded ${sewaAsets.length} aset sewa successfully');
} catch (e) {
debugPrint('Error loading asets: $e');
Get.snackbar(
'Error',
'Terjadi kesalahan saat memuat data aset',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
} finally {
isLoading.value = false;
}
}
void filterAsets(String query) {
if (query.isEmpty) {
filteredAsets.assignAll(asets);
} else {
filteredAsets.assignAll(
asets
.where(
(aset) => aset.nama.toLowerCase().contains(query.toLowerCase()),
)
.toList(),
);
}
}
void refreshAsets() {
loadAsets();
}
String formatPrice(dynamic price) {
if (price == null) return 'Rp 0';
// Handle different types
num numericPrice;
if (price is int || price is double) {
numericPrice = price;
} else if (price is String) {
numericPrice = double.tryParse(price) ?? 0;
} else {
return 'Rp 0';
}
final formatter = NumberFormat.currency(
locale: 'id',
symbol: 'Rp ',
decimalDigits: 0,
);
return formatter.format(numericPrice);
}
void selectAset(AsetModel aset) {
selectedAset.value = aset;
// Reset related values
selectedSatuanWaktuSewa.value = null;
selectedDurasi.value = 1;
totalHarga.value = 0;
// Prepare dropdown items for satuan waktu sewa
updateSatuanWaktuDropdown();
}
void updateSatuanWaktuDropdown() {
satuanWaktuDropdownItems.clear();
if (selectedAset.value != null &&
selectedAset.value!.satuanWaktuSewa.isNotEmpty) {
for (var item in selectedAset.value!.satuanWaktuSewa) {
final satuanWaktuSewa = SatuanWaktuSewaModel.fromJson(item);
satuanWaktuDropdownItems.add(
DropdownMenuItem<SatuanWaktuSewaModel>(
value: satuanWaktuSewa,
child: Text(
'${satuanWaktuSewa.namaSatuanWaktu ?? "Unknown"} - Rp${NumberFormat.decimalPattern('id').format(satuanWaktuSewa.harga)}',
),
),
);
}
}
}
void selectSatuanWaktu(SatuanWaktuSewaModel? satuanWaktuSewa) {
selectedSatuanWaktuSewa.value = satuanWaktuSewa;
calculateTotalPrice();
}
void updateDurasi(int durasi) {
if (durasi < 1) durasi = 1;
selectedDurasi.value = durasi;
calculateTotalPrice();
}
void calculateTotalPrice() {
if (selectedSatuanWaktuSewa.value != null) {
totalHarga.value =
selectedSatuanWaktuSewa.value!.harga * selectedDurasi.value;
} else {
totalHarga.value = 0;
}
}
void pickDate(DateTime date) {
selectedDate.value = date;
}
void pickTime(String time) {
selectedTime.value = time;
}
// Helper method to show error snackbar
void _showError(String message) {
Get.snackbar(
'Error',
message,
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
}
// Method untuk melakukan pemesanan
Future<void> placeOrderAset() async {
if (selectedAset.value == null) {
_showError('Silakan pilih aset terlebih dahulu');
return;
}
if (selectedSatuanWaktuSewa.value == null) {
_showError('Silakan pilih satuan waktu sewa');
return;
}
if (selectedDurasi.value <= 0) {
_showError('Durasi sewa harus lebih dari 0');
return;
}
final userId = authProvider.getCurrentUserId();
if (userId == null) {
_showError('Anda belum login, silakan login terlebih dahulu');
return;
}
try {
final result = await _asetProvider.orderAset(
userId: userId,
asetId: selectedAset.value!.id,
satuanWaktuSewaId: selectedSatuanWaktuSewa.value!.id,
durasi: selectedDurasi.value,
totalHarga: totalHarga.value,
);
if (result) {
Get.snackbar(
'Sukses',
'Pesanan berhasil dibuat',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.green,
colorText: Colors.white,
);
resetSelections();
} else {
_showError('Gagal membuat pesanan');
}
} catch (e) {
_showError('Terjadi kesalahan: $e');
}
}
// Method untuk reset pilihan setelah pemesanan berhasil
void resetSelections() {
selectedAset.value = null;
selectedSatuanWaktuSewa.value = null;
selectedDurasi.value = 1;
totalHarga.value = 0;
}
// Load packages data from paket table
Future<void> loadPakets() async {
try {
isLoadingPakets.value = true;
// Call the provider method to get paket data
final paketData = await _asetProvider.getPakets();
// Debug paket data
debugPrint('===== DEBUG PAKET & SATUAN WAKTU SEWA =====');
for (var paket in paketData) {
debugPrint('Paket: ${paket['nama']} (ID: ${paket['id']})');
if (paket['satuanWaktuSewa'] == null ||
paket['satuanWaktuSewa'].isEmpty) {
debugPrint(' - Tidak ada satuan waktu sewa yang terkait');
} else {
debugPrint(
' - Memiliki ${paket['satuanWaktuSewa'].length} satuan waktu sewa:',
);
for (var sws in paket['satuanWaktuSewa']) {
debugPrint(' * ID: ${sws['id']}');
debugPrint(' Paket ID: ${sws['paket_id']}');
debugPrint(' Satuan Waktu ID: ${sws['satuan_waktu_id']}');
debugPrint(' Harga: ${sws['harga']}');
debugPrint(' Nama Satuan Waktu: ${sws['nama_satuan_waktu']}');
debugPrint(' -----');
}
}
debugPrint('=====================================');
}
pakets.assignAll(paketData);
filteredPakets.assignAll(paketData);
debugPrint('Loaded ${paketData.length} paket successfully');
} catch (e) {
debugPrint('Error loading pakets: $e');
Get.snackbar(
'Error',
'Terjadi kesalahan saat memuat data paket',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
} finally {
isLoadingPakets.value = false;
}
}
// Method to filter pakets based on search query
void filterPakets(String query) {
if (query.isEmpty) {
filteredPakets.assignAll(pakets);
} else {
filteredPakets.assignAll(
pakets
.where(
(paket) => paket['nama'].toString().toLowerCase().contains(
query.toLowerCase(),
),
)
.toList(),
);
}
}
void refreshPakets() {
loadPakets();
}
// Method to load paket data
Future<void> loadPaketData() async {
try {
isLoadingPakets.value = true;
final result = await _asetProvider.getPakets();
if (result != null) {
pakets.clear();
filteredPakets.clear();
pakets.addAll(result);
filteredPakets.addAll(result);
}
} catch (e) {
debugPrint('Error loading pakets: $e');
Get.snackbar(
'Error',
'Gagal memuat data paket. Silakan coba lagi nanti.',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
} finally {
isLoadingPakets.value = false;
}
}
// Method for placing an order for a paket
Future<void> placeOrderPaket({
required String paketId,
required String satuanWaktuSewaId,
required int durasi,
required int totalHarga,
}) async {
debugPrint('===== PLACE ORDER PAKET =====');
debugPrint('paketId: $paketId');
debugPrint('satuanWaktuSewaId: $satuanWaktuSewaId');
debugPrint('durasi: $durasi');
debugPrint('totalHarga: $totalHarga');
final userId = authProvider.getCurrentUserId();
if (userId == null) {
_showError('Anda belum login, silakan login terlebih dahulu');
return;
}
try {
final result = await _asetProvider.orderPaket(
userId: userId,
paketId: paketId,
satuanWaktuSewaId: satuanWaktuSewaId,
durasi: durasi,
totalHarga: totalHarga,
);
if (result) {
Get.snackbar(
'Sukses',
'Pesanan paket berhasil dibuat',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.green,
colorText: Colors.white,
);
} else {
_showError('Gagal membuat pesanan paket');
}
} catch (e) {
_showError('Terjadi kesalahan: $e');
}
}
}

View File

@ -0,0 +1,180 @@
import 'package:get/get.dart';
import '../../../data/providers/auth_provider.dart';
import '../../../routes/app_routes.dart';
import '../../../services/navigation_service.dart';
class WargaDashboardController extends GetxController {
// Dependency injection
final AuthProvider _authProvider = Get.find<AuthProvider>();
final NavigationService navigationService = Get.find<NavigationService>();
// User data
final userName = 'Pengguna Warga'.obs;
final userRole = 'Warga'.obs;
final userAvatar = Rx<String?>(null);
final userEmail = ''.obs;
final userNik = ''.obs;
final userPhone = ''.obs;
final userAddress = ''.obs;
// Navigation state is now managed by NavigationService
// Sample data (would be loaded from API)
final activeRentals = <Map<String, dynamic>>[].obs;
// Active bills
final activeBills = <Map<String, dynamic>>[].obs;
// Active penalties
final activePenalties = <Map<String, dynamic>>[].obs;
@override
void onInit() {
super.onInit();
// Set navigation index to Home (0)
navigationService.setNavIndex(0);
// Load user data
_loadUserData();
// Load sample data
_loadSampleData();
// Load dummy data for bills and penalties
loadDummyData();
// Load unpaid rentals
loadUnpaidRentals();
}
Future<void> _loadUserData() async {
try {
// Get the full name from warga_desa table
final fullName = await _authProvider.getUserFullName();
if (fullName != null && fullName.isNotEmpty) {
userName.value = fullName;
}
// Get the avatar URL
final avatar = await _authProvider.getUserAvatar();
userAvatar.value = avatar;
// Get the role name
final roleId = await _authProvider.getUserRoleId();
if (roleId != null) {
final roleName = await _authProvider.getRoleName(roleId);
if (roleName != null) {
userRole.value = roleName;
}
}
// Load additional user data
// In a real app, these would come from the API/database
userEmail.value = await _authProvider.getUserEmail() ?? '';
userNik.value = await _authProvider.getUserNIK() ?? '';
userPhone.value = await _authProvider.getUserPhone() ?? '';
userAddress.value = await _authProvider.getUserAddress() ?? '';
} catch (e) {
print('Error loading user data: $e');
}
}
void _loadSampleData() {
// Clear any existing data
activeRentals.clear();
// Load active rentals from API
// For now, using sample data
activeRentals.add({
'id': '1',
'name': 'Kursi',
'time': '24 April 2023, 10:00 - 12:00',
'duration': '2 jam',
'price': 'Rp50.000',
'can_extend': true,
});
}
void extendRental(String rentalId) {
// Implementasi untuk memperpanjang sewa
// Seharusnya melakukan API call ke backend
}
void endRental(String rentalId) {
// Implementasi untuk mengakhiri sewa
// Seharusnya melakukan API call ke backend
}
void navigateToRentals() {
// Navigate to SewaAset using the navigation service
navigationService.toSewaAset();
}
void refreshData() {
// Refresh data from repository
_loadSampleData();
loadDummyData();
}
void onNavItemTapped(int index) {
if (navigationService.currentNavIndex.value == index) {
return; // Don't do anything if same tab
}
navigationService.setNavIndex(index);
switch (index) {
case 0:
// Already on Home tab
break;
case 1:
// Navigate to Sewa page
navigationService.toWargaSewa();
break;
}
}
void logout() async {
await _authProvider.signOut();
navigationService.toLogin();
}
void loadDummyData() {
// Dummy active bills
activeBills.clear();
activeBills.add({
'id': '1',
'title': 'Tagihan Air',
'due_date': '30 Apr 2023',
'amount': 'Rp 125.000',
});
activeBills.add({
'id': '2',
'title': 'Sewa Aula Desa',
'due_date': '15 Apr 2023',
'amount': 'Rp 350.000',
});
// Dummy active penalties
activePenalties.clear();
activePenalties.add({
'id': '1',
'title': 'Keterlambatan Sewa Traktor',
'days_late': '7',
'amount': 'Rp 75.000',
});
}
Future<void> loadUnpaidRentals() async {
try {
final results = await _authProvider.getSewaAsetByStatus([
'MENUNGGU PEMBAYARAN',
'PEMBAYARANAN DENDA',
]);
activeBills.value = results;
} catch (e) {
print('Error loading unpaid rentals: $e');
}
}
}

View File

@ -0,0 +1,710 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import '../../../routes/app_routes.dart';
import '../../../services/navigation_service.dart';
import '../../../data/providers/auth_provider.dart';
import '../../../data/providers/aset_provider.dart';
class WargaSewaController extends GetxController
with GetSingleTickerProviderStateMixin {
late TabController tabController;
// Get navigation service
final NavigationService navigationService = Get.find<NavigationService>();
// Get auth provider for user data and sewa_aset queries
final AuthProvider authProvider = Get.find<AuthProvider>();
// Get aset provider for asset data
final AsetProvider asetProvider = Get.find<AsetProvider>();
// Observable lists for different rental statuses
final rentals = <Map<String, dynamic>>[].obs;
final pendingRentals = <Map<String, dynamic>>[].obs;
final acceptedRentals = <Map<String, dynamic>>[].obs;
final completedRentals = <Map<String, dynamic>>[].obs;
final cancelledRentals = <Map<String, dynamic>>[].obs;
// Loading states
final isLoading = false.obs;
final isLoadingPending = false.obs;
final isLoadingAccepted = false.obs;
final isLoadingCompleted = false.obs;
final isLoadingCancelled = false.obs;
@override
void onInit() {
super.onInit();
// Ensure tab index is set to Sewa (1)
navigationService.setNavIndex(1);
// Initialize tab controller with 6 tabs
tabController = TabController(length: 6, vsync: this);
// Set initial tab and ensure tab view is updated
tabController.index = 0;
// Load real rental data for all tabs
loadRentalsData();
loadPendingRentals();
loadAcceptedRentals();
loadCompletedRentals();
loadCancelledRentals();
// Listen to tab changes to update state if needed
tabController.addListener(() {
// Update selected tab index when changed via swipe
final int currentIndex = tabController.index;
debugPrint('Tab changed to index: $currentIndex');
// Load data for the selected tab if not already loaded
switch (currentIndex) {
case 0: // Belum Bayar
if (rentals.isEmpty && !isLoading.value) {
loadRentalsData();
}
break;
case 1: // Pending
if (pendingRentals.isEmpty && !isLoadingPending.value) {
loadPendingRentals();
}
break;
case 2: // Diterima
if (acceptedRentals.isEmpty && !isLoadingAccepted.value) {
loadAcceptedRentals();
}
break;
case 3: // Aktif
// Add Aktif tab logic when needed
break;
case 4: // Selesai
if (completedRentals.isEmpty && !isLoadingCompleted.value) {
loadCompletedRentals();
}
break;
case 5: // Dibatalkan
if (cancelledRentals.isEmpty && !isLoadingCancelled.value) {
loadCancelledRentals();
}
break;
}
});
}
@override
void onReady() {
super.onReady();
// Ensure nav index is set to Sewa (1) when the controller is ready
// This helps maintain correct state during hot reload
navigationService.setNavIndex(1);
}
@override
void onClose() {
tabController.dispose();
super.onClose();
}
// Load real data from sewa_aset table
Future<void> loadRentalsData() async {
try {
isLoading.value = true;
// Clear existing data
rentals.clear();
// Get sewa_aset data with status "MENUNGGU PEMBAYARAN" or "PEMBAYARAN DENDA"
final sewaAsetList = await authProvider.getSewaAsetByStatus([
'MENUNGGU PEMBAYARAN',
'PEMBAYARAN DENDA'
]);
debugPrint('Fetched ${sewaAsetList.length} sewa_aset records');
// Process each sewa_aset record
for (var sewaAset in sewaAsetList) {
// Get asset details if aset_id is available
String assetName = 'Aset';
String? imageUrl;
String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam';
if (sewaAset['aset_id'] != null) {
final asetData = await asetProvider.getAsetById(sewaAset['aset_id']);
if (asetData != null) {
assetName = asetData.nama;
imageUrl = asetData.imageUrl;
}
}
// Parse waktu mulai and waktu selesai
DateTime? waktuMulai;
DateTime? waktuSelesai;
String waktuSewa = '';
String tanggalSewa = '';
String jamMulai = '';
String jamSelesai = '';
String rentangWaktu = '';
if (sewaAset['waktu_mulai'] != null && sewaAset['waktu_selesai'] != null) {
waktuMulai = DateTime.parse(sewaAset['waktu_mulai']);
waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']);
// Format for display
final formatTanggal = DateFormat('dd-MM-yyyy');
final formatWaktu = DateFormat('HH:mm');
final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID');
tanggalSewa = formatTanggalLengkap.format(waktuMulai);
jamMulai = formatWaktu.format(waktuMulai);
jamSelesai = formatWaktu.format(waktuSelesai);
// Format based on satuan waktu
if (namaSatuanWaktu.toLowerCase() == 'jam') {
// For hours, show time range on same day
rentangWaktu = '$jamMulai - $jamSelesai';
} else if (namaSatuanWaktu.toLowerCase() == 'hari') {
// For days, show date range
final tanggalMulai = formatTanggalLengkap.format(waktuMulai);
final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai);
rentangWaktu = '$tanggalMulai - $tanggalSelesai';
} else {
// Default format
rentangWaktu = '$jamMulai - $jamSelesai';
}
// Full time format for waktuSewa
waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
'${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}';
}
// Format price
String totalPrice = 'Rp 0';
if (sewaAset['total'] != null) {
final formatter = NumberFormat.currency(
locale: 'id',
symbol: 'Rp ',
decimalDigits: 0,
);
totalPrice = formatter.format(sewaAset['total']);
}
// Add to rentals list
rentals.add({
'id': sewaAset['id'] ?? '',
'name': assetName,
'imageUrl': imageUrl ?? 'assets/images/gambar_pendukung.jpg',
'jumlahUnit': sewaAset['kuantitas'] ?? 0,
'waktuSewa': waktuSewa,
'duration': '${sewaAset['durasi'] ?? 0} ${namaSatuanWaktu}',
'status': sewaAset['status'] ?? 'MENUNGGU PEMBAYARAN',
'totalPrice': totalPrice,
'countdown': '00:59:59', // Default countdown
'tanggalSewa': tanggalSewa,
'jamMulai': jamMulai,
'jamSelesai': jamSelesai,
'rentangWaktu': rentangWaktu,
'namaSatuanWaktu': namaSatuanWaktu,
'waktuMulai': sewaAset['waktu_mulai'],
'waktuSelesai': sewaAset['waktu_selesai'],
});
}
debugPrint('Processed ${rentals.length} rental records');
} catch (e) {
debugPrint('Error loading rentals data: $e');
} finally {
isLoading.value = false;
}
}
// Navigation methods
void navigateToRentals() {
navigationService.toSewaAset();
}
void onNavItemTapped(int index) {
if (navigationService.currentNavIndex.value == index) return;
navigationService.setNavIndex(index);
switch (index) {
case 0:
// Navigate to Home
Get.offNamed(Routes.WARGA_DASHBOARD);
break;
case 1:
// Already on Sewa tab
break;
case 2:
// Navigate to Langganan
Get.offNamed(Routes.LANGGANAN);
break;
}
}
// Actions
void cancelRental(String id) {
Get.snackbar(
'Info',
'Pembatalan berhasil',
snackPosition: SnackPosition.BOTTOM,
);
}
// Navigate to payment page with the selected rental data
void viewRentalDetail(Map<String, dynamic> rental) {
debugPrint('Navigating to payment page with rental ID: ${rental['id']}');
// Navigate to payment page with rental data
Get.toNamed(
Routes.PEMBAYARAN_SEWA,
arguments: {
'orderId': rental['id'],
'rentalData': rental,
},
);
}
void payRental(String id) {
Get.snackbar(
'Info',
'Navigasi ke halaman pembayaran',
snackPosition: SnackPosition.BOTTOM,
);
}
// Load data for the Selesai tab (status: SELESAI)
Future<void> loadCompletedRentals() async {
try {
isLoadingCompleted.value = true;
// Clear existing data
completedRentals.clear();
// Get sewa_aset data with status "SELESAI"
final sewaAsetList = await authProvider.getSewaAsetByStatus(['SELESAI']);
debugPrint('Fetched ${sewaAsetList.length} completed sewa_aset records');
// Process each sewa_aset record
for (var sewaAset in sewaAsetList) {
// Get asset details if aset_id is available
String assetName = 'Aset';
String? imageUrl;
String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam';
if (sewaAset['aset_id'] != null) {
final asetData = await asetProvider.getAsetById(sewaAset['aset_id']);
if (asetData != null) {
assetName = asetData.nama;
imageUrl = asetData.imageUrl;
}
}
// Parse waktu mulai and waktu selesai
DateTime? waktuMulai;
DateTime? waktuSelesai;
String waktuSewa = '';
String tanggalSewa = '';
String jamMulai = '';
String jamSelesai = '';
String rentangWaktu = '';
if (sewaAset['waktu_mulai'] != null && sewaAset['waktu_selesai'] != null) {
waktuMulai = DateTime.parse(sewaAset['waktu_mulai']);
waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']);
// Format for display
final formatTanggal = DateFormat('dd-MM-yyyy');
final formatWaktu = DateFormat('HH:mm');
final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID');
tanggalSewa = formatTanggalLengkap.format(waktuMulai);
jamMulai = formatWaktu.format(waktuMulai);
jamSelesai = formatWaktu.format(waktuSelesai);
// Format based on satuan waktu
if (namaSatuanWaktu.toLowerCase() == 'jam') {
// For hours, show time range on same day
rentangWaktu = '$jamMulai - $jamSelesai';
} else if (namaSatuanWaktu.toLowerCase() == 'hari') {
// For days, show date range
final tanggalMulai = formatTanggalLengkap.format(waktuMulai);
final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai);
rentangWaktu = '$tanggalMulai - $tanggalSelesai';
} else {
// Default format
rentangWaktu = '$jamMulai - $jamSelesai';
}
// Full time format for waktuSewa
waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
'${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}';
}
// Format price
String totalPrice = 'Rp 0';
if (sewaAset['total'] != null) {
final formatter = NumberFormat.currency(
locale: 'id',
symbol: 'Rp ',
decimalDigits: 0,
);
totalPrice = formatter.format(sewaAset['total']);
}
// Add to completed rentals list
completedRentals.add({
'id': sewaAset['id'] ?? '',
'name': assetName,
'imageUrl': imageUrl ?? 'assets/images/gambar_pendukung.jpg',
'jumlahUnit': sewaAset['kuantitas'] ?? 0,
'waktuSewa': waktuSewa,
'duration': '${sewaAset['durasi'] ?? 0} ${namaSatuanWaktu}',
'status': sewaAset['status'] ?? 'SELESAI',
'totalPrice': totalPrice,
'tanggalSewa': tanggalSewa,
'jamMulai': jamMulai,
'jamSelesai': jamSelesai,
'rentangWaktu': rentangWaktu,
'namaSatuanWaktu': namaSatuanWaktu,
'waktuMulai': sewaAset['waktu_mulai'],
'waktuSelesai': sewaAset['waktu_selesai'],
});
}
debugPrint('Processed ${completedRentals.length} completed rental records');
} catch (e) {
debugPrint('Error loading completed rentals data: $e');
} finally {
isLoadingCompleted.value = false;
}
}
// Load data for the Dibatalkan tab (status: DIBATALKAN)
Future<void> loadCancelledRentals() async {
try {
isLoadingCancelled.value = true;
// Clear existing data
cancelledRentals.clear();
// Get sewa_aset data with status "DIBATALKAN"
final sewaAsetList = await authProvider.getSewaAsetByStatus(['DIBATALKAN']);
debugPrint('Fetched ${sewaAsetList.length} cancelled sewa_aset records');
// Process each sewa_aset record
for (var sewaAset in sewaAsetList) {
// Get asset details if aset_id is available
String assetName = 'Aset';
String? imageUrl;
String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam';
if (sewaAset['aset_id'] != null) {
final asetData = await asetProvider.getAsetById(sewaAset['aset_id']);
if (asetData != null) {
assetName = asetData.nama;
imageUrl = asetData.imageUrl;
}
}
// Parse waktu mulai and waktu selesai
DateTime? waktuMulai;
DateTime? waktuSelesai;
String waktuSewa = '';
String tanggalSewa = '';
String jamMulai = '';
String jamSelesai = '';
String rentangWaktu = '';
if (sewaAset['waktu_mulai'] != null && sewaAset['waktu_selesai'] != null) {
waktuMulai = DateTime.parse(sewaAset['waktu_mulai']);
waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']);
// Format for display
final formatTanggal = DateFormat('dd-MM-yyyy');
final formatWaktu = DateFormat('HH:mm');
final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID');
tanggalSewa = formatTanggalLengkap.format(waktuMulai);
jamMulai = formatWaktu.format(waktuMulai);
jamSelesai = formatWaktu.format(waktuSelesai);
// Format based on satuan waktu
if (namaSatuanWaktu.toLowerCase() == 'jam') {
// For hours, show time range on same day
rentangWaktu = '$jamMulai - $jamSelesai';
} else if (namaSatuanWaktu.toLowerCase() == 'hari') {
// For days, show date range
final tanggalMulai = formatTanggalLengkap.format(waktuMulai);
final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai);
rentangWaktu = '$tanggalMulai - $tanggalSelesai';
} else {
// Default format
rentangWaktu = '$jamMulai - $jamSelesai';
}
// Full time format for waktuSewa
waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
'${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}';
}
// Format price
String totalPrice = 'Rp 0';
if (sewaAset['total'] != null) {
final formatter = NumberFormat.currency(
locale: 'id',
symbol: 'Rp ',
decimalDigits: 0,
);
totalPrice = formatter.format(sewaAset['total']);
}
// Add to cancelled rentals list
cancelledRentals.add({
'id': sewaAset['id'] ?? '',
'name': assetName,
'imageUrl': imageUrl ?? 'assets/images/gambar_pendukung.jpg',
'jumlahUnit': sewaAset['kuantitas'] ?? 0,
'waktuSewa': waktuSewa,
'duration': '${sewaAset['durasi'] ?? 0} ${namaSatuanWaktu}',
'status': sewaAset['status'] ?? 'DIBATALKAN',
'totalPrice': totalPrice,
'tanggalSewa': tanggalSewa,
'jamMulai': jamMulai,
'jamSelesai': jamSelesai,
'rentangWaktu': rentangWaktu,
'namaSatuanWaktu': namaSatuanWaktu,
'waktuMulai': sewaAset['waktu_mulai'],
'waktuSelesai': sewaAset['waktu_selesai'],
'alasanPembatalan': sewaAset['alasan_pembatalan'] ?? '-',
});
}
debugPrint('Processed ${cancelledRentals.length} cancelled rental records');
} catch (e) {
debugPrint('Error loading cancelled rentals data: $e');
} finally {
isLoadingCancelled.value = false;
}
}
// Load data for the Pending tab (status: PERIKSA PEMBAYARAN)
Future<void> loadPendingRentals() async {
try {
isLoadingPending.value = true;
// Clear existing data
pendingRentals.clear();
// Get sewa_aset data with status "PERIKSA PEMBAYARAN"
final sewaAsetList = await authProvider.getSewaAsetByStatus(['PERIKSA PEMBAYARAN']);
debugPrint('Fetched ${sewaAsetList.length} pending sewa_aset records');
// Process each sewa_aset record
for (var sewaAset in sewaAsetList) {
// Get asset details if aset_id is available
String assetName = 'Aset';
String? imageUrl;
String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam';
if (sewaAset['aset_id'] != null) {
final asetData = await asetProvider.getAsetById(sewaAset['aset_id']);
if (asetData != null) {
assetName = asetData.nama;
imageUrl = asetData.imageUrl;
}
}
// Parse waktu mulai and waktu selesai
DateTime? waktuMulai;
DateTime? waktuSelesai;
String waktuSewa = '';
String tanggalSewa = '';
String jamMulai = '';
String jamSelesai = '';
String rentangWaktu = '';
if (sewaAset['waktu_mulai'] != null && sewaAset['waktu_selesai'] != null) {
waktuMulai = DateTime.parse(sewaAset['waktu_mulai']);
waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']);
// Format for display
final formatTanggal = DateFormat('dd-MM-yyyy');
final formatWaktu = DateFormat('HH:mm');
final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID');
tanggalSewa = formatTanggalLengkap.format(waktuMulai);
jamMulai = formatWaktu.format(waktuMulai);
jamSelesai = formatWaktu.format(waktuSelesai);
// Format based on satuan waktu
if (namaSatuanWaktu.toLowerCase() == 'jam') {
// For hours, show time range on same day
rentangWaktu = '$jamMulai - $jamSelesai';
} else if (namaSatuanWaktu.toLowerCase() == 'hari') {
// For days, show date range
final tanggalMulai = formatTanggalLengkap.format(waktuMulai);
final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai);
rentangWaktu = '$tanggalMulai - $tanggalSelesai';
} else {
// Default format
rentangWaktu = '$jamMulai - $jamSelesai';
}
// Full time format for waktuSewa
waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
'${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}';
}
// Format price
String totalPrice = 'Rp 0';
if (sewaAset['total'] != null) {
final formatter = NumberFormat.currency(
locale: 'id',
symbol: 'Rp ',
decimalDigits: 0,
);
totalPrice = formatter.format(sewaAset['total']);
}
// Add to pending rentals list
pendingRentals.add({
'id': sewaAset['id'] ?? '',
'name': assetName,
'imageUrl': imageUrl ?? 'assets/images/gambar_pendukung.jpg',
'jumlahUnit': sewaAset['kuantitas'] ?? 0,
'waktuSewa': waktuSewa,
'duration': '${sewaAset['durasi'] ?? 0} ${namaSatuanWaktu}',
'status': sewaAset['status'] ?? 'PERIKSA PEMBAYARAN',
'totalPrice': totalPrice,
'tanggalSewa': tanggalSewa,
'jamMulai': jamMulai,
'jamSelesai': jamSelesai,
'rentangWaktu': rentangWaktu,
'namaSatuanWaktu': namaSatuanWaktu,
'waktuMulai': sewaAset['waktu_mulai'],
'waktuSelesai': sewaAset['waktu_selesai'],
});
}
debugPrint('Processed ${pendingRentals.length} pending rental records');
} catch (e) {
debugPrint('Error loading pending rentals data: $e');
} finally {
isLoadingPending.value = false;
}
}
// Load data for the Diterima tab (status: DITERIMA)
Future<void> loadAcceptedRentals() async {
try {
isLoadingAccepted.value = true;
// Clear existing data
acceptedRentals.clear();
// Get sewa_aset data with status "DITERIMA"
final sewaAsetList = await authProvider.getSewaAsetByStatus(['DITERIMA']);
debugPrint('Fetched ${sewaAsetList.length} accepted sewa_aset records');
// Process each sewa_aset record
for (var sewaAset in sewaAsetList) {
// Get asset details if aset_id is available
String assetName = 'Aset';
String? imageUrl;
String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam';
if (sewaAset['aset_id'] != null) {
final asetData = await asetProvider.getAsetById(sewaAset['aset_id']);
if (asetData != null) {
assetName = asetData.nama;
imageUrl = asetData.imageUrl;
}
}
// Parse waktu mulai and waktu selesai
DateTime? waktuMulai;
DateTime? waktuSelesai;
String waktuSewa = '';
String tanggalSewa = '';
String jamMulai = '';
String jamSelesai = '';
String rentangWaktu = '';
if (sewaAset['waktu_mulai'] != null && sewaAset['waktu_selesai'] != null) {
waktuMulai = DateTime.parse(sewaAset['waktu_mulai']);
waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']);
// Format for display
final formatTanggal = DateFormat('dd-MM-yyyy');
final formatWaktu = DateFormat('HH:mm');
final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID');
tanggalSewa = formatTanggalLengkap.format(waktuMulai);
jamMulai = formatWaktu.format(waktuMulai);
jamSelesai = formatWaktu.format(waktuSelesai);
// Format based on satuan waktu
if (namaSatuanWaktu.toLowerCase() == 'jam') {
// For hours, show time range on same day
rentangWaktu = '$jamMulai - $jamSelesai';
} else if (namaSatuanWaktu.toLowerCase() == 'hari') {
// For days, show date range
final tanggalMulai = formatTanggalLengkap.format(waktuMulai);
final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai);
rentangWaktu = '$tanggalMulai - $tanggalSelesai';
} else {
// Default format
rentangWaktu = '$jamMulai - $jamSelesai';
}
// Full time format for waktuSewa
waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
'${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}';
}
// Format price
String totalPrice = 'Rp 0';
if (sewaAset['total'] != null) {
final formatter = NumberFormat.currency(
locale: 'id',
symbol: 'Rp ',
decimalDigits: 0,
);
totalPrice = formatter.format(sewaAset['total']);
}
// Add to accepted rentals list
acceptedRentals.add({
'id': sewaAset['id'] ?? '',
'name': assetName,
'imageUrl': imageUrl ?? 'assets/images/gambar_pendukung.jpg',
'jumlahUnit': sewaAset['kuantitas'] ?? 0,
'waktuSewa': waktuSewa,
'duration': '${sewaAset['durasi'] ?? 0} ${namaSatuanWaktu}',
'status': sewaAset['status'] ?? 'DITERIMA',
'totalPrice': totalPrice,
'tanggalSewa': tanggalSewa,
'jamMulai': jamMulai,
'jamSelesai': jamSelesai,
'rentangWaktu': rentangWaktu,
'namaSatuanWaktu': namaSatuanWaktu,
'waktuMulai': sewaAset['waktu_mulai'],
'waktuSelesai': sewaAset['waktu_selesai'],
});
}
debugPrint('Processed ${acceptedRentals.length} accepted rental records');
} catch (e) {
debugPrint('Error loading accepted rentals data: $e');
} finally {
isLoadingAccepted.value = false;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,981 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../controllers/order_sewa_paket_controller.dart';
import '../../../data/models/paket_model.dart';
import '../../../routes/app_routes.dart';
import '../../../services/navigation_service.dart';
import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart';
import 'package:flutter_logs/flutter_logs.dart';
import '../../../theme/app_colors.dart';
import 'package:intl/intl.dart';
class OrderSewaPaketView extends GetView<OrderSewaPaketController> {
const OrderSewaPaketView({super.key});
// Function to show confirmation dialog
void showOrderConfirmationDialog() {
final paket = controller.paket.value!;
final PaketModel? paketModel = paket is PaketModel ? paket : null;
final totalPrice = controller.totalPrice.value;
Get.dialog(
Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
child: Container(
width: double.infinity,
padding: EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header with success icon
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: AppColors.primarySoft,
shape: BoxShape.circle,
),
child: Icon(
Icons.check_circle_outline_rounded,
color: AppColors.primary,
size: 40,
),
),
SizedBox(height: 20),
// Title
Text(
'Konfirmasi Pesanan',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
SizedBox(height: 6),
// Subtitle
Text(
'Periksa detail pesanan Anda',
style: TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
textAlign: TextAlign.center,
),
SizedBox(height: 24),
// Order details
Container(
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surfaceLight,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.borderLight),
),
child: Column(
children: [
// Paket name
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Paket',
style: TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
Text(
paketModel?.nama ?? controller.getPaketNama(paket) ?? 'Paket tanpa nama',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
],
),
),
],
),
Divider(height: 24, color: AppColors.divider),
// Duration info
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Durasi',
style: TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
Obx(
() => Text(
controller.isDailyRental()
? controller.formattedDateRange.value
: '${controller.selectedDate.value}, ${controller.formattedTimeRange.value}',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
),
],
),
),
],
),
Divider(height: 24, color: AppColors.divider),
// Total price info
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Total',
style: TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
Obx(
() => Text(
controller.formatPrice(controller.totalPrice.value),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.primary,
),
),
),
],
),
),
],
),
],
),
),
SizedBox(height: 24),
// Action buttons
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Get.back(),
style: OutlinedButton.styleFrom(
padding: EdgeInsets.symmetric(vertical: 16),
side: BorderSide(color: AppColors.primary),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
'Batal',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
),
),
SizedBox(width: 16),
Expanded(
child: Obx(
() => ElevatedButton(
onPressed: controller.isSubmitting.value
? null
: () {
Get.back();
controller.submitOrder();
},
style: ElevatedButton.styleFrom(
padding: EdgeInsets.symmetric(vertical: 16),
backgroundColor: AppColors.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: controller.isSubmitting.value
? SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
)
: Text(
'Pesan',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
),
],
),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
// Handle hot reload by checking if controller needs to be reset
WidgetsBinding.instance.addPostFrameCallback((_) {
// This will be called after the widget tree is built
controller.handleHotReload();
// Ensure navigation service is registered for back button functionality
if (!Get.isRegistered<NavigationService>()) {
Get.put(NavigationService());
debugPrint('✅ Created new NavigationService instance in view');
}
});
// Function to handle back button press
void handleBackButtonPress() {
debugPrint('🔙 Back button pressed - navigating to SewaAsetView');
try {
// First try to use the controller's method
controller.onBackPressed();
} catch (e) {
debugPrint('⚠️ Error handling back via controller: $e');
// Fallback to direct navigation
Get.back();
}
}
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: AppColors.textPrimary),
onPressed: handleBackButtonPress,
),
title: Text(
'Pesan Paket',
style: TextStyle(
color: AppColors.textPrimary,
fontWeight: FontWeight.bold,
),
),
centerTitle: true,
),
body: Obx(
() => controller.isLoading.value
? Center(child: CircularProgressIndicator())
: controller.paket.value == null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline_rounded,
size: 64,
color: AppColors.error,
),
SizedBox(height: 16),
Text(
'Paket tidak ditemukan',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
SizedBox(height: 8),
Text(
'Silakan kembali dan pilih paket lain',
style: TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
),
SizedBox(height: 24),
ElevatedButton(
onPressed: handleBackButtonPress,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
padding: EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text('Kembali'),
),
],
),
)
: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTopSection(),
_buildPaketDetails(),
_buildPriceOptions(),
_buildDateSelection(context),
SizedBox(height: 100), // Space for bottom bar
],
),
),
),
bottomSheet: Obx(
() => controller.isLoading.value || controller.paket.value == null
? SizedBox.shrink()
: _buildBottomBar(onTapPesan: showOrderConfirmationDialog),
),
);
}
// Build top section with paket images
Widget _buildTopSection() {
return Container(
height: 280,
width: double.infinity,
child: Stack(
children: [
// Photo gallery
Obx(
() => controller.isPhotosLoading.value
? Center(child: CircularProgressIndicator())
: controller.paketImages.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.image_not_supported_outlined,
size: 64,
color: AppColors.textSecondary,
),
SizedBox(height: 16),
Text(
'Tidak ada foto',
style: TextStyle(
fontSize: 16,
color: AppColors.textSecondary,
),
),
],
),
)
: PhotoViewGallery.builder(
scrollPhysics: BouncingScrollPhysics(),
builder: (BuildContext context, int index) {
return PhotoViewGalleryPageOptions(
imageProvider: CachedNetworkImageProvider(
controller.paketImages[index],
),
initialScale: PhotoViewComputedScale.contained,
minScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.covered * 2,
heroAttributes: PhotoViewHeroAttributes(
tag: 'paket_image_$index',
),
);
},
itemCount: controller.paketImages.length,
loadingBuilder: (context, event) => Center(
child: CircularProgressIndicator(),
),
backgroundDecoration: BoxDecoration(
color: Colors.black,
),
pageController: PageController(),
),
),
// Gradient overlay at the top for back button
Positioned(
top: 0,
left: 0,
right: 0,
height: 80,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withOpacity(0.5),
Colors.transparent,
],
),
),
),
),
],
),
);
}
// Build paket details section
Widget _buildPaketDetails() {
final paket = controller.paket.value!;
final PaketModel? paketModel = paket is PaketModel ? paket : null;
return Container(
padding: EdgeInsets.all(16),
color: Colors.white,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Paket name and availability badge
Row(
children: [
Expanded(
child: Text(
paketModel?.nama ?? controller.getPaketNama(paket) ?? 'Paket tanpa nama',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
),
Container(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColors.success.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Tersedia',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.success,
),
),
),
],
),
SizedBox(height: 16),
// Description
Text(
'Deskripsi',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
SizedBox(height: 8),
Text(
paketModel?.deskripsi ?? controller.getPaketDeskripsi(paket) ?? 'Tidak ada deskripsi untuk paket ini.',
style: TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
height: 1.5,
),
),
],
),
);
}
// Build price options section
Widget _buildPriceOptions() {
final paket = controller.paket.value!;
final PaketModel? paketModel = paket is PaketModel ? paket : null;
final satuanWaktuSewa = paketModel?.satuanWaktuSewa ?? controller.getPaketSatuanWaktuSewa(paket);
return Container(
padding: EdgeInsets.all(16),
color: Colors.white,
margin: EdgeInsets.only(top: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Pilih Durasi',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
SizedBox(height: 16),
// Price options grid
GridView.builder(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 2.5,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: satuanWaktuSewa.length,
itemBuilder: (context, index) {
final option = satuanWaktuSewa[index];
final isSelected = controller.selectedSatuanWaktu.value != null &&
controller.selectedSatuanWaktu.value!['id'] == option['id'];
return GestureDetector(
onTap: () => controller.selectSatuanWaktu(option),
child: AnimatedContainer(
duration: Duration(milliseconds: 200),
decoration: BoxDecoration(
color: isSelected ? AppColors.primary : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected ? AppColors.primary : AppColors.borderLight,
width: 1,
),
),
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
option['nama_satuan_waktu'] ?? 'Durasi',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: isSelected ? Colors.white : AppColors.textPrimary,
),
),
SizedBox(height: 4),
Text(
controller.formatPrice(double.tryParse(option['harga'].toString()) ?? 0),
style: TextStyle(
fontSize: 12,
color: isSelected ? Colors.white.withOpacity(0.8) : AppColors.textSecondary,
),
),
],
),
),
);
},
),
],
),
);
}
// Build date selection section
Widget _buildDateSelection(BuildContext context) {
return Obx(
() => controller.selectedSatuanWaktu.value == null
? SizedBox.shrink()
: Container(
padding: EdgeInsets.all(16),
color: Colors.white,
margin: EdgeInsets.only(top: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
controller.isDailyRental() ? 'Pilih Tanggal' : 'Pilih Waktu',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
SizedBox(height: 16),
// Date selection for daily rental
if (controller.isDailyRental())
GestureDetector(
onTap: () async {
// Show date range picker
final now = DateTime.now();
final initialStartDate = controller.selectedStartDate.value ?? now;
final initialEndDate = controller.selectedEndDate.value ?? now.add(Duration(days: 1));
final DateTimeRange? picked = await showDateRangePicker(
context: context,
initialDateRange: DateTimeRange(start: initialStartDate, end: initialEndDate),
firstDate: now,
lastDate: now.add(Duration(days: 365)),
builder: (context, child) {
return Theme(
data: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(
primary: AppColors.primary,
onPrimary: Colors.white,
surface: Colors.white,
onSurface: AppColors.textPrimary,
),
dialogBackgroundColor: Colors.white,
),
child: child!,
);
},
);
if (picked != null) {
controller.selectDateRange(picked.start, picked.end);
}
},
child: Container(
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: AppColors.borderLight),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.calendar_today, color: AppColors.primary),
SizedBox(width: 12),
Expanded(
child: Text(
controller.formattedDateRange.value.isEmpty
? 'Pilih tanggal sewa'
: controller.formattedDateRange.value,
style: TextStyle(
fontSize: 14,
color: controller.formattedDateRange.value.isEmpty
? AppColors.textSecondary
: AppColors.textPrimary,
),
),
),
Icon(Icons.arrow_forward_ios, size: 16, color: AppColors.textSecondary),
],
),
),
)
// Time selection for hourly rental
else
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Date selection
GestureDetector(
onTap: () async {
final now = DateTime.now();
final initialDate = controller.selectedStartDate.value ?? now;
final DateTime? picked = await showDatePicker(
context: context,
initialDate: initialDate,
firstDate: now,
lastDate: now.add(Duration(days: 30)),
builder: (context, child) {
return Theme(
data: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(
primary: AppColors.primary,
onPrimary: Colors.white,
surface: Colors.white,
onSurface: AppColors.textPrimary,
),
dialogBackgroundColor: Colors.white,
),
child: child!,
);
},
);
if (picked != null) {
controller.selectDate(picked);
}
},
child: Container(
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: AppColors.borderLight),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.calendar_today, color: AppColors.primary),
SizedBox(width: 12),
Expanded(
child: Text(
controller.selectedDate.value.isEmpty
? 'Pilih tanggal sewa'
: controller.selectedDate.value,
style: TextStyle(
fontSize: 14,
color: controller.selectedDate.value.isEmpty
? AppColors.textSecondary
: AppColors.textPrimary,
),
),
),
Icon(Icons.arrow_forward_ios, size: 16, color: AppColors.textSecondary),
],
),
),
),
SizedBox(height: 16),
// Time range selection
controller.selectedDate.value.isEmpty
? SizedBox.shrink()

View File

@ -0,0 +1,470 @@
// Build price options section
Widget _buildPriceOptions() {
final paket = controller.paket.value!;
final PaketModel? paketModel = paket is PaketModel ? paket : null;
final satuanWaktuSewa = paketModel?.satuanWaktuSewa ?? controller.getPaketSatuanWaktuSewa(paket);
return Container(
padding: EdgeInsets.all(16),
color: Colors.white,
margin: EdgeInsets.only(top: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Pilih Durasi',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
SizedBox(height: 16),
// Price options grid
GridView.builder(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 2.5,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: satuanWaktuSewa.length,
itemBuilder: (context, index) {
final option = satuanWaktuSewa[index];
final isSelected = controller.selectedSatuanWaktu.value != null &&
controller.selectedSatuanWaktu.value!['id'] == option['id'];
return GestureDetector(
onTap: () => controller.selectSatuanWaktu(option),
child: AnimatedContainer(
duration: Duration(milliseconds: 200),
decoration: BoxDecoration(
color: isSelected ? AppColors.primary : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected ? AppColors.primary : AppColors.borderLight,
width: 1,
),
),
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
option['nama_satuan_waktu'] ?? 'Durasi',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: isSelected ? Colors.white : AppColors.textPrimary,
),
),
SizedBox(height: 4),
Text(
controller.formatPrice(double.tryParse(option['harga'].toString()) ?? 0),
style: TextStyle(
fontSize: 12,
color: isSelected ? Colors.white.withOpacity(0.8) : AppColors.textSecondary,
),
),
],
),
),
);
},
),
],
),
);
}
// Build date selection section
Widget _buildDateSelection(BuildContext context) {
return Obx(
() => controller.selectedSatuanWaktu.value == null
? SizedBox.shrink()
: Container(
padding: EdgeInsets.all(16),
color: Colors.white,
margin: EdgeInsets.only(top: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
controller.isDailyRental() ? 'Pilih Tanggal' : 'Pilih Waktu',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
SizedBox(height: 16),
// Date selection for daily rental
if (controller.isDailyRental())
GestureDetector(
onTap: () async {
// Show date range picker
final now = DateTime.now();
final initialStartDate = controller.selectedStartDate.value ?? now;
final initialEndDate = controller.selectedEndDate.value ?? now.add(Duration(days: 1));
final DateTimeRange? picked = await showDateRangePicker(
context: context,
initialDateRange: DateTimeRange(start: initialStartDate, end: initialEndDate),
firstDate: now,
lastDate: now.add(Duration(days: 365)),
builder: (context, child) {
return Theme(
data: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(
primary: AppColors.primary,
onPrimary: Colors.white,
surface: Colors.white,
onSurface: AppColors.textPrimary,
),
dialogBackgroundColor: Colors.white,
),
child: child!,
);
},
);
if (picked != null) {
controller.selectDateRange(picked.start, picked.end);
}
},
child: Container(
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: AppColors.borderLight),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.calendar_today, color: AppColors.primary),
SizedBox(width: 12),
Expanded(
child: Text(
controller.formattedDateRange.value.isEmpty
? 'Pilih tanggal sewa'
: controller.formattedDateRange.value,
style: TextStyle(
fontSize: 14,
color: controller.formattedDateRange.value.isEmpty
? AppColors.textSecondary
: AppColors.textPrimary,
),
),
),
Icon(Icons.arrow_forward_ios, size: 16, color: AppColors.textSecondary),
],
),
),
)
// Time selection for hourly rental
else
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Date selection
GestureDetector(
onTap: () async {
final now = DateTime.now();
final initialDate = controller.selectedStartDate.value ?? now;
final DateTime? picked = await showDatePicker(
context: context,
initialDate: initialDate,
firstDate: now,
lastDate: now.add(Duration(days: 30)),
builder: (context, child) {
return Theme(
data: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(
primary: AppColors.primary,
onPrimary: Colors.white,
surface: Colors.white,
onSurface: AppColors.textPrimary,
),
dialogBackgroundColor: Colors.white,
),
child: child!,
);
},
);
if (picked != null) {
controller.selectDate(picked);
}
},
child: Container(
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: AppColors.borderLight),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.calendar_today, color: AppColors.primary),
SizedBox(width: 12),
Expanded(
child: Text(
controller.selectedDate.value.isEmpty
? 'Pilih tanggal sewa'
: controller.selectedDate.value,
style: TextStyle(
fontSize: 14,
color: controller.selectedDate.value.isEmpty
? AppColors.textSecondary
: AppColors.textPrimary,
),
),
),
Icon(Icons.arrow_forward_ios, size: 16, color: AppColors.textSecondary),
],
),
),
),
SizedBox(height: 16),
// Time range selection
controller.selectedDate.value.isEmpty
? SizedBox.shrink()
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Pilih Jam',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary,
),
),
SizedBox(height: 12),
Row(
children: [
// Start time
Expanded(
child: GestureDetector(
onTap: () async {
// Show time picker for start time (8-20)
final List<int> availableHours = List.generate(13, (i) => i + 8);
final int? selectedHour = await showDialog<int>(
context: context,
builder: (context) => SimpleDialog(
title: Text('Pilih Jam Mulai'),
children: availableHours.map((hour) {
return SimpleDialogOption(
onPressed: () => Navigator.pop(context, hour),
child: Text('$hour:00'),
);
}).toList(),
),
);
if (selectedHour != null) {
// If end time is already selected and less than start time, reset it
if (controller.selectedEndTime.value > 0 &&
controller.selectedEndTime.value <= selectedHour) {
controller.selectedEndTime.value = -1;
}
controller.selectedStartTime.value = selectedHour;
if (controller.selectedEndTime.value > 0) {
controller.selectTimeRange(
controller.selectedStartTime.value,
controller.selectedEndTime.value,
);
}
}
},
child: Container(
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: AppColors.borderLight),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.access_time, size: 16, color: AppColors.primary),
SizedBox(width: 8),
Text(
controller.selectedStartTime.value < 0
? 'Jam Mulai'
: '${controller.selectedStartTime.value}:00',
style: TextStyle(
fontSize: 14,
color: controller.selectedStartTime.value < 0
? AppColors.textSecondary
: AppColors.textPrimary,
),
),
],
),
),
),
),
SizedBox(width: 16),
// End time
Expanded(
child: GestureDetector(
onTap: () async {
if (controller.selectedStartTime.value < 0) {
Get.snackbar(
'Perhatian',
'Pilih jam mulai terlebih dahulu',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: AppColors.warning,
colorText: Colors.white,
);
return;
}
// Show time picker for end time (start+1 to 21)
final List<int> availableHours = List.generate(
21 - controller.selectedStartTime.value,
(i) => i + controller.selectedStartTime.value + 1,
);
final int? selectedHour = await showDialog<int>(
context: context,
builder: (context) => SimpleDialog(
title: Text('Pilih Jam Selesai'),
children: availableHours.map((hour) {
return SimpleDialogOption(
onPressed: () => Navigator.pop(context, hour),
child: Text('$hour:00'),
);
}).toList(),
),
);
if (selectedHour != null) {
controller.selectedEndTime.value = selectedHour;
controller.selectTimeRange(
controller.selectedStartTime.value,
controller.selectedEndTime.value,
);
}
},
child: Container(
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: AppColors.borderLight),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.access_time, size: 16, color: AppColors.primary),
SizedBox(width: 8),
Text(
controller.selectedEndTime.value < 0
? 'Jam Selesai'
: '${controller.selectedEndTime.value}:00',
style: TextStyle(
fontSize: 14,
color: controller.selectedEndTime.value < 0
? AppColors.textSecondary
: AppColors.textPrimary,
),
),
],
),
),
),
),
],
),
],
),
],
),
],
),
),
);
}
// Build bottom bar with total price and order button
Widget _buildBottomBar({required VoidCallback onTapPesan}) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: Offset(0, -5),
),
],
),
child: SafeArea(
child: Row(
children: [
// Price info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Total',
style: TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
Obx(
() => Text(
controller.formatPrice(controller.totalPrice.value),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.primary,
),
),
),
],
),
),
// Order button
Obx(
() => ElevatedButton(
onPressed: controller.selectedSatuanWaktu.value == null ||
(controller.isDailyRental() &&
(controller.selectedStartDate.value == null ||
controller.selectedEndDate.value == null)) ||
(!controller.isDailyRental() &&
(controller.selectedStartDate.value == null ||
controller.selectedStartTime.value < 0 ||
controller.selectedEndTime.value < 0))
? null
: onTapPesan,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
'Pesan Sekarang',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
],
),
),
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,758 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/warga_dashboard_controller.dart';
import '../views/warga_layout.dart';
import '../../../theme/app_colors.dart';
import '../../../widgets/app_drawer.dart';
import '../../../routes/app_routes.dart';
import 'package:intl/intl.dart';
class WargaDashboardView extends GetView<WargaDashboardController> {
const WargaDashboardView({super.key});
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
return WillPopScope(
onWillPop: () async => false, // Prevent back navigation
child: WargaLayout(
drawer: AppDrawer(
onNavItemTapped: controller.onNavItemTapped,
onLogout: controller.logout,
),
backgroundColor: AppColors.background,
appBar: AppBar(
elevation: 0,
backgroundColor: AppColors.primary,
title: const Text(
'Beranda',
style: TextStyle(fontWeight: FontWeight.w600),
),
centerTitle: true,
),
body: RefreshIndicator(
color: AppColors.primary,
onRefresh: () async {
// Re-fetch data when pulled down
await Future.delayed(const Duration(seconds: 1));
controller.refreshData();
},
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildUserGreetingHeader(context),
_buildActionButtons(),
_buildActiveRentalsSection(context),
const SizedBox(height: 24),
],
),
),
),
),
);
}
// Modern welcome header with user profile
Widget _buildUserGreetingHeader(BuildContext context) {
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(30),
bottomRight: Radius.circular(30),
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 30),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
// User avatar
Obx(() {
final avatarUrl = controller.userAvatar.value;
return Container(
height: 60,
width: 60,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
color: Colors.white.withOpacity(0.2),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(30),
child:
avatarUrl != null && avatarUrl.isNotEmpty
? Image.network(
avatarUrl,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) =>
_buildAvatarFallback(),
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return _buildAvatarFallback();
},
)
: _buildAvatarFallback(),
),
);
}),
const SizedBox(width: 16),
// Greeting and name
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_getGreeting(),
style: const TextStyle(
fontSize: 15,
color: Colors.white70,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Obx(
() => Text(
controller.userName.value,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
),
],
),
),
),
);
}
// Action buttons in a horizontal scroll
Widget _buildActionButtons() {
// Define services - removed Langganan and Pengaduan
final services = [
{
'title': 'Sewa',
'icon': Icons.home_work_outlined,
'color': const Color(0xFF4CAF50),
'route': () => controller.navigateToRentals(),
},
{
'title': 'Bayar',
'icon': Icons.payment_outlined,
'color': const Color(0xFF2196F3),
'route': () => Get.toNamed(Routes.PEMBAYARAN_SEWA),
},
];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(20, 10, 20, 10),
child: Text(
'Layanan',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
),
// Service cards in grid
GridView.count(
crossAxisCount: 2,
childAspectRatio: 1.5,
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
padding: const EdgeInsets.symmetric(horizontal: 16),
mainAxisSpacing: 16,
crossAxisSpacing: 16,
children:
services
.map(
(service) => _buildServiceCard(
title: service['title'] as String,
icon: service['icon'] as IconData,
color: service['color'] as Color,
onTap: service['route'] as VoidCallback,
),
)
.toList(),
),
// Activity Summaries Section
Padding(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 10),
child: Text(
'Ringkasan Aktivitas',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
),
// Summary Cards
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
// Sewa Diterima
_buildActivityCard(
title: 'Sewa Diterima',
value: controller.activeRentals.length.toString(),
icon: Icons.check_circle_outline,
color: AppColors.success,
onTap: () => controller.navigateToRentals(),
),
const SizedBox(height: 12),
// Tagihan Aktif
_buildActivityCard(
title: 'Tagihan Aktif',
value: controller.activeBills.length.toString(),
icon: Icons.receipt_long_outlined,
color: AppColors.warning,
onTap: () => Get.toNamed(Routes.PEMBAYARAN_SEWA),
),
const SizedBox(height: 12),
// Denda Aktif
_buildActivityCard(
title: 'Denda Aktif',
value: controller.activePenalties.length.toString(),
icon: Icons.warning_amber_outlined,
color: AppColors.error,
onTap: () => Get.toNamed(Routes.PEMBAYARAN_SEWA),
),
],
),
),
],
);
}
Widget _buildServiceCard({
required String title,
required IconData icon,
required Color color,
required VoidCallback onTap,
}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [color.withOpacity(0.7), color],
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: color.withOpacity(0.3),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Stack(
children: [
// Background decoration
Positioned(
right: -15,
bottom: -15,
child: Container(
width: 90,
height: 90,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
shape: BoxShape.circle,
),
),
),
Positioned(
left: -20,
top: -20,
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
shape: BoxShape.circle,
),
),
),
// Icon and text
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Icon(icon, color: Colors.white, size: 24),
),
Text(
title,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
],
),
),
],
),
),
),
);
}
// Active rentals section with improved card design
Widget _buildActiveRentalsSection(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Sewa Diterima',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
TextButton.icon(
onPressed: () => controller.onNavItemTapped(1),
icon: const Icon(Icons.arrow_forward, size: 18),
label: const Text('Lihat Semua'),
style: TextButton.styleFrom(foregroundColor: AppColors.primary),
),
],
),
const SizedBox(height: 12),
Obx(() {
if (controller.activeRentals.isEmpty) {
return _buildEmptyState(
message: 'Belum ada sewa aset yang aktif',
icon: Icons.inventory_2_outlined,
buttonText: 'Sewa Sekarang',
onPressed: () => controller.navigateToRentals(),
);
}
return Column(
children:
controller.activeRentals
.map((rental) => _buildModernRentalCard(rental))
.toList(),
);
}),
],
),
);
}
// Empty state widget with consistent design
Widget _buildEmptyState({
required String message,
required IconData icon,
required String buttonText,
required VoidCallback onPressed,
}) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
border: Border.all(color: Colors.grey.shade100),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.primary.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(icon, size: 40, color: AppColors.primary),
),
const SizedBox(height: 20),
Text(
message,
style: TextStyle(
fontSize: 16,
color: AppColors.textSecondary,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
),
child: Text(
buttonText,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
),
],
),
);
}
// Modern rental card with better layout
Widget _buildModernRentalCard(Map<String, dynamic> rental) {
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
offset: const Offset(0, 4),
blurRadius: 15,
),
],
border: Border.all(color: Colors.grey.shade100, width: 1.0),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with glass effect
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppColors.primary.withOpacity(0.04),
AppColors.primary.withOpacity(0.08),
],
),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
child: Row(
children: [
// Asset icon
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppColors.primary.withOpacity(0.7),
AppColors.primary,
],
),
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: AppColors.primary.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
child: const Icon(
Icons.local_shipping,
color: Colors.white,
size: 24,
),
),
const SizedBox(width: 16),
// Asset details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
rental['name'],
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 4),
Text(
rental['time'],
style: TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
),
),
],
),
),
// Price tag
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: AppColors.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.primary.withOpacity(0.3),
width: 1.0,
),
),
child: Text(
rental['price'],
style: TextStyle(
color: AppColors.primary,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
],
),
),
// Details and actions
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
child: Column(
children: [
// Details row
Row(
children: [
Expanded(
child: _buildInfoItem(
icon: Icons.timer_outlined,
title: 'Durasi',
value: rental['duration'],
),
),
Expanded(
child: _buildInfoItem(
icon: Icons.calendar_today_outlined,
title: 'Status',
value: 'Diterima',
valueColor: AppColors.success,
),
),
],
),
const SizedBox(height: 16),
// Action buttons
if (rental['can_extend'])
OutlinedButton.icon(
onPressed: () => controller.extendRental(rental['id']),
icon: const Icon(Icons.update, size: 18),
label: const Text('Perpanjang'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primary,
side: BorderSide(color: AppColors.primary),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 16,
),
),
),
],
),
),
],
),
);
}
// Info item for displaying details
Widget _buildInfoItem({
required IconData icon,
required String title,
required String value,
Color? valueColor,
}) {
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
),
const SizedBox(height: 4),
Row(
children: [
Icon(icon, size: 16, color: AppColors.primary),
const SizedBox(width: 4),
Text(
value,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: valueColor ?? AppColors.textPrimary,
),
),
],
),
],
),
);
}
// Build avatar fallback for when image is not available
Widget _buildAvatarFallback() {
return Center(child: Icon(Icons.person, color: Colors.white70, size: 30));
}
// Get appropriate greeting based on time of day
String _getGreeting() {
final hour = DateTime.now().hour;
if (hour < 12) {
return 'Selamat Pagi';
} else if (hour < 17) {
return 'Selamat Siang';
} else {
return 'Selamat Malam';
}
}
// Build a summary card for activities
Widget _buildActivityCard({
required String title,
required String value,
required IconData icon,
required Color color,
required VoidCallback onTap,
}) {
return Card(
elevation: 1,
shadowColor: Colors.black12,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.grey.shade200),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: color, size: 24),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 4),
Text(
value,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
],
),
),
Icon(
Icons.arrow_forward_ios,
color: AppColors.textSecondary.withOpacity(0.5),
size: 16,
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../widgets/app_bottom_navbar.dart';
import '../../../services/navigation_service.dart';
import '../../../routes/app_routes.dart';
/// A wrapper layout that provides a persistent bottom navigation bar
/// and a content area for warga user pages.
class WargaLayout extends StatelessWidget {
final Widget body;
final PreferredSizeWidget? appBar;
final Widget? drawer;
final Color? backgroundColor;
final Widget? floatingActionButton;
final FloatingActionButtonLocation? floatingActionButtonLocation;
const WargaLayout({
super.key,
required this.body,
this.appBar,
this.drawer,
this.backgroundColor,
this.floatingActionButton,
this.floatingActionButtonLocation,
});
@override
Widget build(BuildContext context) {
// Access the navigation service
final navigationService = Get.find<NavigationService>();
// Single Scaffold that contains all components
return Scaffold(
backgroundColor: backgroundColor ?? Colors.grey.shade100,
appBar: appBar,
// Drawer configuration for proper overlay
drawer: drawer,
drawerEdgeDragWidth: 60, // Wider drag area for easier access
drawerEnableOpenDragGesture: true,
// Higher opacity ensures good contrast & visibility when drawer opens
drawerScrimColor: Colors.black.withOpacity(0.6),
// Main body content
body: body,
// Bottom navigation bar
bottomNavigationBar: AppBottomNavbar(
selectedIndex: navigationService.currentNavIndex.value,
onItemTapped: (index) => _handleNavigation(index, navigationService),
),
floatingActionButton: floatingActionButton,
floatingActionButtonLocation: floatingActionButtonLocation,
);
}
// Handle navigation for bottom navbar
void _handleNavigation(int index, NavigationService navigationService) {
if (navigationService.currentNavIndex.value == index) {
return; // Don't do anything if already on this tab
}
navigationService.setNavIndex(index);
// Navigate to the appropriate page
switch (index) {
case 0:
Get.offAllNamed(Routes.WARGA_DASHBOARD);
break;
case 1:
navigationService.toWargaSewa();
break;
case 2:
navigationService.toProfile();
break;
}
}
}

View File

@ -0,0 +1,455 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/warga_dashboard_controller.dart';
import '../views/warga_layout.dart';
import '../../../theme/app_colors.dart';
class WargaProfileView extends GetView<WargaDashboardController> {
const WargaProfileView({super.key});
@override
Widget build(BuildContext context) {
return WargaLayout(
appBar: AppBar(
title: const Text('Profil Saya'),
backgroundColor: AppColors.primary,
elevation: 0,
centerTitle: true,
actions: [
IconButton(
onPressed: () {
Get.snackbar(
'Info',
'Fitur edit profil akan segera tersedia',
snackPosition: SnackPosition.BOTTOM,
);
},
icon: const Icon(Icons.edit_outlined),
tooltip: 'Edit Profil',
),
],
),
backgroundColor: Colors.grey.shade100,
body: RefreshIndicator(
color: AppColors.primary,
onRefresh: () async {
await Future.delayed(const Duration(milliseconds: 500));
controller.refreshData();
return;
},
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Column(
children: [
_buildProfileHeader(context),
const SizedBox(height: 16),
_buildInfoCard(context),
const SizedBox(height: 16),
_buildSettingsCard(context),
const SizedBox(height: 24),
],
),
),
),
);
}
Widget _buildProfileHeader(BuildContext context) {
return Container(
width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppColors.primary,
AppColors.primary.withBlue(AppColors.primary.blue + 30),
],
),
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(30),
bottomRight: Radius.circular(30),
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 36),
child: Column(
children: [
// Profile picture with shadow effect
Obx(() {
final avatarUrl = controller.userAvatar.value;
return Container(
height: 110,
width: 110,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 4),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(55),
child:
avatarUrl != null && avatarUrl.isNotEmpty
? Image.network(
avatarUrl,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) =>
_buildAvatarFallback(),
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return _buildAvatarFallback();
},
)
: _buildAvatarFallback(),
),
);
}),
const SizedBox(height: 16),
// User name with subtle text shadow
Obx(
() => Text(
controller.userName.value,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: [
Shadow(
color: Colors.black26,
blurRadius: 2,
offset: Offset(0, 1),
),
],
),
),
),
const SizedBox(height: 6),
// User role in a stylish chip
Obx(
() => Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.3),
borderRadius: BorderRadius.circular(30),
border: Border.all(
color: Colors.white.withOpacity(0.5),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.verified_user,
size: 14,
color: Colors.white.withOpacity(0.9),
),
const SizedBox(width: 6),
Text(
controller.userRole.value,
style: TextStyle(
fontSize: 14,
color: Colors.white.withOpacity(0.9),
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
],
),
),
),
],
),
),
);
}
Widget _buildInfoCard(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: Colors.grey.shade200),
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
Icon(Icons.person_outline, color: AppColors.primary, size: 18),
const SizedBox(width: 8),
Text(
'INFORMASI PERSONAL',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.grey.shade700,
letterSpacing: 0.5,
),
),
],
),
),
const Divider(height: 1),
_buildInfoItem(
icon: Icons.email_outlined,
title: 'Email',
value:
controller.userEmail.value.isEmpty
? 'emailpengguna@example.com'
: controller.userEmail.value,
),
Divider(height: 1, color: Colors.grey.shade200),
_buildInfoItem(
icon: Icons.credit_card_outlined,
title: 'NIK',
value:
controller.userNik.value.isEmpty
? '123456789012345'
: controller.userNik.value,
),
Divider(height: 1, color: Colors.grey.shade200),
_buildInfoItem(
icon: Icons.phone_outlined,
title: 'Nomor Telepon',
value:
controller.userPhone.value.isEmpty
? '081234567890'
: controller.userPhone.value,
),
Divider(height: 1, color: Colors.grey.shade200),
_buildInfoItem(
icon: Icons.home_outlined,
title: 'Alamat Lengkap',
value:
controller.userAddress.value.isEmpty
? 'Jl. Contoh No. 123, Desa Sejahtera, Kec. Makmur, Kab. Berkah, Prov. Damai'
: controller.userAddress.value,
isMultiLine: true,
),
],
),
);
}
Widget _buildInfoItem({
required IconData icon,
required String title,
required String value,
bool isMultiLine = false,
}) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
crossAxisAlignment:
isMultiLine ? CrossAxisAlignment.start : CrossAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: AppColors.primary, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
),
const SizedBox(height: 3),
Text(
value,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Colors.grey.shade800,
),
maxLines: isMultiLine ? 3 : 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
);
}
Widget _buildSettingsCard(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: Colors.grey.shade200),
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
Icon(
Icons.settings_outlined,
color: AppColors.primary,
size: 18,
),
const SizedBox(width: 8),
Text(
'PENGATURAN',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.grey.shade700,
letterSpacing: 0.5,
),
),
],
),
),
const Divider(height: 1),
_buildActionItem(
icon: Icons.lock_outline,
title: 'Ubah Password',
iconColor: AppColors.primary,
onTap: () {
Get.snackbar(
'Info',
'Fitur Ubah Password akan segera tersedia',
snackPosition: SnackPosition.BOTTOM,
);
},
),
Divider(height: 1, color: Colors.grey.shade200),
_buildActionItem(
icon: Icons.logout,
title: 'Keluar',
iconColor: Colors.red.shade400,
isDestructive: true,
onTap: () {
_showLogoutConfirmation(context);
},
),
],
),
);
}
Widget _buildActionItem({
required IconData icon,
required String title,
required VoidCallback onTap,
Color? iconColor,
bool isDestructive = false,
}) {
final color =
isDestructive ? Colors.red.shade400 : iconColor ?? AppColors.primary;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: color, size: 20),
),
const SizedBox(width: 12),
Text(
title,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color:
isDestructive ? Colors.red.shade400 : Colors.grey.shade800,
),
),
const Spacer(),
Icon(Icons.chevron_right, color: Colors.grey.shade400, size: 20),
],
),
),
);
}
void _showLogoutConfirmation(BuildContext context) {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: const Text('Konfirmasi Keluar'),
content: const Text('Apakah Anda yakin ingin keluar dari aplikasi?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
style: TextButton.styleFrom(
foregroundColor: Colors.grey.shade700,
),
child: const Text('Batal'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
controller.logout();
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.shade400,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('Keluar'),
),
],
);
},
);
}
Widget _buildAvatarFallback() {
return Container(
color: Colors.grey.shade200,
child: Center(
child: Icon(
Icons.person,
color: AppColors.primary.withOpacity(0.7),
size: 50,
),
),
);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../routes/app_routes.dart';
import '../../../services/navigation_service.dart';
class AppBottomNavbar extends StatelessWidget {
final int selectedIndex;
final Function(int) onItemTapped;
const AppBottomNavbar({
super.key,
required this.selectedIndex,
required this.onItemTapped,
});
@override
Widget build(BuildContext context) {
// Get navigation service to sync with drawer
final navigationService = Get.find<NavigationService>();
return Container(
height: 76,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.07),
blurRadius: 14,
offset: const Offset(0, -2),
),
],
),
child: Obx(
() => Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildNavItem(
context: context,
icon: Icons.home_rounded,
activeIcon: Icons.home_rounded,
label: 'Beranda',
isSelected: navigationService.currentNavIndex.value == 0,
onTap: () {
if (navigationService.currentNavIndex.value != 0) {
onItemTapped(0);
navigationService.setNavIndex(0);
Get.offAllNamed(Routes.WARGA_DASHBOARD);
}
},
),
_buildNavItem(
context: context,
icon: Icons.inventory_outlined,
activeIcon: Icons.inventory_rounded,
label: 'Sewa',
isSelected: navigationService.currentNavIndex.value == 1,
onTap: () {
if (navigationService.currentNavIndex.value != 1) {
onItemTapped(1);
navigationService.toWargaSewa();
}
},
),
_buildNavItem(
context: context,
icon: Icons.person_outline,
activeIcon: Icons.person,
label: 'Profil',
isSelected: navigationService.currentNavIndex.value == 2,
onTap: () {
if (navigationService.currentNavIndex.value != 2) {
onItemTapped(2);
navigationService.toProfile();
}
},
),
],
),
),
);
}
// Modern navigation item for bottom bar
Widget _buildNavItem({
required BuildContext context,
required IconData icon,
required IconData activeIcon,
required String label,
required bool isSelected,
required VoidCallback onTap,
}) {
final theme = Theme.of(context);
final primaryColor = theme.primaryColor;
final tabWidth = MediaQuery.of(context).size.width / 3; // Changed to 3 tabs
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
customBorder: const StadiumBorder(),
splashColor: primaryColor.withOpacity(0.1),
highlightColor: primaryColor.withOpacity(0.05),
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
width: tabWidth,
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: isSelected ? primaryColor : Colors.transparent,
width: 2,
),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Icon with animated scale effect when selected
AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: EdgeInsets.all(isSelected ? 8 : 0),
decoration: BoxDecoration(
color:
isSelected
? primaryColor.withOpacity(0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
isSelected ? activeIcon : icon,
color: isSelected ? primaryColor : Colors.grey.shade400,
size: 24,
),
),
const SizedBox(height: 4),
// Label with animated opacity
AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 200),
style: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
color: isSelected ? primaryColor : Colors.grey.shade500,
),
child: Text(label),
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,517 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../theme/app_colors.dart';
class CustomDateRangePicker extends StatefulWidget {
final List<DateTime> disabledDates;
final Function(DateTime startDate, DateTime endDate) onSelectRange;
final DateTime? initialStartDate;
final DateTime? initialEndDate;
final int? maxDays; // Maximum allowed days between start and end date
final Function? onClearSelection; // Callback when selection is cleared
final bool singleDateMode; // When true, only allows selecting a single date
const CustomDateRangePicker({
super.key,
required this.disabledDates,
required this.onSelectRange,
this.initialStartDate,
this.initialEndDate,
this.maxDays,
this.onClearSelection,
this.singleDateMode = false,
});
@override
_CustomDateRangePickerState createState() => _CustomDateRangePickerState();
}
class _CustomDateRangePickerState extends State<CustomDateRangePicker> {
late DateTime _currentMonth;
DateTime? _startDate;
DateTime? _endDate;
DateTime? _hoverDate;
bool _selectionMode =
false; // true means selecting end date, false means selecting start date
// Map for O(1) lookup of disabled dates
late Set<String> _disabledDateStrings;
@override
void initState() {
super.initState();
_currentMonth = DateTime.now();
_startDate = widget.initialStartDate;
_endDate = widget.initialEndDate;
_selectionMode = _startDate != null && _endDate == null;
// Create a set of strings from disabled dates for faster lookup
_disabledDateStrings = {};
for (var date in widget.disabledDates) {
_disabledDateStrings.add('${date.year}-${date.month}-${date.day}');
}
}
// Check if a date is disabled
bool _isDisabled(DateTime date) {
final dateString = '${date.year}-${date.month}-${date.day}';
return _disabledDateStrings.contains(dateString);
}
// Check if a date is before today or is today
bool _isBeforeToday(DateTime date) {
final today = DateTime.now();
final todayDate = DateTime(today.year, today.month, today.day);
final checkDate = DateTime(date.year, date.month, date.day);
// Return true if date is before today (not including today)
return checkDate.isBefore(todayDate);
}
// Check if a date can be selected
bool _canSelectDate(DateTime date) {
return !_isDisabled(date) && !_isBeforeToday(date);
}
// Get the status of a date (start, end, in-range, disabled, normal)
String _getDateStatus(DateTime date) {
if (_isDisabled(date) || _isBeforeToday(date)) {
return 'disabled';
}
if (_startDate != null && _isSameDay(date, _startDate!)) {
return 'start';
}
if (_endDate != null && _isSameDay(date, _endDate!)) {
return 'end';
}
if (_startDate != null &&
_endDate != null &&
date.isAfter(_startDate!) &&
date.isBefore(_endDate!)) {
return 'in-range';
}
return 'normal';
}
// Check if two dates are the same day
bool _isSameDay(DateTime a, DateTime b) {
return a.year == b.year && a.month == b.month && a.day == b.day;
}
// Handle date tap - now just sets start and optionally end date
void _onDateTap(DateTime date) {
if (!_canSelectDate(date)) return;
setState(() {
// If we're in single date mode, simply set both start and end date to the selected date
if (widget.singleDateMode) {
// If tapping on the already selected date, clear the selection
if (_startDate != null && _isSameDay(date, _startDate!)) {
_startDate = null;
_endDate = null;
_selectionMode = false;
if (widget.onClearSelection != null) {
widget.onClearSelection!();
}
} else {
// Set both start and end date to the selected date
_startDate = date;
_endDate = date;
// Immediately confirm selection in single date mode
Future.microtask(() => _confirmSelection());
}
return;
}
// Regular date range selection behavior (for non-single date mode)
// If tapping on the start date when already selected
if (_startDate != null && _isSameDay(date, _startDate!)) {
// If only start date is selected, clear selection
if (_endDate == null) {
_startDate = null;
_selectionMode = false;
if (widget.onClearSelection != null) {
widget.onClearSelection!();
}
return;
}
// If both dates are selected, move end date to start and clear end date
else if (!_isSameDay(_startDate!, _endDate!)) {
_startDate = _endDate;
_endDate = null;
_selectionMode = true;
return;
}
// If both dates are the same, clear both
else {
_startDate = null;
_endDate = null;
_selectionMode = false;
if (widget.onClearSelection != null) {
widget.onClearSelection!();
}
return;
}
}
// If tapping on the end date when already selected
if (_endDate != null && _isSameDay(date, _endDate!)) {
// Clear end date but keep start date
_endDate = null;
_selectionMode = true;
return;
}
if (!_selectionMode) {
// Selecting start date
_startDate = date;
_endDate = null;
_selectionMode = true;
} else {
// Selecting end date
if (date.isBefore(_startDate!)) {
// If selecting a date before start, swap them
_endDate = _startDate;
_startDate = date;
} else {
// Check if the selection exceeds the maximum allowed days
if (widget.maxDays != null) {
final daysInRange = date.difference(_startDate!).inDays + 1;
if (daysInRange > widget.maxDays!) {
// Show a message about exceeding the maximum days
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Maksimal ${widget.maxDays} hari! Anda memilih $daysInRange hari.',
),
backgroundColor: Colors.red,
),
);
return; // Don't proceed with the selection
}
}
_endDate = date;
}
// Check if any date in the range is disabled (only if we have an end date)
if (_endDate != null && !_isSameDay(_startDate!, _endDate!)) {
_checkRangeForDisabledDates();
}
}
});
}
// Check if range contains any disabled dates
bool _checkRangeForDisabledDates() {
if (_startDate == null || _endDate == null) return false;
bool hasDisabledDate = false;
for (
DateTime d = _startDate!;
!d.isAfter(_endDate!);
d = d.add(const Duration(days: 1))
) {
if (d != _startDate && d != _endDate && _isDisabled(d)) {
hasDisabledDate = true;
break;
}
}
if (hasDisabledDate) {
// Reset selection if range contains disabled date
_endDate = null;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Rentang tanggal mengandung tanggal yang tidak tersedia',
),
backgroundColor: Colors.red,
),
);
return true;
}
return false;
}
// Confirm the selection (either single day or range)
void _confirmSelection() {
if (_startDate == null) return;
// If no end date is selected, use start date as end date
_endDate ??= _startDate;
// Now notify the parent widget
widget.onSelectRange(_startDate!, _endDate!);
}
// Generate the calendar for a month
Widget _buildCalendarMonth(DateTime month) {
final daysInMonth = DateTime(month.year, month.month + 1, 0).day;
final firstDayOfMonth = DateTime(month.year, month.month, 1);
final dayOfWeek = firstDayOfMonth.weekday % 7; // 0 = Sunday, 6 = Saturday
// Headers for days of week
final daysOfWeek = ['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min'];
return Column(
children: [
// Month and year header
Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: Text(
DateFormat('MMMM yyyy', 'id_ID').format(month),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.primary,
),
),
),
// Days of week header
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children:
daysOfWeek
.map(
(day) => SizedBox(
width: 36,
child: Text(
day,
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.bold,
color: AppColors.textSecondary,
),
),
),
)
.toList(),
),
const SizedBox(height: 8),
// Calendar days grid
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 7,
childAspectRatio: 1,
),
itemCount: (dayOfWeek + daysInMonth),
itemBuilder: (context, index) {
// Empty cells for days before the 1st of the month
if (index < dayOfWeek) {
return const SizedBox();
}
final day = index - dayOfWeek + 1;
final date = DateTime(month.year, month.month, day);
final status = _getDateStatus(date);
return GestureDetector(
onTap: () => _onDateTap(date),
child: Container(
margin: const EdgeInsets.all(2),
decoration: BoxDecoration(
color:
status == 'in-range'
? AppColors.primarySoft
: status == 'start' || status == 'end'
? AppColors.primary
: null,
borderRadius: BorderRadius.circular(8),
),
child: Stack(
alignment: Alignment.center,
children: [
// Date number
Text(
day.toString(),
style: TextStyle(
color:
status == 'disabled'
? Colors.grey.shade400
: status == 'start' || status == 'end'
? AppColors.textOnPrimary
: AppColors.textPrimary,
fontWeight:
status == 'start' || status == 'end'
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
);
},
),
],
);
}
// Get selection status text
String? _getSelectionStatusText() {
if (widget.singleDateMode) {
if (_startDate == null) {
return 'Silakan pilih tanggal untuk sewa per jam';
} else {
return 'Tanggal dipilih: ${DateFormat('dd MMM yyyy', 'id_ID').format(_startDate!)}';
}
}
if (_startDate == null) {
return 'Pilih tanggal mulai'; // Guide user to select start date
} else if (_endDate == null) {
return 'Tanggal mulai: ${DateFormat('dd MMM yyyy', 'id_ID').format(_startDate!)} - Pilih tanggal akhir atau konfirmasi untuk sewa satu hari';
} else {
if (_isSameDay(_startDate!, _endDate!)) {
return 'Satu hari dipilih: ${DateFormat('dd MMM yyyy', 'id_ID').format(_startDate!)}';
} else {
final int days = _endDate!.difference(_startDate!).inDays + 1;
return '${DateFormat('dd MMM yyyy', 'id_ID').format(_startDate!)} - ${DateFormat('dd MMM yyyy', 'id_ID').format(_endDate!)} ($days hari)';
}
}
}
// Check if a date can be highlighted as potential end date during hover
bool _canBeEndDate(DateTime date) {
if (!_canSelectDate(date)) return false;
if (_startDate == null) return false;
// If date is before start date, it can't be an end date
if (date.isBefore(_startDate!)) return false;
// Check if the range would exceed the maximum days
if (widget.maxDays != null) {
final daysInRange = date.difference(_startDate!).inDays + 1;
if (daysInRange > widget.maxDays!) return false;
}
// Check if any dates in the range are disabled
for (
DateTime d = _startDate!;
!d.isAfter(date);
d = d.add(const Duration(days: 1))
) {
if (!_isSameDay(d, _startDate!) &&
!_isSameDay(d, date) &&
_isDisabled(d)) {
return false;
}
}
return true;
}
@override
Widget build(BuildContext context) {
return Column(
children: [
// Selection status - only shown when a date is selected
Builder(
builder: (context) {
final statusText = _getSelectionStatusText();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
statusText ?? 'Pilih tanggal untuk memesan',
style: TextStyle(
color: AppColors.textSecondary,
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.center,
),
);
},
),
// Display current month
_buildCalendarMonth(_currentMonth),
// Hint for deselection
if (_startDate != null)
Padding(
padding: const EdgeInsets.only(top: 8.0, bottom: 4.0),
child: Text(
"Tekan tanggal yang sudah dipilih untuk membatalkan",
style: TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.center,
),
),
// Month navigation
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: Icon(Icons.arrow_back_ios, color: AppColors.primary),
onPressed: () {
setState(() {
_currentMonth = DateTime(
_currentMonth.year,
_currentMonth.month - 1,
);
});
},
),
IconButton(
icon: Icon(Icons.arrow_forward_ios, color: AppColors.primary),
onPressed: () {
setState(() {
_currentMonth = DateTime(
_currentMonth.year,
_currentMonth.month + 1,
);
});
},
),
],
),
),
// Controls
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
style: TextButton.styleFrom(
foregroundColor: AppColors.textSecondary,
),
child: const Text('Batal'),
),
// Hide confirm button in single date mode as selection is auto-confirmed
if (!widget.singleDateMode)
ElevatedButton(
onPressed: _startDate != null ? _confirmSelection : null,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: AppColors.textOnPrimary,
),
child: const Text('Konfirmasi'),
),
],
),
),
],
);
}
}