diff --git a/lib/app/data/models/paket_model.dart b/lib/app/data/models/paket_model.dart index 74c324b..befd998 100644 --- a/lib/app/data/models/paket_model.dart +++ b/lib/app/data/models/paket_model.dart @@ -1,54 +1,156 @@ import 'dart:convert'; class PaketModel { - final String? id; - final String? nama; - final String? deskripsi; - final int? harga; - final int? kuantitas; - final String? foto_paket; - final List? satuanWaktuSewa; - + final String id; + final String nama; + final String deskripsi; + final double harga; + final int kuantitas; + final List foto; + final List> satuanWaktuSewa; + final DateTime createdAt; + final DateTime updatedAt; + final String? foto_paket; // Main photo URL + final List? images; // List of photo URLs + PaketModel({ - this.id, - this.nama, - this.deskripsi, - this.harga, - this.kuantitas, + required this.id, + required this.nama, + required this.deskripsi, + required this.harga, + required this.kuantitas, + required this.foto, + required this.satuanWaktuSewa, this.foto_paket, - this.satuanWaktuSewa, + this.images, + required this.createdAt, + required this.updatedAt, }); - Map toMap() { - return { - 'id': id, - 'nama': nama, - 'deskripsi': deskripsi, - 'harga': harga, - 'kuantitas': kuantitas, - 'foto_paket': foto_paket, - 'satuanWaktuSewa': satuanWaktuSewa, - }; - } - - factory PaketModel.fromMap(Map map) { + // Alias for fromJson to maintain compatibility + factory PaketModel.fromMap(Map json) => PaketModel.fromJson(json); + + factory PaketModel.fromJson(Map json) { + // Handle different possible JSON structures + final fotoList = []; + + // Check for different possible photo field names + if (json['foto'] != null) { + if (json['foto'] is String) { + fotoList.add(json['foto']); + } else if (json['foto'] is List) { + fotoList.addAll((json['foto'] as List).whereType()); + } + } + + if (json['foto_paket'] != null) { + if (json['foto_paket'] is String) { + fotoList.add(json['foto_paket']); + } else if (json['foto_paket'] is List) { + fotoList.addAll((json['foto_paket'] as List).whereType()); + } + } + + // Handle satuan_waktu_sewa + List> satuanWaktuList = []; + if (json['satuan_waktu_sewa'] != null) { + if (json['satuan_waktu_sewa'] is List) { + satuanWaktuList = List>.from( + json['satuan_waktu_sewa'].map((x) => x is Map ? Map.from(x) : {}) + ); + } else if (json['satuan_waktu_sewa'] is Map) { + satuanWaktuList = [Map.from(json['satuan_waktu_sewa'])]; + } + } + return PaketModel( - id: map['id'], - nama: map['nama'], - deskripsi: map['deskripsi'], - harga: map['harga']?.toInt(), - kuantitas: map['kuantitas']?.toInt(), - foto_paket: map['foto_paket'], - satuanWaktuSewa: map['satuanWaktuSewa'], + id: json['id']?.toString() ?? '', + nama: json['nama']?.toString() ?? '', + deskripsi: json['deskripsi']?.toString() ?? '', + harga: (json['harga'] is num) ? (json['harga'] as num).toDouble() : 0.0, + kuantitas: (json['kuantitas'] is num) ? (json['kuantitas'] as num).toInt() : 1, + foto: fotoList, + satuanWaktuSewa: satuanWaktuList, + foto_paket: json['foto_paket']?.toString(), + images: json['images'] != null ? List.from(json['images']) : null, + createdAt: json['created_at'] != null + ? DateTime.parse(json['created_at'].toString()) + : DateTime.now(), + updatedAt: json['updated_at'] != null + ? DateTime.parse(json['updated_at'].toString()) + : DateTime.now(), ); } - String toJson() => json.encode(toMap()); + // Convert to JSON + Map toJson() => { + 'id': id, + 'nama': nama, + 'deskripsi': deskripsi, + 'harga': harga, + 'kuantitas': kuantitas, + 'foto': foto, + 'foto_paket': foto_paket, + 'images': images, + 'satuan_waktu_sewa': satuanWaktuSewa, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + }; - factory PaketModel.fromJson(String source) => PaketModel.fromMap(json.decode(source)); - - @override - String toString() { - return 'PaketModel(id: $id, nama: $nama, deskripsi: $deskripsi, harga: $harga, kuantitas: $kuantitas, foto_paket: $foto_paket, satuanWaktuSewa: $satuanWaktuSewa)'; + // Create a copy of the model with some fields updated + PaketModel copyWith({ + String? id, + String? nama, + String? deskripsi, + double? harga, + int? kuantitas, + List? foto, + List>? satuanWaktuSewa, + String? foto_paket, + List? images, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return PaketModel( + id: id ?? this.id, + nama: nama ?? this.nama, + deskripsi: deskripsi ?? this.deskripsi, + harga: harga ?? this.harga, + kuantitas: kuantitas ?? this.kuantitas, + foto: foto ?? List.from(this.foto), + satuanWaktuSewa: satuanWaktuSewa ?? List.from(this.satuanWaktuSewa), + foto_paket: foto_paket ?? this.foto_paket, + images: images ?? (this.images != null ? List.from(this.images!) : null), + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + // Get the first photo URL or a placeholder + String get firstPhotoUrl => foto.isNotEmpty ? foto.first : ''; + + // Get the formatted price + String get formattedPrice => 'Rp${harga.toStringAsFixed(0).replaceAllMapped( + RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), + (Match m) => '${m[1]}.', + )}'; + + // Check if the package is available + bool get isAvailable => kuantitas > 0; + + // Get the first available time unit + Map? get defaultTimeUnit => + satuanWaktuSewa.isNotEmpty ? satuanWaktuSewa.first : null; + + // Get the price for a specific time unit + double getPriceForTimeUnit(String timeUnitId) { + try { + final unit = satuanWaktuSewa.firstWhere( + (unit) => unit['id'] == timeUnitId || unit['id'].toString() == timeUnitId, + ); + return (unit['harga'] as num?)?.toDouble() ?? 0.0; + } catch (e) { + return 0.0; + } } } diff --git a/lib/app/data/providers/aset_provider.dart b/lib/app/data/providers/aset_provider.dart index 5262fc6..197db5a 100644 --- a/lib/app/data/providers/aset_provider.dart +++ b/lib/app/data/providers/aset_provider.dart @@ -846,28 +846,6 @@ class AsetProvider extends GetxService { return null; } } - - // Get bank accounts from akun_bank table - Future>> getBankAccounts() async { - try { - debugPrint('๐Ÿ” Fetching bank accounts from akun_bank table'); - - final response = await client - .from('akun_bank') - .select('*') - .order('nama_bank', ascending: true); - - debugPrint('โœ… Fetched ${response.length} bank accounts'); - - // Convert response to List> - List> accounts = List>.from(response); - - return accounts; - } catch (e) { - debugPrint('โŒ Error fetching bank accounts: $e'); - return []; - } - } // Get sewa_aset details with aset data Future?> getSewaAsetWithAsetData( @@ -967,29 +945,6 @@ class AsetProvider extends GetxService { return null; } } - - // Get all photos for a paket using id_paket column - Future> getFotoPaket(String paketId) async { - try { - debugPrint('๐Ÿ“ท Fetching all photos for paket ID: $paketId'); - - final response = await client - .from('foto_aset') - .select('*') - .eq('id_paket', paketId); - - if (response.isEmpty) { - debugPrint('โš ๏ธ No photos found for paket $paketId'); - return []; - } - - debugPrint('โœ… Found ${response.length} photos for paket $paketId'); - return response; - } catch (e) { - debugPrint('โŒ Error fetching photos for paket $paketId: $e'); - return []; - } - } // Get paket data with their associated satuan waktu sewa data Future> getPakets() async { @@ -1058,8 +1013,7 @@ class AsetProvider extends GetxService { // Default to hourly if not specified String satuanWaktu = 'jam'; - if (swsResponse != null && - swsResponse['satuan_waktu'] != null && + if (swsResponse['satuan_waktu'] != null && swsResponse['satuan_waktu']['nama'] != null) { satuanWaktu = swsResponse['satuan_waktu']['nama']; } @@ -1087,7 +1041,7 @@ class AsetProvider extends GetxService { final response = await client.from('sewa_paket').insert(sewa).select(); - if (response != null && response.isNotEmpty) { + if (response.isNotEmpty) { return true; } return false; @@ -1096,4 +1050,189 @@ class AsetProvider extends GetxService { return false; } } + + // Get photos for a package + Future> getFotoPaket(String paketId) async { + try { + final response = await client + .from('foto_aset') + .select('foto_aset') + .eq('id_paket', paketId) + .order('created_at'); + + if (response != null && response.isNotEmpty) { + return response.map((item) => item['foto_aset'] as String).toList(); + } + return []; + } catch (e) { + debugPrint('Error getting package photos: $e'); + return []; + } + } + + // Get items included in a package with additional asset details + Future>> getPaketItems(String paketId) async { + debugPrint('๐Ÿ”„ [1/3] Starting to fetch items for paket ID: $paketId'); + + try { + // 1. First, get the basic package items (aset_id and kuantitas) + debugPrint('๐Ÿ” [2/3] Querying paket_item table for paket_id: $paketId'); + + final response = await client + .from('paket_item') + .select(''' + aset_id, + kuantitas + ''') + .eq('paket_id', paketId); + + debugPrint('๐Ÿ“Š Raw response from paket_item query:'); + debugPrint(response.toString()); + + if (response == null) { + debugPrint('โŒ [ERROR] Null response from paket_item query'); + return []; + } + + if (response.isEmpty) { + debugPrint('โ„น๏ธ [INFO] No items found in paket_item for paket ID: $paketId'); + return []; + } + + debugPrint('โœ… [SUCCESS] Found ${response.length} items in paket_item'); + + final List> enrichedItems = []; + + // Process each item to fetch additional details + debugPrint('๐Ÿ”„ [3/3] Processing ${response.length} items to fetch asset details'); + + for (var item in response) { + final String? asetId = item['aset_id']?.toString(); + final int kuantitas = item['kuantitas'] ?? 1; + + debugPrint('\n๐Ÿ” Processing item:'); + debugPrint(' - Raw item data: $item'); + debugPrint(' - aset_id: $asetId'); + debugPrint(' - kuantitas: $kuantitas'); + + if (asetId == null || asetId.isEmpty) { + debugPrint('โš ๏ธ [WARNING] Skipping item with null/empty aset_id'); + continue; + } + + try { + // 1. Get asset name from aset table + debugPrint(' - Querying aset table for id: $asetId'); + final asetResponse = await client + .from('aset') + .select('id, nama, deskripsi') + .eq('id', asetId) + .maybeSingle(); + + debugPrint(' - Aset response: ${asetResponse?.toString() ?? 'null'}'); + + if (asetResponse == null) { + debugPrint('โš ๏ธ [WARNING] No asset found with id: $asetId'); + enrichedItems.add({ + 'aset_id': asetId, + 'kuantitas': kuantitas, + 'nama_aset': 'Item tidak diketahui', + 'foto_aset': '', + 'semua_foto': [], + 'error': 'Asset not found' + }); + continue; + } + + // 2. Get only the first photo from foto_aset table + debugPrint(' - Querying first photo for id_aset: $asetId'); + final fotoResponse = await client + .from('foto_aset') + .select('foto_aset') + .eq('id_aset', asetId) + .order('created_at', ascending: true) + .limit(1); + + String? fotoUtama = ''; + List semuaFoto = []; + + if (fotoResponse.isNotEmpty) { + final firstFoto = fotoResponse.first['foto_aset']?.toString(); + if (firstFoto != null && firstFoto.isNotEmpty) { + fotoUtama = firstFoto; + semuaFoto = [firstFoto]; + debugPrint(' - Found photo: $firstFoto'); + } else { + debugPrint(' - No valid photo URL found'); + } + } else { + debugPrint(' - No photos found for asset $asetId'); + } + + // 4. Combine all data + final enrichedItem = { + 'aset_id': asetId, + 'kuantitas': kuantitas, + 'nama_aset': asetResponse['nama']?.toString() ?? 'Nama tidak tersedia', + 'foto_aset': fotoUtama, + 'semua_foto': semuaFoto, + 'debug': { + 'aset_query': asetResponse, + 'foto_count': semuaFoto.length + } + }; + + enrichedItems.add(enrichedItem); + + // Debug log + debugPrint('โœ… Successfully processed item:'); + debugPrint(' - Aset ID: $asetId'); + debugPrint(' - Nama: ${enrichedItem['nama_aset']}'); + debugPrint(' - Kuantitas: $kuantitas'); + debugPrint(' - Jumlah Foto: ${semuaFoto.length}'); + if (semuaFoto.isNotEmpty) { + debugPrint(' - Foto Utama: ${semuaFoto.first}'); + } + + } catch (e) { + debugPrint('โŒ Error processing asset $asetId: $e'); + // Still add the basic item even if we couldn't fetch additional details + enrichedItems.add({ + 'aset_id': asetId, + 'kuantitas': item['kuantitas'], + 'nama_aset': 'Nama Aset Tidak Ditemukan', + 'foto_aset': '', + 'semua_foto': [], + }); + } + } + + debugPrint('โœ… Successfully fetched ${enrichedItems.length} items with details'); + return enrichedItems; + + } catch (e, stackTrace) { + debugPrint('โŒ Error getting package items for paket $paketId: $e'); + debugPrint('Stack trace: $stackTrace'); + return []; + } + } + + // Get available bank accounts for payment + Future>> getBankAccounts() async { + try { + final response = await client + .from('bank_accounts') + .select('*') + .eq('is_active', true) + .order('bank_name'); + + if (response != null && response.isNotEmpty) { + return List>.from(response); + } + return []; + } catch (e) { + debugPrint('Error getting bank accounts: $e'); + return []; + } + } } diff --git a/lib/app/modules/warga/bindings/order_sewa_paket_binding.dart b/lib/app/modules/warga/bindings/order_sewa_paket_binding.dart index 23fad22..5001541 100644 --- a/lib/app/modules/warga/bindings/order_sewa_paket_binding.dart +++ b/lib/app/modules/warga/bindings/order_sewa_paket_binding.dart @@ -1,7 +1,6 @@ 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 @@ -11,10 +10,6 @@ class OrderSewaPaketBinding extends Bindings { Get.put(AsetProvider()); } - if (!Get.isRegistered()) { - Get.put(SewaProvider()); - } - Get.lazyPut( () => OrderSewaPaketController(), ); diff --git a/lib/app/modules/warga/controllers/order_sewa_paket_controller.dart b/lib/app/modules/warga/controllers/order_sewa_paket_controller.dart index 94efaa2..67bfb63 100644 --- a/lib/app/modules/warga/controllers/order_sewa_paket_controller.dart +++ b/lib/app/modules/warga/controllers/order_sewa_paket_controller.dart @@ -3,23 +3,27 @@ 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 'package:flutter/material.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'; +import '../../../theme/app_colors.dart'; +import '../widgets/custom_date_range_picker.dart'; class OrderSewaPaketController extends GetxController { // Dependencies final AsetProvider asetProvider = Get.find(); - final SewaProvider sewaProvider = Get.find(); - final NavigationService navigationService = ServiceManager().navigationService; + final NavigationService navigationService = ServiceManager.navigationService; // State variables final paket = Rx(null); final paketImages = RxList([]); + final currentPhotoIndex = 0.obs; final isLoading = RxBool(true); final isPhotosLoading = RxBool(true); + final paketItems = RxList>([]); + final isPaketItemsLoading = RxBool(true); final selectedSatuanWaktu = Rx?>(null); final selectedDate = RxString(''); final selectedStartDate = Rx(null); @@ -28,10 +32,21 @@ class OrderSewaPaketController extends GetxController { final selectedEndTime = RxInt(-1); final formattedDateRange = RxString(''); final formattedTimeRange = RxString(''); + final duration = RxInt(0); final totalPrice = RxDouble(0.0); final kuantitas = RxInt(1); final isSubmitting = RxBool(false); + // Hourly inventory tracking + final Map> hourlyAvailability = {}; + final isLoadingHourlyInventory = RxBool(false); + final unavailableDatesForHourly = [].obs; + final bookedHours = RxList>([]); + final selectedHours = RxList([]); + + // Available hours for hourly rental (6 AM to 9 PM) + final List availableHours = List.generate(16, (index) => index + 6); + // Format currency final currencyFormat = NumberFormat.currency( locale: 'id', @@ -42,14 +57,16 @@ class OrderSewaPaketController extends GetxController { @override void onInit() { super.onInit(); - FlutterLogs.logInfo("OrderSewaPaketController", "onInit", "Initializing OrderSewaPaketController"); - + // FlutterLogs.logInfo("OrderSewaPaketController", "onInit", "Initializing OrderSewaPaketController"); + // Get the paket ID from arguments final Map args = Get.arguments ?? {}; - final String? paketId = args['id']; - + final String? paketId = + args['paketId'] ?? args['id']; // Check for both paketId and id keys + final dynamic paketData = args['paketData']; + if (paketId != null) { - loadPaketData(paketId); + loadPaketData(paketId, paketData); } else { debugPrint('โŒ No paket ID provided in arguments'); isLoading.value = false; @@ -61,7 +78,7 @@ class OrderSewaPaketController extends GetxController { if (paket.value == null) { final Map args = Get.arguments ?? {}; final String? paketId = args['id']; - + if (paketId != null) { // Try to get from cache first final cachedPaket = GetStorage().read('cached_paket_$paketId'); @@ -77,123 +94,166 @@ class OrderSewaPaketController extends GetxController { } } - // Load paket data from API - Future loadPaketData(String id) async { + // Load paket data from API or direct argument + Future loadPaketData(String id, [dynamic directPaketData]) async { try { isLoading.value = true; debugPrint('๐Ÿ” Loading paket data for ID: $id'); - - // First check if we have it in cache + + // If directPaketData is provided, use it directly + if (directPaketData != null) { + debugPrint('โœ… Using directly provided paket data'); + processPaketData(id, directPaketData); + + // Load package items even when using direct data + await loadPaketItems(id); + return; + } + + // Otherwise 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); + await loadPaketItems(id); // Load package items + await loadHourlyInventory(id); initializePriceOptions(); } else { - // Get all pakets and filter for the one we need - final List 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'); + // Fetch from API + debugPrint('๐Ÿ” Fetching paket data from API for ID: $id'); + final allPakets = await asetProvider.getPakets(); + Map? paketData; + try { + paketData = allPakets.firstWhere((paket) => paket['id'] == id); + } catch (e) { + debugPrint( + 'โŒ Paket with ID $id not found in list of ${allPakets.length} pakets', + ); + paketData = null; + } + if (paketData != null) { + processPaketData(id, paketData); + await loadPaketItems(id); // Load package items + await loadHourlyInventory(id); + } else { + debugPrint('โŒ Failed to load paket data for ID: $id'); + Get.snackbar( + 'Error', + 'Gagal memuat data paket', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); } - } - - // 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'); + Get.snackbar( + 'Error', + 'Terjadi kesalahan saat memuat data paket', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); } finally { isLoading.value = false; } } + // Process and set the paket data + Future processPaketData(String id, dynamic rawPaket) async { + try { + debugPrint('๐Ÿ”„ Processing paket data for ID: $id'); + debugPrint('๐Ÿ“ฆ Raw paket data type: ${rawPaket.runtimeType}'); + + // Initialize loadedPaket with a default value + late final PaketModel loadedPaket; + + 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: const [], // Initialize with empty list, will be populated later + satuanWaktuSewa: getPaketSatuanWaktuSewa(rawPaket), + foto_paket: getPaketMainPhoto(rawPaket), + images: const [], // Initialize with empty list, will be populated later + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + debugPrint('โœ… Created PaketModel using helper methods'); + } + + // Update the state with the loaded paket + 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'); + } + } + } catch (e) { + debugPrint('โŒ Error processing paket data: $e'); + isLoading.value = false; + } + } + // Helper methods to safely access paket properties - String? getPaketId(dynamic paket) { - if (paket == null) return null; + String getPaketId(dynamic paket) { + if (paket == null) return ''; try { - return paket.id ?? paket['id']; + return (paket.id ?? paket['id'] ?? '').toString(); } catch (_) { - return null; + return ''; } } - String? getPaketNama(dynamic paket) { - if (paket == null) return null; + String getPaketNama(dynamic paket) { + if (paket == null) return 'Paket Tanpa Nama'; try { - return paket.nama ?? paket['nama']; + return (paket.nama ?? paket['nama'] ?? 'Paket Tanpa Nama').toString(); } catch (_) { - return null; + return 'Paket Tanpa Nama'; } } - String? getPaketDeskripsi(dynamic paket) { - if (paket == null) return null; + String getPaketDeskripsi(dynamic paket) { + if (paket == null) return ''; try { - return paket.deskripsi ?? paket['deskripsi']; + return (paket.deskripsi ?? paket['deskripsi'] ?? '').toString(); } catch (_) { - return null; + return ''; } } double getPaketHarga(dynamic paket) { if (paket == null) return 0.0; try { - var harga = paket.harga ?? paket['harga'] ?? 0; + var harga = paket.harga ?? paket['harga'] ?? 0.0; + if (harga is int) return harga.toDouble(); + if (harga is double) return harga; return double.tryParse(harga.toString()) ?? 0.0; } catch (_) { return 0.0; @@ -210,21 +270,25 @@ class OrderSewaPaketController extends GetxController { } } - String? getPaketMainPhoto(dynamic paket) { - if (paket == null) return null; + String getPaketMainPhoto(dynamic paket) { + if (paket == null) return ''; try { - return paket.foto_paket ?? paket['foto_paket']; + return (paket.foto_paket ?? paket['foto_paket'] ?? '').toString(); } catch (_) { - return null; + return ''; } } - List getPaketSatuanWaktuSewa(dynamic paket) { - if (paket == null) return []; + List> getPaketSatuanWaktuSewa(dynamic paket) { + if (paket == null) return const []; try { - return paket.satuanWaktuSewa ?? paket['satuanWaktuSewa'] ?? []; + final dynamic rawList = paket.satuanWaktuSewa ?? paket['satuanWaktuSewa']; + if (rawList is List) { + return rawList.whereType>().toList(); + } + return const []; } catch (_) { - return []; + return const []; } } @@ -232,33 +296,246 @@ class OrderSewaPaketController extends GetxController { Future loadPaketPhotos(String paketId) async { try { isPhotosLoading.value = true; + debugPrint('๐Ÿ“ท Loading photos for paket: $paketId'); + 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); - } + paketImages.clear(); + + if (photos.isNotEmpty) { + // Process each photo URL + for (var photoUrl in photos) { + if (photoUrl != null && photoUrl.isNotEmpty) { + paketImages.add(photoUrl); } } + + debugPrint('โœ… Found ${paketImages.length} photos for paket $paketId'); } + + // If we got no photos but have a model with a main photo, use that + if (paketImages.isEmpty && paket.value != null) { + String? mainPhoto = getPaketMainPhoto(paket.value); + if (mainPhoto != null && mainPhoto.isNotEmpty) { + paketImages.add(mainPhoto); + debugPrint( + 'โœ… No photos from API, using main photo as fallback: $mainPhoto', + ); + } + } + + // If we still have no photos but have package data with gambar_url, use that + if (paketImages.isEmpty && paket.value is Map) { + final paketMap = paket.value as Map; + if (paketMap['gambar_url'] != null && + paketMap['gambar_url'].toString().isNotEmpty) { + paketImages.add(paketMap['gambar_url'].toString()); + debugPrint( + 'โœ… Using gambar_url as final fallback: ${paketMap['gambar_url']}', + ); + } + } + } catch (e) { + debugPrint('โŒ Error loading paket photos: $e'); + paketImages.value = []; } finally { isPhotosLoading.value = false; } } + // Load package items with asset details + Future loadPaketItems(String paketId) async { + try { + isPaketItemsLoading.value = true; + debugPrint('๐Ÿงฉ Loading items for paket: $paketId'); + + final items = await asetProvider.getPaketItems(paketId); + if (items.isNotEmpty) { + paketItems.value = items; + debugPrint('โœ… Loaded ${paketItems.length} package items'); + } + } catch (e) { + debugPrint('โŒ Error loading paket items: $e'); + paketItems.value = []; + } finally { + isPaketItemsLoading.value = false; + } + } + + // Load hourly inventory for all assets in the package + Future loadHourlyInventory(String paketId) async { + if (paketItems.isEmpty) { + // debugPrint('โš ๏ธ Cannot load hourly inventory: No package items available'); + return; + } + + try { + isLoadingHourlyInventory.value = true; + + // Print a clear section header + // debugPrint('โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”'); + // debugPrint('๐Ÿ“Š LOADING HOURLY INVENTORY'); + // debugPrint('๐Ÿ“ฆ Package: $paketId | Items: ${paketItems.length}'); + // debugPrint('โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”'); + + // Reset availability data + hourlyAvailability.clear(); + unavailableDatesForHourly.clear(); + + // Get the current date for checking availability + final DateTime today = DateTime.now(); + final String formattedToday = DateFormat('yyyy-MM-dd').format(today); + // debugPrint('๐Ÿ“† Starting from date: $formattedToday'); + + // Initialize availability map for the next 30 days (all hours available by default) + for (int day = 0; day < 30; day++) { + final DateTime currentDate = today.add(Duration(days: day)); + final String dateStr = DateFormat('yyyy-MM-dd').format(currentDate); + + hourlyAvailability[dateStr] = {}; + for (int hour = 0; hour < 24; hour++) { + hourlyAvailability[dateStr]![hour] = + true; // Initially all hours are available + } + } + // debugPrint('โœ… Initialized availability data for 30 days'); + + // Process each asset in the package + int processedAssets = 0; + int unavailableHoursCount = 0; + + // debugPrint('โ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆ'); + // debugPrint('๐Ÿ” CHECKING ASSET AVAILABILITY'); + + // Check each asset in the package for availability + for (var item in paketItems) { + final asetId = item['aset_id']; + final int requiredQuantity = item['kuantitas'] ?? 1; + processedAssets++; + + if (asetId == null || asetId.isEmpty) { + // debugPrint('โš ๏ธ Skipping item with missing aset_id'); + continue; + } + + // debugPrint('๐Ÿ”น Asset ${processedAssets}/${paketItems.length}: $asetId (needed qty: $requiredQuantity)'); + + // Get all bookings for this asset + try { + final bookings = await asetProvider.getAsetBookings( + asetId, + formattedToday, + ); + // debugPrint(' ๐Ÿ“‹ Found ${bookings.length} bookings'); + + // Process each booking to update availability + for (var booking in bookings) { + final String bookingId = booking['id'] ?? ''; + final String status = booking['status'] ?? ''; + final int bookingQuantity = booking['kuantitas'] ?? 1; + + // Skip canceled bookings + if (status.toLowerCase() == 'dibatalkan') { + continue; + } + + // Get start and end date-times + final String waktuMulaiStr = booking['waktu_mulai'] ?? ''; + final String waktuSelesaiStr = booking['waktu_selesai'] ?? ''; + + if (waktuMulaiStr.isEmpty || waktuSelesaiStr.isEmpty) { + continue; + } + + try { + final DateTime waktuMulai = DateTime.parse(waktuMulaiStr); + final DateTime waktuSelesai = DateTime.parse(waktuSelesaiStr); + + // Get all hours between start and end + DateTime currentHour = DateTime( + waktuMulai.year, + waktuMulai.month, + waktuMulai.day, + waktuMulai.hour, + ); + + while (currentHour.isBefore(waktuSelesai) || + currentHour.isAtSameMomentAs(waktuSelesai)) { + final String dateStr = DateFormat( + 'yyyy-MM-dd', + ).format(currentHour); + final int hour = currentHour.hour; + + // If this date is in our availability map + if (hourlyAvailability.containsKey(dateStr)) { + // Check if the asset quantity would be insufficient + final int assetTotalQuantity = await getAssetTotalQuantity( + asetId, + ); + final int bookedQuantity = bookingQuantity; + + // If the booked quantity plus what we need exceeds total, mark as unavailable + if (bookedQuantity + requiredQuantity > assetTotalQuantity) { + hourlyAvailability[dateStr]![hour] = false; + unavailableHoursCount++; + + // If this date isn't already in our unavailable dates list, add it + final DateTime unavailableDate = DateTime( + currentHour.year, + currentHour.month, + currentHour.day, + ); + if (!unavailableDatesForHourly.contains(unavailableDate)) { + unavailableDatesForHourly.add(unavailableDate); + } + } + } + + currentHour = currentHour.add(const Duration(hours: 1)); + } + } catch (e) { + // Just silently skip problematic bookings + continue; + } + } + } catch (e) { + // debugPrint(' โŒ Error loading bookings: ${e.toString().split('\n').first}'); + continue; + } + } + + // Final summary section + // debugPrint('โ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆโ”ˆ'); + // debugPrint('๐Ÿ“Š HOURLY INVENTORY SUMMARY'); + // debugPrint('โœ… Processed ${paketItems.length} assets'); + // debugPrint('๐Ÿšซ Found $unavailableHoursCount unavailable hour slots'); + // debugPrint('๐Ÿ“… Found ${unavailableDatesForHourly.length} dates with unavailable hours'); + // debugPrint('โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”'); + } catch (e) { + // debugPrint('โŒ ERROR: ${e.toString().split('\n').first}'); + } finally { + isLoadingHourlyInventory.value = false; + } + } + + // Get the total quantity of an asset + Future getAssetTotalQuantity(String asetId) async { + try { + final assetData = await asetProvider.getAsetById(asetId); + if (assetData != null) { + final int quantity = assetData.kuantitas ?? 0; + // debugPrint('๐Ÿ“Š Asset $asetId total quantity: $quantity'); + return quantity; + } + } catch (e) { + // debugPrint('โŒ Error getting asset quantity: $e'); + } + return 0; + } + // Initialize price options void initializePriceOptions() { if (paket.value == null) return; - + final satuanWaktuSewa = getPaketSatuanWaktuSewa(paket.value); if (satuanWaktuSewa.isNotEmpty) { // Default to the first option @@ -267,9 +544,9 @@ class OrderSewaPaketController extends GetxController { } // Select satuan waktu - void selectSatuanWaktu(Map satuanWaktu) { + void selectSatuanWaktu(Map satuanWaktu) async { selectedSatuanWaktu.value = satuanWaktu; - + // Reset date and time selections selectedStartDate.value = null; selectedEndDate.value = null; @@ -278,7 +555,30 @@ class OrderSewaPaketController extends GetxController { selectedDate.value = ''; formattedDateRange.value = ''; formattedTimeRange.value = ''; - + + // If hourly rental is selected, automatically set today's date + final namaSatuan = satuanWaktu['nama_satuan_waktu'] ?? ''; + final bool isHourly = !namaSatuan.toString().toLowerCase().contains('hari'); + + if (isHourly && paket.value != null) { + debugPrint('โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”'); + debugPrint('๐Ÿ•’ HOURLY RENTAL SELECTED'); + debugPrint('โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”'); + + // Set today as the default date + final now = DateTime.now(); + selectedStartDate.value = now; + selectedDate.value = DateFormat('yyyy-MM-dd').format(now); + formattedDateRange.value = DateFormat('d MMMM yyyy').format(now); + + // Immediately load hourly inventory + if (paket.value != null && paket.value!.id != null) { + await loadHourlyInventory(paket.value!.id!); + } else { + // debugPrint('โš ๏ธ Cannot load hourly inventory: Package ID is null or empty'); + } + } + calculateTotalPrice(); } @@ -288,23 +588,6 @@ class OrderSewaPaketController extends GetxController { 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; @@ -312,31 +595,496 @@ class OrderSewaPaketController extends GetxController { calculateTotalPrice(); } + // 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); + duration.value = 1; // Same day rental is considered as 1 day + } else { + formattedDateRange.value = + '${formatter.format(start)} - ${formatter.format(end)}'; + // Calculate the difference in days + duration.value = + end.difference(start).inDays + + 1; // +1 because both start and end days are inclusive + } + + selectedDate.value = formatter.format(start); + calculateTotalPrice(); + } + + // Pick date range using custom date range picker for daily rental + Future pickDateRange(BuildContext context) async { + try { + // Get current date + final now = DateTime.now(); + final todayDate = DateTime(now.year, now.month, now.day); + + // Create unavailable dates list + final unavailableDates = []; + + // Add past dates as unavailable + for ( + DateTime date = DateTime(now.year, now.month, 1); + !date.isAfter(todayDate); + date = date.add(const Duration(days: 1)) + ) { + unavailableDates.add(date); + } + + // Get initial dates + DateTime initialStartDate; + DateTime initialEndDate; + + try { + initialStartDate = + selectedStartDate.value ?? todayDate.add(const Duration(days: 1)); + initialEndDate = + selectedEndDate.value ?? + initialStartDate.add(const Duration(days: 1)); + } catch (e) { + initialStartDate = todayDate.add(const Duration(days: 1)); + initialEndDate = todayDate.add(const Duration(days: 2)); + } + + // Store error message if needed + final errorMessageText = RxString(''); + + // Show our custom date range picker in a dialog + final result = await showDialog( + context: context, + builder: (BuildContext context) { + return Dialog( + insetPadding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 24.0, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Container( + width: double.maxFinite, + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.8, + ), + padding: const EdgeInsets.all(16.0), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Pilih Tanggal', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + // Maximum rental days info + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + 'Maksimal waktu sewa: 30 hari', + style: TextStyle( + fontSize: 12, + color: AppColors.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + Obx( + () => + errorMessageText.value.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(top: 8), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.errorLight, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.error.withOpacity(0.3), + ), + ), + child: Text( + errorMessageText.value, + style: TextStyle( + fontSize: 12, + color: AppColors.error, + ), + ), + ), + ) + : const SizedBox.shrink(), + ), + const SizedBox(height: 16), + // Use the custom calendar widget + CustomDateRangePicker( + disabledDates: unavailableDates, + initialStartDate: initialStartDate, + initialEndDate: initialEndDate, + maxDays: 30, // Default max days + onClearSelection: () { + // Handle selection clearing + debugPrint('๐Ÿงน Date range selection cleared by user'); + }, + onSelectRange: (start, end) { + try { + // Clear error message + errorMessageText.value = ''; + + final days = end.difference(start).inDays + 1; + + // Validate maximum days + if (days > 30) { + errorMessageText.value = 'Maksimal 30 hari sewa'; + debugPrint('โš ๏ธ Max days exceeded: $days'); + return; + } + + debugPrint( + '๐Ÿ“… Selected date range: ${DateFormat('yyyy-MM-dd').format(start)} to ${DateFormat('yyyy-MM-dd').format(end)} ($days days)', + ); + + // Update the selected date range + selectDateRange(start, end); + + Navigator.of( + context, + ).pop(true); // Close dialog with success + } catch (e) { + debugPrint('โŒ Error in date range selection: $e'); + } + }, + ), + ], + ), + ), + ), + ); + }, + ); + + // Handle result if needed + if (result == true) { + debugPrint( + '๐Ÿ“… Date range selected successfully: ${formattedDateRange.value}', + ); + } + } catch (e) { + debugPrint('โŒ Error in date range picker: $e'); + } + } + + // Pick date using custom date picker dialog + Future pickDate(BuildContext context) async { + try { + final now = DateTime.now(); + final todayDate = DateTime(now.year, now.month, now.day); + final unavailableDates = []; + + // Add past dates as unavailable + for ( + DateTime date = DateTime(now.year, now.month, 1); + date.isBefore(todayDate); + date = date.add(const Duration(days: 1)) + ) { + unavailableDates.add(date); + } + + // Get initial date + DateTime initialDate; + try { + initialDate = selectedStartDate.value ?? todayDate; + } catch (e) { + initialDate = todayDate; + } + + // Show our custom date picker in a dialog + final result = await showDialog( + context: context, + builder: (BuildContext context) { + return Dialog( + insetPadding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 24.0, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Container( + width: double.maxFinite, + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.8, + ), + padding: const EdgeInsets.all(16.0), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Pilih Tanggal', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 16), + // Use the custom calendar widget for single date selection + CustomDateRangePicker( + disabledDates: unavailableDates, + initialStartDate: initialDate, + initialEndDate: + initialDate, // For hourly rental, set end date to same as start date + singleDateMode: + true, // Force single date selection for hourly rentals + maxDays: 1, // Limit to 1 day + onClearSelection: () { + // Handle selection clearing + debugPrint('๐Ÿงน Date selection cleared by user'); + }, + onSelectRange: (start, end) { + try { + // For hourly rental, we only need the start date + debugPrint( + '๐Ÿ“… Selected date for hourly rental: ${DateFormat('yyyy-MM-dd').format(start)}', + ); + + // Update the selected date + updateSelectedDate(start); + + // Reset time selections + selectedStartTime.value = -1; + selectedEndTime.value = -1; + formattedTimeRange.value = ''; + duration.value = 0; + + Navigator.of( + context, + ).pop(true); // Close dialog with success result + } catch (e) { + debugPrint('โŒ Error in date selection: $e'); + } + }, + ), + ], + ), + ), + ), + ); + }, + ); + + // Handle result if needed + if (result == true) { + debugPrint('๐Ÿ“… Date selected successfully: ${selectedDate.value}'); + } + } catch (e) { + debugPrint('โŒ Error in date picker: $e'); + } + } + // 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'; - + + // Calculate duration in hours + duration.value = end - start; + calculateTotalPrice(); } + // Check if an hour should be disabled (unavailable) + bool isHourDisabled(int hour) { + // Always check if the hour is in the past + if (isHourInPast(hour)) { + return true; + } + + // If no date selected, disable all hours + if (selectedStartDate.value == null) { + return true; + } + + // Get the current date in yyyy-MM-dd format + final String formattedDate = DateFormat( + 'yyyy-MM-dd', + ).format(selectedStartDate.value!); + + // Check if we have availability data for this date + if (!hourlyAvailability.containsKey(formattedDate)) { + // For future dates with no data, assume all hours are available + final now = DateTime.now(); + final selectedDate = selectedStartDate.value!; + + // If the selected date is today or in the future + if (selectedDate.isAfter(now) || + (selectedDate.year == now.year && + selectedDate.month == now.month && + selectedDate.day == now.day)) { + debugPrint( + 'โœ… No availability data for future date $formattedDate - defaulting to available', + ); + return false; // Not disabled + } else { + debugPrint( + 'โš ๏ธ No availability data for past date $formattedDate - defaulting to disabled', + ); + return true; + } + } + + // Get the availability for this hour + if (!hourlyAvailability[formattedDate]!.containsKey(hour)) { + // Default to available if we don't have specific data for this hour + debugPrint( + 'โœ… No availability data for hour $hour - defaulting to available', + ); + return false; // Not disabled + } + + // Return the stored availability (false = disabled/unavailable) + final bool isAvailable = hourlyAvailability[formattedDate]![hour]!; + + debugPrint('๐Ÿ•’ Hour $hour availability: $isAvailable on $formattedDate'); + return !isAvailable; + } + + // Check if an hour is in the past + bool isHourInPast(int hour) { + // If no date selected, we can't determine if it's in the past + if (selectedStartDate.value == null) return false; + + final DateTime now = DateTime.now(); + final DateTime selectedDateTime = DateTime( + selectedStartDate.value!.year, + selectedStartDate.value!.month, + selectedStartDate.value!.day, + hour, + ); + + return selectedDateTime.isBefore(now); + } + + // Select a date and update availability for that date + Future updateSelectedDate(DateTime date) async { + debugPrint('๐Ÿ“… Selecting date: ${DateFormat('yyyy-MM-dd').format(date)}'); + + // Update the selected date + selectedStartDate.value = date; + selectedDate.value = DateFormat('yyyy-MM-dd').format(date); + + // For hourly rentals, set the end date to the same day + if (!isDailyRental()) { + selectedEndDate.value = date; + formattedDateRange.value = DateFormat('d MMMM yyyy').format(date); + } + + // Reset time selections + selectedStartTime.value = -1; + selectedEndTime.value = -1; + formattedTimeRange.value = ''; + duration.value = 0; + + // Refresh hourly inventory data if we're in hourly rental mode + if (!isDailyRental() && paket.value != null) { + debugPrint('๐Ÿ• Refreshing hourly inventory for selected date'); + await loadHourlyInventory(paket.value?.id ?? ''); + } + + // Recalculate price + calculateTotalPrice(); + } + + // Select a specific time from the grid + void selectTime(int hour) { + if (hour < 0 || hour > 23) { + debugPrint('โŒ Invalid hour: $hour'); + return; + } + + // Only continue if we have a date selected + if (selectedStartDate.value == null) { + debugPrint('โš ๏ธ Cannot select time: No date selected'); + return; + } + + // Check if this hour is available + if (isHourDisabled(hour)) { + debugPrint('โŒ Cannot select hour $hour: It is disabled/unavailable'); + Get.snackbar( + 'Waktu Tidak Tersedia', + 'Jam ini tidak tersedia karena ada aset dalam paket yang sudah dipesan', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.orange, + colorText: Colors.white, + ); + return; + } + + // If this hour is already selected, deselect it + if (selectedStartTime.value == hour) { + selectedStartTime.value = -1; + selectedEndTime.value = -1; + formattedTimeRange.value = ''; + calculateTotalPrice(); + return; + } + + // Otherwise select it + selectedStartTime.value = hour; + selectedEndTime.value = hour + 1; // Default to 1 hour + + // Update formatted time range + formattedTimeRange.value = '$hour:00 - ${hour + 1}:00'; + + // Recalculate price + calculateTotalPrice(); + } + + // Check if a time is selected (either as start or end time) + bool isTimeSelected(int hour) { + return selectedStartTime.value == hour || selectedEndTime.value == hour; + } + + // Check if a time is in the selected range (between start and end, not inclusive) + bool isTimeInRange(int hour) { + return selectedStartTime.value >= 0 && + selectedEndTime.value >= 0 && + hour > selectedStartTime.value && + hour < selectedEndTime.value; + } + // Calculate total price void calculateTotalPrice() { if (selectedSatuanWaktu.value == null) { totalPrice.value = 0.0; return; } - - final basePrice = double.tryParse(selectedSatuanWaktu.value!['harga'].toString()) ?? 0.0; - + + 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; + final days = + selectedEndDate.value!.difference(selectedStartDate.value!).inDays + + 1; totalPrice.value = basePrice * days; } else { totalPrice.value = basePrice; @@ -349,7 +1097,7 @@ class OrderSewaPaketController extends GetxController { totalPrice.value = basePrice; } } - + // Multiply by quantity totalPrice.value *= kuantitas.value; } @@ -372,9 +1120,14 @@ class OrderSewaPaketController extends GetxController { ); return; } - - if ((isDailyRental() && (selectedStartDate.value == null || selectedEndDate.value == null)) || - (!isDailyRental() && (selectedStartDate.value == null || selectedStartTime.value < 0 || selectedEndTime.value < 0))) { + + 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', @@ -384,24 +1137,39 @@ class OrderSewaPaketController extends GetxController { ); return; } - + isSubmitting.value = true; - + // Prepare order data final Map 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(), + '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 using AsetProvider's orderPaket method + final userId = GetStorage().read('user_id') ?? ''; + final result = await asetProvider.orderPaket( + userId: userId, + paketId: orderData['id_paket'], + satuanWaktuSewaId: orderData['id_satuan_waktu'], + durasi: orderData['durasi'] ?? 1, // Default to 1 if not provided + totalHarga: orderData['total_harga'].toInt(), + ); - // Submit the order - final result = await sewaProvider.createPaketOrder(orderData); - + // Create a mock result for navigation + final resultData = { + 'id': 'order_${DateTime.now().millisecondsSinceEpoch}', + 'status': 'PENDING', + }; + if (result != null) { Get.snackbar( 'Sukses', @@ -410,9 +1178,29 @@ class OrderSewaPaketController extends GetxController { backgroundColor: Colors.green, colorText: Colors.white, ); - + // Navigate to payment page - navigationService.navigateToPembayaranSewa(result['id']); + if (result && resultData != null && resultData['id'] != null) { + navigationService.navigateToPembayaranSewa(resultData['id'].toString()); + } else if (result) { + // If result is true but we don't have an ID, navigate back to sewa aset + navigationService.navigateToSewaAset(); + Get.snackbar( + 'Error', + 'Gagal mendapatkan ID pesanan', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } else { + Get.snackbar( + 'Error', + 'Gagal membuat pesanan', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } } else { Get.snackbar( 'Error', @@ -440,4 +1228,266 @@ class OrderSewaPaketController extends GetxController { void onBackPressed() { navigationService.navigateToSewaAset(); } + + // Get paket images from the paket model or fallback to a default list + List getPaketImages(dynamic paket) { + if (paket == null) return []; + + debugPrint('๐Ÿ” Getting images for paket: ${paket.runtimeType}'); + + // Check if we already have loaded images in the RxList + if (paketImages.isNotEmpty) { + debugPrint('โœ… Using ${paketImages.length} already loaded images'); + return paketImages.toList(); + } + + // If it's already a PaketModel with images + if (paket is PaketModel) { + if (paket.images != null && paket.images!.isNotEmpty) { + debugPrint( + 'โœ… Using ${paket.images!.length} images from PaketModel.images', + ); + return paket.images!; + } + // Check for foto_paket (main photo) + if (paket.foto_paket != null && paket.foto_paket!.isNotEmpty) { + debugPrint( + 'โœ… Using main photo from PaketModel.foto_paket: ${paket.foto_paket}', + ); + return [paket.foto_paket!]; + } + } + + // If it's a Map, try to extract images + if (paket is Map) { + debugPrint( + '๐Ÿ“ Extracting images from map with keys: ${paket.keys.join(', ')}', + ); + + // First try the images array + if (paket['images'] != null && paket['images'] is List) { + final imageList = List.from(paket['images']); + debugPrint('โœ… Found ${imageList.length} images in paket["images"]'); + return imageList; + } + + // Try gambar_url (commonly used in the app) + if (paket['gambar_url'] != null && + paket['gambar_url'].toString().isNotEmpty) { + debugPrint('โœ… Using gambar_url: ${paket['gambar_url']}'); + return [paket['gambar_url'].toString()]; + } + + // Try foto_paket + if (paket['foto_paket'] != null && + paket['foto_paket'].toString().isNotEmpty) { + debugPrint('โœ… Using foto_paket: ${paket['foto_paket']}'); + return [paket['foto_paket'].toString()]; + } + } + + debugPrint('โŒ No images found for paket'); + return []; + } + + // Open image gallery to view full-size images + void openImageGallery( + BuildContext context, + List images, + int initialIndex, + ) { + if (images.isEmpty) return; + + // Convert all images to strings + final List imageUrls = images.map((img) => img.toString()).toList(); + + // Show dialog with gallery + showDialog( + context: context, + builder: + (context) => Dialog( + insetPadding: EdgeInsets.zero, + backgroundColor: Colors.black.withOpacity(0.9), + child: Stack( + children: [ + // Image display + PageView.builder( + controller: PageController(initialPage: initialIndex), + itemCount: imageUrls.length, + itemBuilder: (context, index) { + return Center( + child: Image.network( + imageUrls[index], + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return Center( + child: Icon( + Icons.broken_image, + size: 64, + color: Colors.white, + ), + ); + }, + ), + ); + }, + ), + // Close button + Positioned( + top: 16, + right: 16, + child: IconButton( + icon: Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.of(context).pop(), + ), + ), + ], + ), + ), + ); + } + + // Get formatted duration string based on selected dates/times + String getFormattedDuration() { + if (selectedSatuanWaktu.value == null) { + return '-'; + } + + if (isDailyRental()) { + if (selectedStartDate.value != null && selectedEndDate.value != null) { + final days = + selectedEndDate.value!.difference(selectedStartDate.value!).inDays + + 1; + return '$days hari'; + } + return '-'; + } else { + if (selectedStartTime.value >= 0 && selectedEndTime.value >= 0) { + final hours = selectedEndTime.value - selectedStartTime.value; + return '$hours jam'; + } + return '-'; + } + } + + // Get formatted selected date for display + String get formattedSelectedDate { + if (selectedStartDate.value == null) { + return 'Pilih tanggal'; + } + + final formatter = DateFormat('EEEE, d MMMM yyyy', 'id'); + if (isDailyRental() && selectedEndDate.value != null) { + final startStr = formatter.format(selectedStartDate.value!); + final endStr = formatter.format(selectedEndDate.value!); + return '$startStr - $endStr'; + } else { + return formatter.format(selectedStartDate.value!); + } + } + + // Show time selection dialog + Future showTimeSelectionDialog(BuildContext context) async { + if (selectedStartDate.value == null) { + Get.snackbar( + 'Peringatan', + 'Pilih tanggal terlebih dahulu', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.orange, + colorText: Colors.white, + ); + return; + } + + // Predefined time slots from 7 AM to 9 PM + final List availableStartTimes = List.generate( + 15, + (index) => index + 7, + ); // 7 AM to 9 PM + + int? selectedStart; + int? selectedEnd; + + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: Text('Pilih Waktu Sewa'), + content: SizedBox( + width: double.maxFinite, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Jam Mulai'), + SizedBox( + height: 200, + child: ListView.builder( + shrinkWrap: true, + itemCount: + availableStartTimes.length - + 1, // Exclude last time as start time + itemBuilder: (context, index) { + final time = availableStartTimes[index]; + return ListTile( + title: Text('$time:00'), + onTap: () { + selectedStart = time; + // Auto-select end time as start + 1 hour initially + selectedEnd = time + 1; + Navigator.pop(context); + }, + ); + }, + ), + ), + ], + ), + ), + ), + ); + + if (selectedStart != null && selectedEnd != null) { + // Show end time selection + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: Text('Pilih Jam Selesai'), + content: SizedBox( + width: double.maxFinite, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Jam Selesai (setelah ${selectedStart!}:00)'), + SizedBox( + height: 200, + child: ListView.builder( + shrinkWrap: true, + itemCount: availableStartTimes.length - selectedStart!, + itemBuilder: (context, index) { + final time = + selectedStart! + + index + + 1; // Start from selected + 1 + return ListTile( + title: Text('$time:00'), + onTap: () { + selectedEnd = time; + Navigator.pop(context); + }, + ); + }, + ), + ), + ], + ), + ), + ), + ); + + if (selectedEnd != null && selectedStart != null) { + selectTimeRange(selectedStart!, selectedEnd!); + } + } + } } diff --git a/lib/app/modules/warga/views/order_sewa_paket_view.dart b/lib/app/modules/warga/views/order_sewa_paket_view.dart index 03872d0..ffc3658 100644 --- a/lib/app/modules/warga/views/order_sewa_paket_view.dart +++ b/lib/app/modules/warga/views/order_sewa_paket_view.dart @@ -9,12 +9,617 @@ 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 '../../../../utils/constants/app_colors.dart'; import 'package:intl/intl.dart'; class OrderSewaPaketView extends GetView { const OrderSewaPaketView({super.key}); + // Build package items details section + Widget _buildPackageItemsDetails() { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 24.0, vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header with icon and title + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon( + Icons.inventory_2_rounded, + size: 20, + color: AppColors.primary, + ), + SizedBox(width: 8), + Text( + 'Detail Paket', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ], + ), + + // Show badge with number of items + Obx( + () => + controller.paketItems.isNotEmpty + ? Container( + padding: EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: AppColors.primarySoft, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${controller.paketItems.length} item', + style: TextStyle( + fontSize: 12, + color: AppColors.primary, + fontWeight: FontWeight.w500, + ), + ), + ) + : SizedBox.shrink(), + ), + ], + ), + ), + + // Container with items list + Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: Offset(0, 2), + ), + ], + ), + child: Obx(() { + // Show loading indicator + if (controller.isPaketItemsLoading.value) { + return SizedBox( + height: 100, + child: Center( + child: CircularProgressIndicator(color: AppColors.primary), + ), + ); + } + + // Show empty state + if (controller.paketItems.isEmpty) { + return Container( + height: 100, + padding: EdgeInsets.symmetric(vertical: 16), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.inventory_2_outlined, + size: 32, + color: Colors.grey[400], + ), + SizedBox(height: 8), + Text( + 'Tidak ada item dalam paket', + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + ), + ], + ), + ), + ); + } + + // Show items list + return ListView.separated( + physics: NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: controller.paketItems.length, + separatorBuilder: + (context, index) => Divider( + height: 1, + thickness: 1, + color: Colors.grey[200], + ), + itemBuilder: (context, index) { + final item = controller.paketItems[index]; + final aset = item['aset']; + final kuantitas = item['kuantitas'] ?? 1; + final asetNama = + aset != null + ? aset['nama'] ?? 'Item tidak diketahui' + : 'Item tidak diketahui'; + + // Get asset image URL - use the first photo from semua_foto if available + final String? imageUrl = item['foto_aset']?.toString().isNotEmpty == true + ? item['foto_aset'].toString() + : aset != null ? aset['foto_url'] : null; + + // Get asset name - prefer nama_aset from the item, fall back to aset['nama'] + final String displayName = item['nama_aset']?.toString().isNotEmpty == true + ? item['nama_aset'].toString() + : asetNama; + + return Container( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Item image + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: imageUrl == null + ? AppColors.primarySoft + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.grey[200]!, + width: 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(7), + child: imageUrl != null && imageUrl.isNotEmpty + ? CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + placeholder: (context, url) => Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.primary, + ), + ), + ), + errorWidget: (context, url, error) => Center( + child: Icon( + Icons.image_not_supported_outlined, + color: AppColors.primary, + size: 20, + ), + ), + ) + : Center( + child: Icon( + Icons.inventory_rounded, + color: AppColors.primary, + size: 24, + ), + ), + ), + ), + SizedBox(width: 16), + + // Item name and info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + displayName, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + SizedBox(height: 4), + Text( + 'Aset dalam paket', + style: TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + + // Quantity badge + Container( + padding: EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withOpacity(0.3), + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + child: Text( + 'x$kuantitas', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ], + ), + ); + }, + ); + }), + ), + ], + ), + ); + } + + // Build price options section + Widget _buildPriceOptions() { + final paket = controller.paket.value!; + final PaketModel? paketModel = paket is PaketModel ? paket : null; + + // Get the satuanWaktuSewa from the paket model + final satuanWaktuSewa = paketModel?.satuanWaktuSewa ?? []; + + // Create default options for hourly and daily + Map hourlyOption = { + 'id': 'hourly', + 'nama_satuan_waktu': 'per Jam', + 'harga': 0, + 'type': 'hourly', + }; + + Map dailyOption = { + 'id': 'daily', + 'nama_satuan_waktu': 'per Hari', + 'harga': 0, + 'type': 'daily', + }; + + // Find and update hourly and daily options from satuanWaktuSewa if they exist + for (var option in satuanWaktuSewa) { + final namaSatuan = (option['nama_satuan_waktu'] ?? '').toString().toLowerCase(); + if (namaSatuan.contains('jam')) { + hourlyOption = Map.from(option); + hourlyOption['type'] = 'hourly'; + } else if (namaSatuan.contains('hari')) { + dailyOption = Map.from(option); + dailyOption['type'] = 'daily'; + } + } + + // Always include both options in the list + final List> availableOptions = [ + hourlyOption, + dailyOption, + ]; + + // Count available options + final availableOptionsCount = availableOptions.length; + + return Container( + padding: EdgeInsets.symmetric(horizontal: 24.0, vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon( + Icons.access_time_filled_rounded, + size: 20, + color: AppColors.primary, + ), + SizedBox(width: 8), + Text( + 'Opsi Durasi Sewa', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ], + ), + // Show number of available options + availableOptionsCount == 1 + ? Container( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppColors.primarySoft, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '1 opsi tersedia', + style: TextStyle( + fontSize: 12, + color: AppColors.primary, + fontWeight: FontWeight.w500, + ), + ), + ) + : Container( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppColors.primarySoft, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '$availableOptionsCount opsi tersedia', + style: TextStyle( + fontSize: 12, + color: AppColors.primary, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Always show hourly option + _buildDurationOption( + option: availableOptions.firstWhere( + (opt) => opt['type'] == 'hourly', + orElse: () => {'type': 'hourly', 'nama_satuan_waktu': 'per Jam', 'harga': 0}, + ), + isFirst: true, + isLast: false, + ), + + // Always show daily option + _buildDurationOption( + option: availableOptions.firstWhere( + (opt) => opt['type'] == 'daily', + orElse: () => {'type': 'daily', 'nama_satuan_waktu': 'per Hari', 'harga': 0}, + ), + isFirst: false, + isLast: true, + ), + ], + ), + ), + ], + ), + ); + } + + // Helper method to build each duration option + Widget _buildDurationOption({ + required Map option, + required bool isFirst, + required bool isLast, + }) { + // Determine which icon and display name to use based on the rental type + final String type = option['type'] ?? ''; + final String durationName = option['nama_satuan_waktu'] ?? ''; + final double price = double.tryParse(option['harga']?.toString() ?? '0') ?? 0; + final bool isEnabled = price > 0; + + IconData durationIcon; + String durationDisplayName; + + if (type == 'hourly' || durationName.toLowerCase().contains('jam')) { + durationIcon = Icons.access_time_rounded; + durationDisplayName = 'Sewa per Jam'; + } else if (type == 'daily' || durationName.toLowerCase().contains('hari')) { + durationIcon = Icons.calendar_today_rounded; + durationDisplayName = 'Sewa per Hari'; + } else { + durationIcon = Icons.timelapse_rounded; + durationDisplayName = durationName.isNotEmpty ? durationName : 'Sewa'; + } + + final String formattedPrice = controller.formatPrice(price); + + return Opacity( + opacity: isEnabled ? 1.0 : 0.6, + child: InkWell( + onTap: isEnabled ? () { + if (controller.selectedSatuanWaktu.value?['id'] != option['id']) { + HapticFeedback.lightImpact(); + controller.selectSatuanWaktu(option); + } + } : null, + borderRadius: BorderRadius.vertical( + top: isFirst ? Radius.circular(16) : Radius.zero, + bottom: isLast ? Radius.circular(16) : Radius.zero, + ), + child: Obx(() { + final bool isSelected = controller.selectedSatuanWaktu.value?['id'] == option['id']; + return AnimatedContainer( + duration: Duration(milliseconds: 300), + curve: Curves.easeInOut, + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: isSelected + ? AppColors.primarySoft + : (isEnabled ? Colors.white : Colors.grey[100]), + borderRadius: BorderRadius.vertical( + top: isFirst ? Radius.circular(16) : Radius.zero, + bottom: isLast ? Radius.circular(16) : Radius.zero, + ), + border: !isLast + ? Border( + bottom: BorderSide( + color: AppColors.borderLight, + width: 1, + ), + ) + : null, + boxShadow: isSelected && isEnabled + ? [ + BoxShadow( + color: AppColors.primary.withOpacity(0.1), + blurRadius: 10, + offset: Offset(0, 2), + ), + ] + : null, + ), + child: Row( + children: [ + // Icon container + AnimatedContainer( + duration: Duration(milliseconds: 300), + width: 48, + height: 48, + decoration: BoxDecoration( + color: isSelected + ? AppColors.primary.withOpacity(0.1) + : AppColors.surfaceLight, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected + ? AppColors.primary.withOpacity(0.3) + : AppColors.borderLight, + width: isSelected ? 1.5 : 1, + ), + ), + child: Icon( + durationIcon, + color: isSelected ? AppColors.primary : AppColors.textSecondary, + size: 24, + ), + ), + + SizedBox(width: 16), + + // Text content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + durationDisplayName, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: isSelected + ? AppColors.primary + : (isEnabled ? AppColors.textPrimary : AppColors.textSecondary), + height: 1.2, + ), + ), + SizedBox(height: 4), + Text( + isEnabled ? formattedPrice : 'Tidak tersedia', + style: TextStyle( + fontSize: 14, + fontWeight: isEnabled ? FontWeight.w500 : FontWeight.normal, + color: isSelected + ? AppColors.primary.withOpacity(0.9) + : (isEnabled ? AppColors.textSecondary : AppColors.textTertiary), + fontStyle: isEnabled ? null : FontStyle.italic, + ), + ), + ], + ), + ), + + // Checkmark indicator or unavailable indicator + isEnabled + ? AnimatedSwitcher( + duration: Duration(milliseconds: 200), + transitionBuilder: (Widget child, Animation animation) { + return ScaleTransition(scale: animation, child: child); + }, + child: isSelected + ? Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: AppColors.primary, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: AppColors.primary.withOpacity(0.3), + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + child: Icon( + Icons.check, + color: Colors.white, + size: 16, + ), + ) + : Container( + width: 24, + height: 24, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: AppColors.borderLight, + width: 1.5, + ), + ), + ), + ) + : Icon( + Icons.block, + color: Colors.grey[400], + size: 20, + ), + ], + ), + ); + }), + ), + ); + } + // Function to show confirmation dialog void showOrderConfirmationDialog() { final paket = controller.paket.value!; @@ -23,9 +628,7 @@ class OrderSewaPaketView extends GetView { Get.dialog( Dialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), child: Container( width: double.infinity, padding: EdgeInsets.all(24), @@ -62,10 +665,7 @@ class OrderSewaPaketView extends GetView { // Subtitle Text( 'Periksa detail pesanan Anda', - style: TextStyle( - fontSize: 14, - color: AppColors.textSecondary, - ), + style: TextStyle(fontSize: 14, color: AppColors.textSecondary), textAlign: TextAlign.center, ), SizedBox(height: 24), @@ -95,7 +695,9 @@ class OrderSewaPaketView extends GetView { ), ), Text( - paketModel?.nama ?? controller.getPaketNama(paket) ?? 'Paket tanpa nama', + paketModel?.nama ?? + controller.getPaketNama(paket) ?? + 'Paket tanpa nama', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, @@ -109,6 +711,143 @@ class OrderSewaPaketView extends GetView { ), Divider(height: 24, color: AppColors.divider), + // Package Items section + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Detail Paket', + style: TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + SizedBox(height: 8), + Obx(() { + if (controller.isPaketItemsLoading.value) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 16.0, + ), + child: CircularProgressIndicator( + valueColor: + AlwaysStoppedAnimation( + AppColors.primary, + ), + strokeWidth: 2, + ), + ), + ); + } + + if (controller.paketItems.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric( + vertical: 12.0, + ), + child: Text( + 'Tidak ada item dalam paket ini', + style: TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + fontStyle: FontStyle.italic, + ), + ), + ); + } + + return ListView.separated( + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + itemCount: controller.paketItems.length, + separatorBuilder: + (context, index) => Divider( + height: 16, + color: AppColors.divider.withOpacity( + 0.5, + ), + ), + itemBuilder: (context, index) { + final item = controller.paketItems[index]; + final aset = item['aset']; + final kuantitas = item['kuantitas'] ?? 1; + final String assetName = + aset['nama'] ?? 'Item tidak dikenal'; + + return Padding( + padding: const EdgeInsets.symmetric( + vertical: 4.0, + ), + child: Row( + children: [ + // Icon for list item + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: AppColors.primarySoft + .withOpacity(0.2), + shape: BoxShape.circle, + ), + child: Icon( + Icons.inventory_2_outlined, + size: 16, + color: AppColors.primary, + ), + ), + SizedBox(width: 12), + + // Asset name + Expanded( + child: Text( + assetName, + style: TextStyle( + fontSize: 14, + color: AppColors.textPrimary, + ), + ), + ), + + // Quantity + Container( + padding: EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: AppColors.surfaceLight, + borderRadius: + BorderRadius.circular(12), + border: Border.all( + color: AppColors.borderLight, + ), + ), + child: Text( + '$kuantitas', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), + ), + ], + ), + ); + }, + ); + }), + ], + ), + ), + ], + ), + Divider(height: 24, color: AppColors.divider), + // Duration info Row( children: [ @@ -125,9 +864,7 @@ class OrderSewaPaketView extends GetView { ), Obx( () => Text( - controller.isDailyRental() - ? controller.formattedDateRange.value - : '${controller.selectedDate.value}, ${controller.formattedTimeRange.value}', + controller.getFormattedDuration(), style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, @@ -150,7 +887,7 @@ class OrderSewaPaketView extends GetView { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Total', + 'Total Harga', style: TextStyle( fontSize: 12, color: AppColors.textSecondary, @@ -158,7 +895,9 @@ class OrderSewaPaketView extends GetView { ), Obx( () => Text( - controller.formatPrice(controller.totalPrice.value), + controller.formatPrice( + controller.totalPrice.value, + ), style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, @@ -183,8 +922,8 @@ class OrderSewaPaketView extends GetView { child: OutlinedButton( onPressed: () => Get.back(), style: OutlinedButton.styleFrom( - padding: EdgeInsets.symmetric(vertical: 16), - side: BorderSide(color: AppColors.primary), + side: BorderSide(color: AppColors.borderDark), + padding: EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), @@ -203,38 +942,40 @@ class OrderSewaPaketView extends GetView { Expanded( child: Obx( () => ElevatedButton( - onPressed: controller.isSubmitting.value - ? null - : () { - Get.back(); - controller.submitOrder(); - }, + onPressed: + controller.isSubmitting.value + ? null + : () { + Get.back(); + controller.submitOrder(); + }, style: ElevatedButton.styleFrom( - padding: EdgeInsets.symmetric(vertical: 16), + padding: EdgeInsets.symmetric(vertical: 12), backgroundColor: AppColors.primary, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), - child: controller.isSubmitting.value - ? SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - Colors.white, + child: + controller.isSubmitting.value + ? SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ) + : Text( + 'Konfirmasi', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, ), ), - ) - : Text( - 'Pesan', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), ), ), ), @@ -247,1205 +988,1067 @@ class OrderSewaPaketView extends GetView { ); } - @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()) { - 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, + // Build date selection section + Widget _buildDateSelection(BuildContext context) { + return Obx( + () => + controller.selectedSatuanWaktu.value == null + ? SizedBox.shrink() + : Container( + padding: EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, 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, + Row( children: [ Icon( - Icons.image_not_supported_outlined, - size: 64, - color: AppColors.textSecondary, + Icons.event_available_rounded, + size: 20, + color: AppColors.primary, ), - SizedBox(height: 16), + SizedBox(width: 8), Text( - 'Tidak ada foto', + 'Pilih Waktu Sewa', style: TextStyle( fontSize: 16, - color: AppColors.textSecondary, + fontWeight: FontWeight.bold, + color: AppColors.primary, ), ), ], ), - ) - : 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(), + // Information badge + _buildInfoBadge( + controller.isDailyRental() ? 'Harian' : 'Per Jam', ), - 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, + SizedBox(height: 16), + + // Show different UI based on rental type (hourly or daily) + controller.isDailyRental() + ? _buildDailyRentalDateSelection(context) + : _buildHourlyRentalDateSelection(context), ], ), ), - ), - ), - ], + ); + } + + void onTapPesan() { + showOrderConfirmationDialog(); + } + + // Helper method to build info badge + Widget _buildInfoBadge(String text) { + return Container( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppColors.primarySoft, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + text, + style: TextStyle( + fontSize: 12, + color: AppColors.primary, + fontWeight: FontWeight.w500, + ), ), ); } - // 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, - ), - ), + // Hourly rental date and time selection UI + Widget _buildHourlyRentalDateSelection(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Date picker button + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: Offset(0, 2), ), ], ), - SizedBox(height: 16), - - // Description - Text( - 'Deskripsi', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, + child: InkWell( + onTap: () => controller.pickDate(context), + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: Color(0xFF92B4D7).withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Icon( + Icons.date_range_rounded, + color: Color(0xFF3A6EA5), + size: 24, + ), + ), + ), + SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Tanggal Sewa', + style: TextStyle( + fontSize: 14, + color: Colors.grey[700], + ), + ), + SizedBox(height: 4), + Obx( + () => Text( + controller.selectedStartDate.value == null + ? 'Pilih tanggal sewa' + : controller.formattedSelectedDate, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF3A6EA5), + ), + ), + ), + ], + ), + ), + Icon( + Icons.arrow_forward_ios_rounded, + size: 16, + color: Colors.grey[400], + ), + ], + ), ), ), - 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, + ), + + SizedBox(height: 24), + + // Time selection section + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppColors.primarySoft, + borderRadius: BorderRadius.circular(10), + ), + child: Center( + child: Icon( + Icons.access_time_rounded, + color: AppColors.primary, + size: 20, + ), + ), + ), + SizedBox(width: 12), + Text( + 'Pilih Jam', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ], + ), + + // Show selected time range if any + Obx( + () => + controller.formattedTimeRange.value.isNotEmpty + ? Container( + padding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: AppColors.primarySoft, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + controller.formattedTimeRange.value, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), + ) + : SizedBox.shrink(), + ), + ], + ), + ), + + Divider(height: 1, thickness: 1, color: Colors.grey[200]), + + // Time selection grid + Padding( + padding: const EdgeInsets.all(16.0), + child: _buildTimeGrid(), + ), + + // Show selected duration + Obx( + () => + controller.formattedTimeRange.value.isNotEmpty + ? Container( + padding: EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + color: AppColors.primarySoft, + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(16), + bottomRight: Radius.circular(16), + ), + ), + child: Row( + children: [ + Icon( + Icons.info_outline_rounded, + size: 16, + color: AppColors.primary, + ), + SizedBox(width: 8), + Text( + 'Durasi: ${controller.duration.value} jam', + style: TextStyle( + fontSize: 14, + color: AppColors.primary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ) + : SizedBox.shrink(), + ), + ], + ), + ), + ], + ); + } + + // Daily rental date selection UI + Widget _buildTimeGrid() { + // Create rows of hours, with 4 hours per row + final List rows = []; + final hours = controller.availableHours; + + // Create rows of 4 hours each + for (int i = 0; i < hours.length; i += 4) { + final rowItems = []; + + // Add up to 4 hours for this row + for (int j = 0; j < 4 && i + j < hours.length; j++) { + final hour = hours[i + j]; + rowItems.add(Expanded(child: _buildTimeButton(hour))); + + // Add spacing between buttons + if (j < 3 && i + j + 1 < hours.length) { + rowItems.add(SizedBox(width: 8)); + } + } + + rows.add(Row(children: rowItems)); + + // Add spacing between rows + if (i + 4 < hours.length) { + rows.add(SizedBox(height: 12)); + } + } + + return Container( + margin: EdgeInsets.symmetric(horizontal: 4), + child: Column(children: rows), + ); + } + + Widget _buildTimeButton(int hour) { + return Obx(() { + // Check if hour is disabled based on inventory availability + final bool isDisabled = controller.isHourDisabled(hour); + + // Get selection states + final bool isStartTime = controller.selectedStartTime.value == hour; + final bool isEndTime = controller.selectedEndTime.value == hour; + final bool isSelected = isStartTime || isEndTime; + final bool isInRange = + controller.selectedStartTime.value >= 0 && + controller.selectedEndTime.value >= 0 && + hour > controller.selectedStartTime.value && + hour < controller.selectedEndTime.value; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: + isDisabled + ? null + : () { + // Add haptic feedback when selecting an hour + HapticFeedback.selectionClick(); + controller.selectTime(hour); + }, + borderRadius: BorderRadius.circular(12), + child: AnimatedContainer( + duration: Duration(milliseconds: 300), + curve: Curves.easeInOut, + height: 48, + decoration: BoxDecoration( + color: + isDisabled + ? Colors.grey[200] + : isSelected + ? AppColors.primary + : isInRange + ? AppColors.primarySoft + : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: + isDisabled + ? Colors.grey[300]! + : isSelected + ? AppColors.primary + : isInRange + ? AppColors.primary.withOpacity(0.3) + : Colors.grey[300]!, + width: 1.5, + ), + ), + child: Center( + child: Text( + '$hour:00', + style: TextStyle( + color: + isDisabled + ? Colors.grey[500] + : isSelected + ? Colors.white + : isInRange + ? AppColors.primary + : Colors.grey[800], + fontWeight: + isSelected || isInRange + ? FontWeight.bold + : FontWeight.normal, + fontSize: 14, + ), + ), + ), + ), + ), + ); + }); + } + + Widget _buildDailyRentalDateSelection(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Date range picker button + InkWell( + onTap: () => controller.pickDateRange(context), + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: Color(0xFF92B4D7).withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Icon( + Icons.date_range_rounded, + color: Color(0xFF3A6EA5), + size: 24, + ), + ), + ), + SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Rentang Tanggal', + style: TextStyle( + fontSize: 14, + color: Colors.grey[700], + ), + ), + SizedBox(height: 4), + Obx( + () => Text( + controller.formattedDateRange.value.isNotEmpty + ? controller.formattedDateRange.value + : 'Pilih tanggal sewa', + style: TextStyle( + fontSize: 16, + fontWeight: + controller.formattedDateRange.value.isNotEmpty + ? FontWeight.w600 + : FontWeight.normal, + color: + controller.formattedDateRange.value.isNotEmpty + ? Color(0xFF3A6EA5) + : Colors.grey[500], + ), + ), + ), + ], + ), + ), + Icon( + Icons.arrow_forward_ios_rounded, + size: 16, + color: Colors.grey[400], + ), + ], + ), + ), + ), + + // Display selected duration + Obx( + () => + controller.formattedDateRange.value.isNotEmpty + ? Container( + padding: EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + color: Color(0xFF92B4D7).withOpacity(0.1), + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(16), + bottomRight: Radius.circular(16), + ), + ), + child: Row( + children: [ + Icon( + Icons.info_outline_rounded, + size: 16, + color: Color(0xFF3A6EA5), + ), + SizedBox(width: 8), + Text( + 'Durasi: ${controller.duration.value} hari', + style: TextStyle( + fontSize: 14, + color: Color(0xFF3A6EA5), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ) + : SizedBox.shrink(), + ), + ], + ), + ); + } + + // Build summary section + Widget _buildSummary() { + return Obx( + () => + 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)) + ? SizedBox.shrink() + : Container( + padding: EdgeInsets.all(16), + color: Colors.white, + margin: EdgeInsets.only(top: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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, + ), + ), + ), + ], + ), + ), + // 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, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final paket = controller.paket.value; + + // If no paket, return loading + if (paket == null) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + title: Text('Detail Paket'), + backgroundColor: Colors.white, + foregroundColor: Colors.black, + elevation: 0.5, + ), + body: Center(child: CircularProgressIndicator()), + ); + } + + final PaketModel? paketModel = paket is PaketModel ? paket : null; + final String paketNama = + paketModel?.nama ?? + controller.getPaketNama(paket) ?? + 'Paket Tanpa Nama'; + final String? paketDeskripsi = + paketModel?.deskripsi ?? controller.getPaketDeskripsi(paket); + + // Function to handle back button press + void handleBackButtonPress() { + debugPrint('๐Ÿ”™ Back button pressed - returning to previous page'); + try { + // First try to use navigation service if available + if (Get.isRegistered()) { + Get.find().back(); + } else { + // Fallback to direct navigation + Get.back(); + } + } catch (e) { + debugPrint('โš ๏ธ Error handling back button: $e'); + // Last resort fallback + Get.back(); + } + } + + return Scaffold( + backgroundColor: Color(0xFFF5F5F5), + // No AppBar - we'll use a custom back button + body: Stack( + children: [ + SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Top image section with gallery and back button + Stack( + children: [ + // Image gallery with rounded corners + SizedBox( + height: 320, + width: double.infinity, + child: GestureDetector( + onTap: () { + if (controller.paketImages.isNotEmpty) { + controller.openImageGallery( + context, + controller.paketImages.toList(), + 0, + ); + } + }, + child: ClipRRect( + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(24), + bottomRight: Radius.circular(24), + ), + child: Obx(() { + // Show loading indicator when images are being loaded + if (controller.isPhotosLoading.value) { + return Container( + color: Colors.grey[200], + child: Center( + child: CircularProgressIndicator( + color: AppColors.primary, + ), + ), + ); + } + + if (controller.paketImages.isEmpty) { + return Container( + color: Colors.grey[200], + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.image_not_supported_rounded, + size: 64, + color: Colors.grey[400], + ), + SizedBox(height: 16), + Text( + 'Tidak ada foto', + style: TextStyle( + color: Colors.grey[600], + fontSize: 16, + ), + ), + ], + ), + ), + ); + } + + return CachedNetworkImage( + imageUrl: controller.paketImages[0], + fit: BoxFit.cover, + placeholder: + (context, url) => Container( + color: Colors.grey[200], + child: Center( + child: CircularProgressIndicator( + color: AppColors.primary, + ), + ), + ), + errorWidget: + (context, url, error) => Container( + color: Colors.grey[200], + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.broken_image_rounded, + size: 64, + color: Colors.grey[400], + ), + SizedBox(height: 16), + Text( + 'Gagal memuat foto', + style: TextStyle( + color: Colors.grey[600], + fontSize: 16, + ), + ), + ], + ), + ), + ), + ); + }), + ), + ), + ), + + // Gradient overlay + Positioned( + top: 0, + left: 0, + right: 0, + height: 120, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withOpacity(0.5), + Colors.transparent, + ], + stops: [0.0, 1.0], + ), + ), + ), + ), + + // Back button + Positioned( + top: 40, + left: 16, + child: GestureDetector( + onTap: handleBackButtonPress, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.9), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 8, + offset: Offset(0, 2), + ), + ], + ), + child: Center( + child: Icon( + Icons.arrow_back_rounded, + color: AppColors.primary, + size: 20, + ), + ), + ), + ), + ), + + // Navigation arrows - only show if more than 1 photo + Obx( + () => + controller.paketImages.length > 1 + ? Positioned.fill( + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Container( + margin: EdgeInsets.only(left: 16), + child: IconButton( + icon: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.black26, + shape: BoxShape.circle, + ), + child: Icon( + Icons.arrow_back_ios_rounded, + color: Colors.white, + size: 20, + ), + ), + onPressed: () { + if (controller + .currentPhotoIndex + .value > + 0) { + controller + .currentPhotoIndex + .value--; + } else { + controller.currentPhotoIndex.value = + controller.paketImages.length - + 1; + } + }, + ), + ), + Container( + margin: EdgeInsets.only(right: 16), + child: IconButton( + icon: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.black26, + shape: BoxShape.circle, + ), + child: Icon( + Icons.arrow_forward_ios_rounded, + color: Colors.white, + size: 20, + ), + ), + onPressed: () { + if (controller + .currentPhotoIndex + .value < + controller.paketImages.length - + 1) { + controller + .currentPhotoIndex + .value++; + } else { + controller.currentPhotoIndex.value = + 0; + } + }, + ), + ), + ], + ), + ) + : SizedBox.shrink(), + ), + + // Photo counter in top right + Obx( + () => + controller.paketImages.length > 1 + ? Positioned( + top: 40, + right: 16, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.black45, + borderRadius: BorderRadius.circular(16), + ), + child: Text( + "${controller.currentPhotoIndex.value + 1}/${controller.paketImages.length}", + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ) + : SizedBox.shrink(), + ), + + // Zoom indicator overlay + Positioned( + bottom: 16, + right: 16, + child: Container( + padding: EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.5), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.zoom_in, + color: Colors.white, + size: 24, + ), + ), + ), + ], + ), + + // Paket info + Container( + padding: EdgeInsets.all(24), + color: Colors.white, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Package name + Text( + paketNama, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + height: 1.2, + color: AppColors.textPrimary, + ), + ), + + SizedBox(height: 24), + + // Description with card styling + if (paketDeskripsi != null && paketDeskripsi.isNotEmpty) + Container( + padding: EdgeInsets.all(20), + width: double.infinity, + constraints: BoxConstraints(minHeight: 120), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: AppColors.shadow, + blurRadius: 10, + offset: Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.description_outlined, + color: AppColors.primary, + size: 18, + ), + SizedBox(width: 8), + Text( + 'Deskripsi', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ], + ), + SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: Text( + paketDeskripsi, + style: TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + height: 1.5, + ), + ), + ), + ], + ), + ), + ], + ), + ), + + // Package items details section + _buildPackageItemsDetails(), + + // Price options + _buildPriceOptions(), + + // Date selection + _buildDateSelection(context), + + // Summary and order button + _buildSummary(), + + // Space for bottom sheet + SizedBox(height: 80), + ], ), ), ], ), ); } - // 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, - - ), - - ), - - ), - - ), - - ], - - ), - - ), - - ); - - } - - +} diff --git a/lib/app/modules/warga/views/sewa_aset_view.dart b/lib/app/modules/warga/views/sewa_aset_view.dart index 8a09c53..3933d68 100644 --- a/lib/app/modules/warga/views/sewa_aset_view.dart +++ b/lib/app/modules/warga/views/sewa_aset_view.dart @@ -392,7 +392,9 @@ class SewaAsetView extends GetView { String imageUrl = paket['gambar_url'] ?? ''; return GestureDetector( - onTap: () => _showPaketDetailModal(paket), + onTap: () { + _showPaketDetailModal(paket); + }, child: Container( decoration: BoxDecoration( color: AppColors.surface, @@ -535,7 +537,7 @@ class SewaAsetView extends GetView { ], ), ); - }).toList(), + }), ], ), ) @@ -571,7 +573,18 @@ class SewaAsetView extends GetView { SizedBox( width: double.infinity, child: ElevatedButton( - onPressed: () => _showPaketDetailModal(paket), + onPressed: () { + // Navigate directly to order page with package data + Get.toNamed( + Routes.ORDER_SEWA_PAKET, + arguments: { + 'id': paket['id'], + 'paketId': paket['id'], + 'paketData': paket, + 'satuanWaktuSewa': satuanWaktuSewa, + }, + ); + }, style: ElevatedButton.styleFrom( backgroundColor: AppColors.primary, foregroundColor: Colors.white, @@ -893,35 +906,37 @@ class SewaAsetView extends GetView { // Order button Padding( - padding: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.only(top: 16.0, bottom: 24.0), child: SizedBox( width: double.infinity, height: 50, child: ElevatedButton( onPressed: () { - if (satuanWaktuSewa.isEmpty) { - Get.snackbar( - 'Tidak Dapat Memesan', - 'Pilihan harga belum tersedia untuk paket ini', - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.red[100], - colorText: Colors.red[800], - ); - return; - } - - _showOrderPaketForm(paket, satuanWaktuSewa); + // Close the modal + Get.back(); + // Navigate to order_sewa_paket page with package data + Get.toNamed( + Routes.ORDER_SEWA_PAKET, + arguments: { + 'paket': paket, + 'satuanWaktuSewa': satuanWaktuSewa, + }, + ); }, style: ElevatedButton.styleFrom( - foregroundColor: Colors.white, backgroundColor: AppColors.primary, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), + elevation: 2, ), child: const Text( - 'Pesan Paket Ini', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + 'Pesan Sekarang', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + ), ), ), ), @@ -929,6 +944,9 @@ class SewaAsetView extends GetView { ], ), ), + isScrollControlled: true, + backgroundColor: Colors.transparent, + barrierColor: Colors.black54, ); } @@ -945,10 +963,11 @@ class SewaAsetView extends GetView { final RxInt duration = RxInt(selectedSWS.value?['durasi_min'] ?? 1); // Calculate total price - final calculateTotal = () { + calculateTotal() { if (selectedSWS.value == null) return 0; return (selectedSWS.value!['harga'] ?? 0) * duration.value; - }; + } + final RxInt totalPrice = RxInt(calculateTotal()); // Update total when duration or pricing option changes @@ -1231,7 +1250,7 @@ class SewaAsetView extends GetView { ], ), Text( - 'Minimum ${minDuration} ${namaSatuanWaktu.toLowerCase()}', + 'Minimum $minDuration ${namaSatuanWaktu.toLowerCase()}', style: TextStyle( fontSize: 12, color: Colors.grey[600], @@ -1285,20 +1304,12 @@ class SewaAsetView extends GetView { onPressed: () { Get.back(); // Close the form - // Navigate to order_sewa_paket page - // Get the navigation service from the controller - final navigationService = controller.navigationService; - - // Store the selected parameters in a controller or pass as arguments - Get.toNamed( - Routes.ORDER_SEWA_PAKET, - arguments: { - 'paketId': paket['id'], - 'satuanWaktuSewaId': selectedSWS.value?['id'] ?? '', - 'durasi': duration.value, - 'totalHarga': totalPrice.value, - 'paketData': paket, - }, + // Order the package + controller.placeOrderPaket( + paketId: paket['id'], + satuanWaktuSewaId: selectedSWS.value?['id'] ?? '', + durasi: duration.value, + totalHarga: totalPrice.value, ); }, style: ElevatedButton.styleFrom( diff --git a/lib/app/services/navigation_service.dart b/lib/app/services/navigation_service.dart index c202b86..56eccb4 100644 --- a/lib/app/services/navigation_service.dart +++ b/lib/app/services/navigation_service.dart @@ -28,7 +28,7 @@ class NavigationService extends GetxService { Get.toNamed(Routes.SEWA_ASET, preventDuplicates: false); } - /// Navigasi ke halaman Detail Sewa Aset dengan ID + /// Navigasi ke halaman Order Sewa Aset dengan ID Future toOrderSewaAset(String asetId) async { debugPrint('๐Ÿงญ Navigating to OrderSewaAset with ID: $asetId'); if (asetId.isEmpty) { @@ -50,6 +50,33 @@ class NavigationService extends GetxService { ); } + /// Navigasi ke halaman Pembayaran Sewa dengan ID sewa + Future navigateToPembayaranSewa(String sewaId) async { + debugPrint('๐Ÿงญ Navigating to PembayaranSewa with ID: $sewaId'); + if (sewaId.isEmpty) { + Get.snackbar( + 'Error', + 'ID sewa tidak valid', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + return; + } + + // Navigasi dengan arguments + Get.offAndToNamed( + Routes.PEMBAYARAN_SEWA, + arguments: {'sewaId': sewaId}, + ); + } + + /// Kembali ke halaman Sewa Aset + void navigateToSewaAset() { + debugPrint('๐Ÿงญ Navigating back to SewaAset'); + Get.offAllNamed(Routes.SEWA_ASET); + } + /// Navigasi ke halaman Order Sewa Paket dengan ID Future toOrderSewaPaket(String paketId) async { debugPrint('๐Ÿงญ Navigating to OrderSewaPaket with ID: $paketId'); diff --git a/lib/utils/constants/app_colors.dart b/lib/utils/constants/app_colors.dart new file mode 100644 index 0000000..da12ae4 --- /dev/null +++ b/lib/utils/constants/app_colors.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; + +class AppColors { + // Primary Colors + static const Color primary = Color(0xFF007AFF); + static const Color primaryDark = Color(0xFF0062CC); + static const Color primaryLight = Color(0xFF4DA6FF); + static const Color primarySoft = Color(0xFFE3F2FD); // Light blue, 10% opacity of primary + + // Secondary Colors + static const Color secondary = Color(0xFF34C759); + static const Color secondaryDark = Color(0xFF248A3D); + static const Color secondaryLight = Color(0xFF5DDC7F); + + // Status Colors + static const Color success = Color(0xFF34C759); + static const Color warning = Color(0xFFFF9500); + static const Color error = Color(0xFFFF3B30); + static const Color info = Color(0xFF007AFF); + + // Grayscale + static const Color black = Color(0xFF000000); + static const Color gray1 = Color(0xFF1C1C1E); + static const Color gray2 = Color(0xFF2C2C2E); + static const Color gray3 = Color(0xFF48484A); + static const Color gray4 = Color(0xFF8E8E93); + static const Color gray5 = Color(0xFFAEAEB2); + static const Color gray6 = Color(0xFFC7C7CC); + static const Color gray7 = Color(0xFFD1D1D6); + static const Color gray8 = Color(0xFFE5E5EA); + static const Color gray9 = Color(0xFFF2F2F7); + static const Color white = Color(0xFFFFFFFF); + + // UI Elements + static const Color border = Color(0xFFE5E5EA); + static const Color borderDark = Color(0xFFD1D1D6); + static const Color borderLight = Color(0xFFF0F0F0); + static const Color background = Color(0xFFFFFFFF); + static const Color cardBackground = Color(0xFFFFFFFF); + static const Color disabled = Color(0xFFD1D1D6); + static const Color placeholder = Color(0xFFC7C7CC); + + // Surface Colors (for cards, sheets, menus, etc.) + static const Color surface = Color(0xFFFFFFFF); + static const Color surfaceLight = Color(0xFFF8F9FA); // Very light gray, slightly lighter than backgroundSecondary + + // Text Colors + static const Color textPrimary = Color(0xFF000000); + static const Color textSecondary = Color(0xFF48484A); + static const Color textTertiary = Color(0xFF8E8E93); + static const Color textDisabled = Color(0xFFAEAEB2); + static const Color textInverse = Color(0xFFFFFFFF); + + // Background Colors + static const Color backgroundPrimary = Color(0xFFFFFFFF); + static const Color backgroundSecondary = Color(0xFFF2F2F7); + static const Color backgroundTertiary = Color(0xFFE5E5EA); + + // Overlay Colors + static const Color overlayDark = Color(0x99000000); + static const Color overlayLight = Color(0x33FFFFFF); + + // Shadow Colors + static const Color shadow = Color(0x1A000000); + + // Transparent + static const Color transparent = Color(0x00000000); + + // Other UI Colors + static const Color divider = Color(0xFFE5E5EA); + static const Color highlight = Color(0x1A000000); + static const Color splash = Color(0x1A000000); + + // Gradients + static const LinearGradient primaryGradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [primary, primaryDark], + ); + + static const LinearGradient secondaryGradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [secondary, secondaryDark], + ); +}