diff --git a/lib/app/data/models/rental_booking_model.dart b/lib/app/data/models/rental_booking_model.dart index 1b310db..d39352d 100644 --- a/lib/app/data/models/rental_booking_model.dart +++ b/lib/app/data/models/rental_booking_model.dart @@ -24,6 +24,8 @@ class SewaModel { final double? denda; final double? dibayar; final double? paidAmount; + // Add nama_satuan_waktu field + final String? namaSatuanWaktu; SewaModel({ required this.id, @@ -47,6 +49,7 @@ class SewaModel { this.denda, this.dibayar, this.paidAmount, + this.namaSatuanWaktu, }); factory SewaModel.fromJson(Map json) { @@ -90,6 +93,7 @@ class SewaModel { (json['paid_amount'] is num) ? json['paid_amount'].toDouble() : double.tryParse(json['paid_amount']?.toString() ?? '0'), + namaSatuanWaktu: json['nama_satuan_waktu'], ); } } diff --git a/lib/app/data/providers/aset_provider.dart b/lib/app/data/providers/aset_provider.dart index f62188a..03cddb8 100644 --- a/lib/app/data/providers/aset_provider.dart +++ b/lib/app/data/providers/aset_provider.dart @@ -18,6 +18,75 @@ class AsetProvider extends GetxService { client = Supabase.instance.client; } + // Method to clear any cached data + void clearCache() { + debugPrint('Clearing AsetProvider cached data'); + // Clear any cached asset data or state + // This is useful when logging out to ensure no user data remains in memory + + // Note: Since this provider doesn't currently maintain any persistent cache variables, + // this method serves as a placeholder for future cache implementations + } + + // Delete an asset and all related data + Future deleteAset(String asetId) async { + try { + debugPrint('🔄 Starting deletion process for asset ID: $asetId'); + + // 1. Get existing photo URLs to delete them from storage + debugPrint('📋 Fetching photos for asset ID: $asetId'); + final existingPhotos = await client + .from('foto_aset') + .select('foto_aset') + .eq('id_aset', asetId); + + // 2. Delete files from storage first + if (existingPhotos is List && existingPhotos.isNotEmpty) { + debugPrint('🗑️ Deleting ${existingPhotos.length} files from storage'); + for (final photo in existingPhotos) { + final url = photo['foto_aset'] as String?; + if (url != null && url.isNotEmpty) { + await deleteFileFromStorage(url); + } + } + } + + // 3. Delete records from related tables in the correct order to maintain referential integrity + // 3.1 Delete rental time units + debugPrint('🗑️ Deleting rental time units for asset ID: $asetId'); + await deleteSatuanWaktuSewaByAsetId(asetId); + + // 3.2 Delete photo records from database + debugPrint('🗑️ Deleting photo records for asset ID: $asetId'); + await client.from('foto_aset').delete().eq('id_aset', asetId); + + // 3.3 Delete bookings if any (optional, may want to keep for historical records) + debugPrint('🗑️ Checking for bookings related to asset ID: $asetId'); + final bookings = await client + .from('booked_detail') + .select('id') + .eq('aset_id', asetId); + + if (bookings is List && bookings.isNotEmpty) { + debugPrint('⚠️ Found ${bookings.length} bookings for this asset'); + debugPrint('⚠️ Consider handling booking records appropriately'); + // Uncomment to delete bookings: + // await client.from('booked_detail').delete().eq('aset_id', asetId); + } + + // 4. Finally delete the asset itself + debugPrint('🗑️ Deleting asset record with ID: $asetId'); + await client.from('aset').delete().eq('id', asetId); + + debugPrint('✅ Asset deletion completed successfully'); + return true; + } catch (e, stackTrace) { + debugPrint('❌ Error deleting asset: $e'); + debugPrint('❌ Stack trace: $stackTrace'); + return false; + } + } + // Mendapatkan semua aset dengan kategori "sewa" Future> getSewaAsets() async { try { @@ -805,7 +874,8 @@ class AsetProvider extends GetxService { // Fungsi untuk membuat pesanan lengkap (sewa_aset, booked_detail, dan tagihan_sewa) dalam satu operasi Future createCompleteOrder({ required Map sewaAsetData, - required Map bookedDetailData, + required dynamic + bookedDetailData, // Changed to dynamic to accept List or Map required Map tagihanSewaData, }) async { try { @@ -813,15 +883,39 @@ class AsetProvider extends GetxService { debugPrint('📦 sewa_aset data:'); sewaAsetData.forEach((key, value) => debugPrint(' $key: $value')); - debugPrint('📦 booked_detail data:'); - bookedDetailData.forEach((key, value) => debugPrint(' $key: $value')); + // Check if bookedDetailData is a list (for package orders) or a single map (for regular orders) + bool isPackageOrder = bookedDetailData is List; - // Ensure we don't try to insert a status field that no longer exists - if (bookedDetailData.containsKey('status')) { + if (isPackageOrder) { debugPrint( - '⚠️ Removing status field from booked_detail data as it does not exist in the table', + '📦 Package order detected with ${bookedDetailData.length} booked_detail items', ); - bookedDetailData.remove('status'); + for (int i = 0; i < bookedDetailData.length; i++) { + debugPrint('📦 booked_detail item $i:'); + bookedDetailData[i].forEach( + (key, value) => debugPrint(' $key: $value'), + ); + + // Ensure we don't try to insert a status field that no longer exists + if (bookedDetailData[i].containsKey('status')) { + debugPrint( + '⚠️ Removing status field from booked_detail data as it does not exist in the table', + ); + bookedDetailData[i].remove('status'); + } + } + } else { + debugPrint('📦 Regular order with single booked_detail'); + debugPrint('📦 booked_detail data:'); + bookedDetailData.forEach((key, value) => debugPrint(' $key: $value')); + + // Ensure we don't try to insert a status field that no longer exists + if (bookedDetailData.containsKey('status')) { + debugPrint( + '⚠️ Removing status field from booked_detail data as it does not exist in the table', + ); + bookedDetailData.remove('status'); + } } debugPrint('📦 tagihan_sewa data:'); @@ -835,19 +929,36 @@ class AsetProvider extends GetxService { tagihanSewaData.remove('nama_aset'); } - // Insert all three records + // Insert sewa_aset record final sewaAsetResult = await client.from('sewa_aset').insert(sewaAsetData).select().single(); debugPrint('✅ sewa_aset created: ${sewaAsetResult['id']}'); - final bookedDetailResult = - await client - .from('booked_detail') - .insert(bookedDetailData) - .select() - .single(); - debugPrint('✅ booked_detail created: ${bookedDetailResult['id']}'); + // Insert booked_detail record(s) + if (isPackageOrder) { + // For package orders, insert multiple booked_detail records + for (int i = 0; i < bookedDetailData.length; i++) { + final bookedDetailItem = bookedDetailData[i]; + final bookedDetailResult = + await client + .from('booked_detail') + .insert(bookedDetailItem) + .select() + .single(); + debugPrint('✅ booked_detail $i created: ${bookedDetailResult['id']}'); + } + } else { + // For regular orders, insert a single booked_detail record + final bookedDetailResult = + await client + .from('booked_detail') + .insert(bookedDetailData) + .select() + .single(); + debugPrint('✅ booked_detail created: ${bookedDetailResult['id']}'); + } + // Insert tagihan_sewa record final tagihanSewaResult = await client .from('tagihan_sewa') @@ -875,9 +986,19 @@ class AsetProvider extends GetxService { ); // Print the field names from each data object to help debug debugPrint('❌ Fields in sewa_aset data: ${sewaAsetData.keys.toList()}'); - debugPrint( - '❌ Fields in booked_detail data: ${bookedDetailData.keys.toList()}', - ); + + if (bookedDetailData is List) { + for (int i = 0; i < bookedDetailData.length; i++) { + debugPrint( + '❌ Fields in booked_detail item $i: ${bookedDetailData[i].keys.toList()}', + ); + } + } else { + debugPrint( + '❌ Fields in booked_detail data: ${bookedDetailData.keys.toList()}', + ); + } + debugPrint( '❌ Fields in tagihan_sewa data: ${tagihanSewaData.keys.toList()}', ); @@ -1461,6 +1582,8 @@ class AsetProvider extends GetxService { // Get photos for a package Future> getFotoPaket(String paketId) async { try { + debugPrint('🔍 Fetching photos for package ID: $paketId'); + final response = await client .from('foto_aset') .select('foto_aset') @@ -1468,13 +1591,27 @@ class AsetProvider extends GetxService { .order('created_at'); if (response != null && response.isNotEmpty) { - return response - .map((item) => item['foto_aset'] as String) - .toList(); + // Extract photo URLs and filter out duplicates + final Set uniqueUrls = {}; + final List uniquePhotos = []; + + for (var item in response) { + final String url = item['foto_aset'] as String; + if (url.isNotEmpty && !uniqueUrls.contains(url)) { + uniqueUrls.add(url); + uniquePhotos.add(url); + } + } + + debugPrint( + '📸 Found ${response.length} photos, ${uniquePhotos.length} unique for package $paketId', + ); + return uniquePhotos; } + debugPrint('⚠️ No photos found for package ID: $paketId'); return []; } catch (e) { - debugPrint('Error getting package photos: $e'); + debugPrint('❌ Error getting package photos for ID $paketId: $e'); return []; } } @@ -1910,7 +2047,7 @@ class AsetProvider extends GetxService { 'tagihan_sewa_id': tagihanSewaId, 'metode_pembayaran': metodePembayaran, 'total_pembayaran': nominal, - 'status': 'lunas', + 'status': 'diterima', 'created_at': DateTime.now().toIso8601String(), 'id_petugas': idPetugas, }; @@ -1979,4 +2116,74 @@ class AsetProvider extends GetxService { } return 0; } + + /// Delete a package (paket) and all related data + /// This includes: + /// 1. Deleting photos from storage + /// 2. Removing records from foto_aset table + /// 3. Removing records from satuan_waktu_sewa table + /// 4. Removing records from paket_item table + /// 5. Finally deleting the package itself from the paket table + Future deletePaket(String paketId) async { + try { + debugPrint('🔄 Starting deletion process for package ID: $paketId'); + + // 1. Get all photo URLs for this package + debugPrint('📋 Fetching photos for package ID: $paketId'); + final existingPhotos = await client + .from('foto_aset') + .select('foto_aset') + .eq('id_paket', paketId); + + // 2. Delete files from storage first + if (existingPhotos is List && existingPhotos.isNotEmpty) { + debugPrint('🗑️ Deleting ${existingPhotos.length} files from storage'); + for (final photo in existingPhotos) { + final url = photo['foto_aset'] as String?; + if (url != null && url.isNotEmpty) { + await deleteFileFromStorage(url); + } + } + } + + // 3. Delete records from related tables in the correct order to maintain referential integrity + // 3.1 Delete rental time units related to this package + debugPrint('🗑️ Deleting rental time units for package ID: $paketId'); + await client.from('satuan_waktu_sewa').delete().eq('paket_id', paketId); + + // 3.2 Delete photo records from database + debugPrint('🗑️ Deleting photo records for package ID: $paketId'); + await client.from('foto_aset').delete().eq('id_paket', paketId); + + // 3.3 Delete package items + debugPrint('🗑️ Deleting package items for package ID: $paketId'); + await client.from('paket_item').delete().eq('paket_id', paketId); + + // 3.4 Check for bookings (optional) + debugPrint('🔍 Checking for bookings related to package ID: $paketId'); + final bookings = await client + .from('sewa_aset') + .select('id') + .eq('paket_id', paketId) + .not('status', 'in', '(DIBATALKAN,SELESAI)'); + + if (bookings is List && bookings.isNotEmpty) { + debugPrint('⚠️ Found ${bookings.length} bookings for this package'); + debugPrint( + '⚠️ These bookings will be orphaned. Consider updating them if needed', + ); + } + + // 4. Finally delete the package itself + debugPrint('🗑️ Deleting package record with ID: $paketId'); + await client.from('paket').delete().eq('id', paketId); + + debugPrint('✅ Package deletion completed successfully'); + return true; + } catch (e, stackTrace) { + debugPrint('❌ Error deleting package: $e'); + debugPrint('❌ Stack trace: $stackTrace'); + return false; + } + } } diff --git a/lib/app/data/providers/auth_provider.dart b/lib/app/data/providers/auth_provider.dart index 23ad383..1d8fd81 100644 --- a/lib/app/data/providers/auth_provider.dart +++ b/lib/app/data/providers/auth_provider.dart @@ -75,6 +75,15 @@ class AuthProvider extends GetxService { await client.auth.signOut(); } + // Method to clear any cached data in the AuthProvider + void clearAuthData() { + // Clear any cached user data or state + // This method is called during logout to ensure all user-related data is cleared + debugPrint('Clearing AuthProvider cached data'); + // Currently, signOut() handles most of the cleanup, but this method can be extended + // if additional cleanup is needed in the future + } + User? get currentUser => client.auth.currentUser; Stream get authChanges => client.auth.onAuthStateChange; @@ -415,28 +424,17 @@ class AuthProvider extends GetxService { final userData = await client .from('warga_desa') - .select('nomor_telepon, no_telepon, phone') + .select('no_hp') .eq('user_id', user.id) .maybeSingle(); // Jika berhasil mendapatkan data, cek beberapa kemungkinan nama kolom if (userData != null) { - if (userData.containsKey('nomor_telepon')) { - final phone = userData['nomor_telepon']?.toString(); - if (phone != null && phone.isNotEmpty) return phone; - } - - if (userData.containsKey('no_telepon')) { - final phone = userData['no_telepon']?.toString(); - if (phone != null && phone.isNotEmpty) return phone; - } - - if (userData.containsKey('phone')) { - final phone = userData['phone']?.toString(); + if (userData.containsKey('no_hp')) { + final phone = userData['no_hp']?.toString(); if (phone != null && phone.isNotEmpty) return phone; } } - // Fallback ke data dari Supabase Auth final userMetadata = user.userMetadata; if (userMetadata != null) { @@ -496,6 +494,146 @@ class AuthProvider extends GetxService { } } + // Metode untuk mendapatkan tanggal lahir dari tabel warga_desa berdasarkan user_id + Future getUserTanggalLahir() async { + final user = currentUser; + if (user == null) { + debugPrint('No current user found when getting tanggal_lahir'); + return null; + } + + try { + debugPrint('Fetching tanggal_lahir for user_id: ${user.id}'); + + // Coba ambil tanggal lahir dari tabel warga_desa + final userData = + await client + .from('warga_desa') + .select('tanggal_lahir') + .eq('user_id', user.id) + .maybeSingle(); + + // Jika berhasil mendapatkan data + if (userData != null && userData.containsKey('tanggal_lahir')) { + final tanggalLahir = userData['tanggal_lahir']?.toString(); + if (tanggalLahir != null && tanggalLahir.isNotEmpty) { + debugPrint('Found tanggal_lahir: $tanggalLahir'); + return tanggalLahir; + } + } + + return null; + } catch (e) { + debugPrint('Error fetching user tanggal_lahir: $e'); + return null; + } + } + + // Metode untuk mendapatkan RT/RW dari tabel warga_desa berdasarkan user_id + Future getUserRtRw() async { + final user = currentUser; + if (user == null) { + debugPrint('No current user found when getting rt_rw'); + return null; + } + + try { + debugPrint('Fetching rt_rw for user_id: ${user.id}'); + + // Coba ambil RT/RW dari tabel warga_desa + final userData = + await client + .from('warga_desa') + .select('rt_rw') + .eq('user_id', user.id) + .maybeSingle(); + + // Jika berhasil mendapatkan data + if (userData != null && userData.containsKey('rt_rw')) { + final rtRw = userData['rt_rw']?.toString(); + if (rtRw != null && rtRw.isNotEmpty) { + debugPrint('Found rt_rw: $rtRw'); + return rtRw; + } + } + + return null; + } catch (e) { + debugPrint('Error fetching user rt_rw: $e'); + return null; + } + } + + // Metode untuk mendapatkan kelurahan/desa dari tabel warga_desa berdasarkan user_id + Future getUserKelurahanDesa() async { + final user = currentUser; + if (user == null) { + debugPrint('No current user found when getting kelurahan_desa'); + return null; + } + + try { + debugPrint('Fetching kelurahan_desa for user_id: ${user.id}'); + + // Coba ambil kelurahan/desa dari tabel warga_desa + final userData = + await client + .from('warga_desa') + .select('kelurahan_desa') + .eq('user_id', user.id) + .maybeSingle(); + + // Jika berhasil mendapatkan data + if (userData != null && userData.containsKey('kelurahan_desa')) { + final kelurahanDesa = userData['kelurahan_desa']?.toString(); + if (kelurahanDesa != null && kelurahanDesa.isNotEmpty) { + debugPrint('Found kelurahan_desa: $kelurahanDesa'); + return kelurahanDesa; + } + } + + return null; + } catch (e) { + debugPrint('Error fetching user kelurahan_desa: $e'); + return null; + } + } + + // Metode untuk mendapatkan kecamatan dari tabel warga_desa berdasarkan user_id + Future getUserKecamatan() async { + final user = currentUser; + if (user == null) { + debugPrint('No current user found when getting kecamatan'); + return null; + } + + try { + debugPrint('Fetching kecamatan for user_id: ${user.id}'); + + // Coba ambil kecamatan dari tabel warga_desa + final userData = + await client + .from('warga_desa') + .select('kecamatan') + .eq('user_id', user.id) + .maybeSingle(); + + // Jika berhasil mendapatkan data + if (userData != null && userData.containsKey('kecamatan')) { + final kecamatan = userData['kecamatan']?.toString(); + if (kecamatan != null && kecamatan.isNotEmpty) { + debugPrint('Found kecamatan: $kecamatan'); + return kecamatan; + } + } + + return null; + } catch (e) { + debugPrint('Error fetching user kecamatan: $e'); + return null; + } + } + // Mendapatkan data sewa_aset berdasarkan status (misal: MENUNGGU PEMBAYARAN, PEMBAYARANAN DENDA) Future>> getSewaAsetByStatus( List statuses, @@ -507,28 +645,78 @@ class AuthProvider extends GetxService { } try { debugPrint( - 'Fetching sewa_aset for user_id: \\${user.id} with statuses: \\${statuses.join(', ')}', + 'Fetching sewa_aset for user_id: ${user.id} with statuses: ${statuses.join(', ')}', ); // Supabase expects the IN filter as a comma-separated string in parentheses final statusString = '(${statuses.map((s) => '"$s"').join(',')})'; + + // Get sewa_aset records filtered by user_id and status final response = await client .from('sewa_aset') .select('*') .eq('user_id', user.id) - .filter('status', 'in', statusString); - debugPrint('Fetched sewa_aset count: \\${response.length}'); - // Pastikan response adalah List + .filter('status', 'in', statusString) + .order('created_at', ascending: false); + + debugPrint('Fetched sewa_aset count: ${response.length}'); + + // Process the response to handle package data if (response is List) { - return response - .map>( - (item) => Map.from(item), - ) - .toList(); + final List> processedResponse = []; + + for (var item in response) { + final Map processedItem = Map.from( + item, + ); + + // If aset_id is null and paket_id is not null, fetch package data + if (item['aset_id'] == null && item['paket_id'] != null) { + final String paketId = item['paket_id']; + debugPrint( + 'Found rental with paket_id: $paketId, fetching package details', + ); + + try { + // Get package name from paket table + final paketResponse = + await client + .from('paket') + .select('nama') + .eq('id', paketId) + .maybeSingle(); + + if (paketResponse != null && paketResponse['nama'] != null) { + processedItem['nama_paket'] = paketResponse['nama']; + debugPrint('Found package name: ${paketResponse['nama']}'); + } + + // Get package photo from foto_aset table + final fotoResponse = + await client + .from('foto_aset') + .select('foto_aset') + .eq('id_paket', paketId) + .limit(1) + .maybeSingle(); + + if (fotoResponse != null && fotoResponse['foto_aset'] != null) { + processedItem['foto_paket'] = fotoResponse['foto_aset']; + debugPrint('Found package photo: ${fotoResponse['foto_aset']}'); + } + } catch (e) { + debugPrint('Error fetching package details: $e'); + } + } + + processedResponse.add(processedItem); + } + + return processedResponse; } else { return []; } } catch (e) { - debugPrint('Error fetching sewa_aset by status: \\${e.toString()}'); + debugPrint('Error fetching sewa_aset by status: ${e.toString()}'); return []; } } diff --git a/lib/app/data/providers/pesanan_provider.dart b/lib/app/data/providers/pesanan_provider.dart index 5730665..2379ded 100644 --- a/lib/app/data/providers/pesanan_provider.dart +++ b/lib/app/data/providers/pesanan_provider.dart @@ -9,6 +9,16 @@ class PesananProvider { final SupabaseClient _supabase = Supabase.instance.client; final _tableName = 'pesanan'; + // Method to clear any cached data + void clearCache() { + print('Clearing PesananProvider cached data'); + // Clear any cached order data or state + // This is useful when logging out to ensure no user data remains in memory + + // Note: Since this provider doesn't currently maintain any persistent cache variables, + // this method serves as a placeholder for future cache implementations + } + Future> getPesananByUserId(String userId) async { try { final response = await _supabase diff --git a/lib/app/modules/auth/controllers/auth_controller.dart b/lib/app/modules/auth/controllers/auth_controller.dart index 8c0323e..45b243d 100644 --- a/lib/app/modules/auth/controllers/auth_controller.dart +++ b/lib/app/modules/auth/controllers/auth_controller.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import '../../../data/providers/auth_provider.dart'; import '../../../routes/app_routes.dart'; +import 'dart:math'; class AuthController extends GetxController { final AuthProvider _authProvider = Get.find(); @@ -20,6 +21,10 @@ class AuthController extends GetxController { final RxString phoneNumber = ''.obs; final RxString selectedRole = 'WARGA'.obs; // Default role final RxString alamatLengkap = ''.obs; + final Rx tanggalLahir = Rx(null); + final RxString rtRw = ''.obs; + final RxString kelurahan = ''.obs; + final RxString kecamatan = ''.obs; // Form status final RxBool isLoading = false.obs; @@ -96,7 +101,7 @@ class AuthController extends GetxController { // Navigate based on role name if (roleName == null) { - _navigateToWargaDashboard(); // Default to warga if role name not found + await _checkWargaStatusAndNavigate(); // Default to warga if role name not found return; } @@ -105,6 +110,9 @@ class AuthController extends GetxController { _navigateToPetugasBumdesDashboard(); break; case 'WARGA': + // For WARGA role, check account status in warga_desa table + await _checkWargaStatusAndNavigate(); + break; default: _navigateToWargaDashboard(); break; @@ -114,6 +122,64 @@ class AuthController extends GetxController { } } + // Check warga status in warga_desa table and navigate accordingly + Future _checkWargaStatusAndNavigate() async { + try { + final user = _authProvider.currentUser; + if (user == null) { + errorMessage.value = 'Tidak dapat memperoleh data pengguna'; + return; + } + + // Get user data from warga_desa table + final userData = + await _authProvider.client + .from('warga_desa') + .select('status, keterangan') + .eq('user_id', user.id) + .maybeSingle(); + + if (userData == null) { + errorMessage.value = 'Data pengguna tidak ditemukan'; + return; + } + + final status = userData['status'] as String?; + + switch (status?.toLowerCase()) { + case 'active': + // Allow login for active users + _navigateToWargaDashboard(); + break; + case 'suspended': + // Show error for suspended users + final keterangan = + userData['keterangan'] as String? ?? 'Tidak ada keterangan'; + errorMessage.value = + 'Akun Anda dinonaktifkan oleh petugas. Keterangan: $keterangan'; + // Sign out the user + await _authProvider.signOut(); + break; + case 'pending': + // Show error for pending users + errorMessage.value = + 'Akun Anda sedang dalam proses verifikasi. Silakan tunggu hingga verifikasi selesai.'; + // Sign out the user + await _authProvider.signOut(); + break; + default: + errorMessage.value = 'Status akun tidak valid'; + // Sign out the user + await _authProvider.signOut(); + break; + } + } catch (e) { + errorMessage.value = 'Gagal memeriksa status akun: ${e.toString()}'; + // Sign out the user on error + await _authProvider.signOut(); + } + } + void _navigateToPetugasBumdesDashboard() { Get.offAllNamed(Routes.PETUGAS_BUMDES_DASHBOARD); } @@ -188,60 +254,69 @@ class AuthController extends GetxController { // Register user implementation Future registerUser() async { - // Validate all required fields - if (email.value.isEmpty || - password.value.isEmpty || - nik.value.isEmpty || - phoneNumber.value.isEmpty || - alamatLengkap.value.isEmpty) { - errorMessage.value = 'Semua field harus diisi'; + // Clear previous error messages + errorMessage.value = ''; + + // Validate form fields + if (!formKey.currentState!.validate()) { return; } - // Basic validation for email - if (!GetUtils.isEmail(email.value.trim())) { - errorMessage.value = 'Format email tidak valid'; - return; - } - - // Basic validation for password - if (password.value.length < 6) { - errorMessage.value = 'Password minimal 6 karakter'; - return; - } - - // Basic validation for NIK - if (nik.value.length != 16) { - errorMessage.value = 'NIK harus 16 digit'; - return; - } - - // Basic validation for phone number - if (!phoneNumber.value.startsWith('08') || phoneNumber.value.length < 10) { - errorMessage.value = - 'Nomor HP tidak valid (harus diawali 08 dan minimal 10 digit)'; + // Validate date of birth separately (since it's not a standard form field) + if (!validateDateOfBirth()) { return; } try { isLoading.value = true; - errorMessage.value = ''; - // Create user with Supabase - final response = await _authProvider.signUp( + // Format tanggal lahir to string (YYYY-MM-DD) + final formattedTanggalLahir = + tanggalLahir.value != null + ? '${tanggalLahir.value!.year}-${tanggalLahir.value!.month.toString().padLeft(2, '0')}-${tanggalLahir.value!.day.toString().padLeft(2, '0')}' + : ''; + + // Generate register_id with format REG-YYYY-1234567 + final currentYear = DateTime.now().year.toString(); + final randomDigits = _generateRandomDigits(7); // Generate 7 random digits + final registerId = 'REG-$currentYear-$randomDigits'; + + // 1. Register user with Supabase Auth and add role_id to metadata + final response = await _authProvider.client.auth.signUp( email: email.value.trim(), password: password.value, data: { - 'nik': nik.value.trim(), - 'phone_number': phoneNumber.value.trim(), - 'alamat_lengkap': alamatLengkap.value.trim(), - 'role': selectedRole.value, + 'role_id': + 'bb5360d5-8fd0-404e-8f6f-71ec4d8ad0ae', // Fixed role_id for WARGA }, ); + // Check if registration was successful if (response.user != null) { + // 2. Get the UID from the created auth user + final userId = response.user!.id; + + // 3. Insert user data into the warga_desa table + await _authProvider.client.from('warga_desa').insert({ + 'user_id': userId, + 'email': email.value.trim(), + 'nama_lengkap': nameController.text.trim(), + 'nik': nik.value.trim(), + 'status': 'pending', + 'tanggal_lahir': formattedTanggalLahir, + 'no_hp': phoneNumber.value.trim(), + 'rt_rw': rtRw.value.trim(), + 'kelurahan_desa': kelurahan.value.trim(), + 'kecamatan': kecamatan.value.trim(), + 'alamat': alamatLengkap.value.trim(), + 'register_id': registerId, // Add register_id to the warga_desa table + }); + // Registration successful - Get.offNamed(Routes.REGISTRATION_SUCCESS); + Get.offNamed( + Routes.REGISTRATION_SUCCESS, + arguments: {'register_id': registerId}, + ); } else { errorMessage.value = 'Gagal mendaftar. Silakan coba lagi.'; } @@ -252,4 +327,155 @@ class AuthController extends GetxController { isLoading.value = false; } } + + // Generate random digits of specified length + String _generateRandomDigits(int length) { + final random = Random(); + final buffer = StringBuffer(); + for (var i = 0; i < length; i++) { + buffer.write(random.nextInt(10)); + } + return buffer.toString(); + } + + // Validation methods + String? validateEmail(String? value) { + if (value == null || value.isEmpty) { + return 'Email tidak boleh kosong'; + } + if (!GetUtils.isEmail(value)) { + return 'Format email tidak valid'; + } + return null; + } + + String? validatePassword(String? value) { + if (value == null || value.isEmpty) { + return 'Password tidak boleh kosong'; + } + if (value.length < 8) { + return 'Password minimal 8 karakter'; + } + if (!value.contains(RegExp(r'[A-Z]'))) { + return 'Password harus memiliki minimal 1 huruf besar'; + } + if (!value.contains(RegExp(r'[0-9]'))) { + return 'Password harus memiliki minimal 1 angka'; + } + return null; + } + + String? validateConfirmPassword(String? value) { + if (value == null || value.isEmpty) { + return 'Konfirmasi password tidak boleh kosong'; + } + if (value != password.value) { + return 'Password tidak cocok'; + } + return null; + } + + String? validateName(String? value) { + if (value == null || value.isEmpty) { + return 'Nama lengkap tidak boleh kosong'; + } + if (value.length < 3) { + return 'Nama lengkap minimal 3 karakter'; + } + if (!RegExp(r"^[a-zA-Z\s\.]+$").hasMatch(value)) { + return 'Nama hanya boleh berisi huruf, spasi, titik, dan apostrof'; + } + return null; + } + + String? validateNIK(String? value) { + if (value == null || value.isEmpty) { + return 'NIK tidak boleh kosong'; + } + if (value.length != 16) { + return 'NIK harus 16 digit'; + } + if (!RegExp(r'^[0-9]+$').hasMatch(value)) { + return 'NIK hanya boleh berisi angka'; + } + return null; + } + + String? validatePhone(String? value) { + if (value == null || value.isEmpty) { + return 'No HP tidak boleh kosong'; + } + if (!value.startsWith('08')) { + return 'Nomor HP harus diawali dengan 08'; + } + if (value.length < 10 || value.length > 13) { + return 'Nomor HP harus antara 10-13 digit'; + } + if (!RegExp(r'^[0-9]+$').hasMatch(value)) { + return 'Nomor HP hanya boleh berisi angka'; + } + return null; + } + + String? validateRTRW(String? value) { + if (value == null || value.isEmpty) { + return 'RT/RW tidak boleh kosong'; + } + if (!RegExp(r'^\d{1,3}\/\d{1,3}$').hasMatch(value)) { + return 'Format RT/RW tidak valid (contoh: 001/002)'; + } + return null; + } + + String? validateKelurahan(String? value) { + if (value == null || value.isEmpty) { + return 'Kelurahan/Desa tidak boleh kosong'; + } + if (value.length < 3) { + return 'Kelurahan/Desa minimal 3 karakter'; + } + return null; + } + + String? validateKecamatan(String? value) { + if (value == null || value.isEmpty) { + return 'Kecamatan tidak boleh kosong'; + } + if (value.length < 3) { + return 'Kecamatan minimal 3 karakter'; + } + return null; + } + + String? validateAlamat(String? value) { + if (value == null || value.isEmpty) { + return 'Alamat lengkap tidak boleh kosong'; + } + if (value.length < 5) { + return 'Alamat terlalu pendek, minimal 5 karakter'; + } + return null; + } + + bool validateDateOfBirth() { + if (tanggalLahir.value == null) { + errorMessage.value = 'Tanggal lahir harus diisi'; + return false; + } + + // Check if user is at least 17 years old + final DateTime today = DateTime.now(); + final DateTime minimumAge = DateTime( + today.year - 17, + today.month, + today.day, + ); + + if (tanggalLahir.value!.isAfter(minimumAge)) { + errorMessage.value = 'Anda harus berusia minimal 17 tahun'; + return false; + } + + return true; + } } diff --git a/lib/app/modules/auth/views/login_view.dart b/lib/app/modules/auth/views/login_view.dart index a67b01f..b85ab2f 100644 --- a/lib/app/modules/auth/views/login_view.dart +++ b/lib/app/modules/auth/views/login_view.dart @@ -9,6 +9,7 @@ class LoginView extends GetView { @override Widget build(BuildContext context) { return Scaffold( + resizeToAvoidBottomInset: true, body: Stack( children: [ // Background gradient @@ -72,18 +73,21 @@ class LoginView extends GetView { ), ), - // Main content + // Main content with keyboard avoidance SafeArea( child: SingleChildScrollView( - physics: const BouncingScrollPhysics(), + physics: const ClampingScrollPhysics(), + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 24.0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const SizedBox(height: 50), + SizedBox(height: MediaQuery.of(context).size.height * 0.05), _buildHeader(), - const SizedBox(height: 40), + SizedBox(height: MediaQuery.of(context).size.height * 0.03), _buildLoginCard(), _buildRegisterLink(), const SizedBox(height: 30), @@ -103,12 +107,12 @@ class LoginView extends GetView { tag: 'logo', child: Image.asset( 'assets/images/logo.png', - width: 220, - height: 220, + width: 180, + height: 180, errorBuilder: (context, error, stackTrace) { return Icon( Icons.apartment_rounded, - size: 180, + size: 150, color: AppColors.primary, ); }, @@ -123,7 +127,7 @@ class LoginView extends GetView { shadowColor: AppColors.shadow, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), child: Padding( - padding: const EdgeInsets.all(28.0), + padding: const EdgeInsets.all(24.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -145,7 +149,7 @@ class LoginView extends GetView { fontWeight: FontWeight.w400, ), ), - const SizedBox(height: 32), + const SizedBox(height: 24), // Email field _buildInputLabel('Email'), @@ -204,7 +208,7 @@ class LoginView extends GetView { Obx( () => SizedBox( width: double.infinity, - height: 56, + height: 50, // Slightly smaller height child: ElevatedButton( onPressed: controller.isLoading.value ? null : controller.login, @@ -309,6 +313,16 @@ class LoginView extends GetView { keyboardType: keyboardType, obscureText: obscureText, style: TextStyle(fontSize: 16, color: AppColors.textPrimary), + textInputAction: + keyboardType == TextInputType.emailAddress + ? TextInputAction.next + : TextInputAction.done, + scrollPhysics: const ClampingScrollPhysics(), + onChanged: (_) { + if (controller.text.isNotEmpty) { + this.controller.errorMessage.value = ''; + } + }, decoration: InputDecoration( hintText: hintText, hintStyle: TextStyle(color: AppColors.textLight), diff --git a/lib/app/modules/auth/views/registration_success_view.dart b/lib/app/modules/auth/views/registration_success_view.dart index 123a4a9..f4b43cb 100644 --- a/lib/app/modules/auth/views/registration_success_view.dart +++ b/lib/app/modules/auth/views/registration_success_view.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:get/get.dart'; import '../../../theme/app_colors.dart'; +import 'package:flutter/rendering.dart'; class RegistrationSuccessView extends StatefulWidget { const RegistrationSuccessView({Key? key}) : super(key: key); @@ -15,10 +17,17 @@ class _RegistrationSuccessViewState extends State late AnimationController _animationController; late Animation _scaleAnimation; late Animation _fadeAnimation; + String? registerId; @override void initState() { super.initState(); + + // Get the registration ID from arguments + if (Get.arguments != null && Get.arguments is Map) { + registerId = Get.arguments['register_id'] as String?; + } + _animationController = AnimationController( vsync: this, duration: const Duration(milliseconds: 1000), @@ -215,7 +224,7 @@ class _RegistrationSuccessViewState extends State Container( padding: const EdgeInsets.symmetric(horizontal: 20), child: Text( - 'Akun Anda telah berhasil terdaftar. Silakan masuk dengan email dan password yang telah Anda daftarkan.', + 'Akun Anda telah berhasil terdaftar. Silahkan tunggu petugas untuk melakukan verifikasi data diri anda.', style: TextStyle( fontSize: 16, color: AppColors.textSecondary, @@ -224,6 +233,84 @@ class _RegistrationSuccessViewState extends State textAlign: TextAlign.center, ), ), + if (registerId != null) ...[ + const SizedBox(height: 24), + Text( + 'Kode Registrasi:', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + decoration: BoxDecoration( + color: AppColors.successLight, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppColors.success.withOpacity(0.5), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + registerId!, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.success, + letterSpacing: 1, + ), + ), + const SizedBox(width: 8), + IconButton( + icon: Icon( + Icons.copy, + size: 20, + color: AppColors.success, + ), + onPressed: () { + // Copy to clipboard + final data = ClipboardData(text: registerId!); + Clipboard.setData(data); + Get.snackbar( + 'Berhasil Disalin', + 'Kode registrasi telah disalin ke clipboard', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: AppColors.successLight, + colorText: AppColors.success, + margin: const EdgeInsets.all(16), + ); + }, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + splashRadius: 20, + ), + ], + ), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text( + 'Simpan kode registrasi ini untuk memeriksa status pendaftaran Anda.', + style: TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + fontStyle: FontStyle.italic, + ), + textAlign: TextAlign.center, + ), + ), + ], ], ), ); diff --git a/lib/app/modules/auth/views/registration_view.dart b/lib/app/modules/auth/views/registration_view.dart index 25c0388..30f0652 100644 --- a/lib/app/modules/auth/views/registration_view.dart +++ b/lib/app/modules/auth/views/registration_view.dart @@ -9,71 +9,93 @@ class RegistrationView extends GetView { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: AppColors.background, - body: SafeArea( - child: Stack( - children: [ - // Background gradient - Positioned( - top: -100, - right: -100, - child: Container( - width: 300, - height: 300, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: RadialGradient( - colors: [ - AppColors.primaryLight.withOpacity(0.2), - AppColors.background.withOpacity(0), - ], - stops: const [0.0, 1.0], - ), - ), + body: Stack( + children: [ + // Background gradient - same as login page + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topRight, + end: Alignment.bottomLeft, + colors: [ + AppColors.primaryLight.withOpacity(0.1), + AppColors.background, + AppColors.accentLight.withOpacity(0.1), + ], ), ), - Positioned( - bottom: -80, - left: -80, - child: Container( - width: 200, - height: 200, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: RadialGradient( - colors: [ - AppColors.accent.withOpacity(0.15), - AppColors.background.withOpacity(0), - ], - stops: const [0.0, 1.0], - ), - ), + ), + + // Pattern overlay - same as login page + Opacity( + opacity: 0.03, + child: Container( + decoration: BoxDecoration( + color: Colors.blue[50], // Temporary solid color ), ), - // Content - SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildBackButton(), - const SizedBox(height: 20), - _buildHeader(), - const SizedBox(height: 24), - _buildRegistrationForm(), - const SizedBox(height: 32), - _buildRegisterButton(), - const SizedBox(height: 24), - _buildImportantInfo(), - const SizedBox(height: 24), - _buildLoginLink(), + ), + + // Accent circles - same as login page + Positioned( + top: -40, + right: -20, + child: Container( + width: 150, + height: 150, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + AppColors.primary.withOpacity(0.2), + Colors.transparent, ], ), ), ), - ], - ), + ), + Positioned( + bottom: -50, + left: -30, + child: Container( + width: 180, + height: 180, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + AppColors.accent.withOpacity(0.2), + Colors.transparent, + ], + ), + ), + ), + ), + + // Main content + SafeArea( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 20), + _buildBackButton(), + const SizedBox(height: 20), + _buildHeader(), + const SizedBox(height: 30), + _buildRegistrationCard(), + _buildLoginLink(), + _buildCheckStatusLink(), + const SizedBox(height: 30), + ], + ), + ), + ), + ), + ], ), ); } @@ -87,7 +109,7 @@ class RegistrationView extends GetView { child: Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: AppColors.surface, + color: Colors.white, shape: BoxShape.circle, boxShadow: [ BoxShadow( @@ -97,137 +119,430 @@ class RegistrationView extends GetView { ), ], ), - child: const Icon( - Icons.arrow_back, - size: 20, - color: AppColors.primary, - ), + child: Icon(Icons.arrow_back, size: 20, color: AppColors.primary), ), ), ); } Widget _buildHeader() { - return Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Hero( - tag: 'logo', - child: Container( - width: 80, - height: 80, - decoration: BoxDecoration( - color: AppColors.primarySoft, - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: AppColors.primary.withOpacity(0.2), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - ), - child: Icon( + return Center( + child: Hero( + tag: 'logo', + child: Image.asset( + 'assets/images/logo.png', + width: 150, + height: 150, + errorBuilder: (context, error, stackTrace) { + return Icon( Icons.apartment_rounded, - size: 40, + size: 120, color: AppColors.primary, - ), - ), + ); + }, ), - const SizedBox(height: 24), - Text( - 'Daftar Akun', - style: TextStyle( - fontSize: 28, - fontWeight: FontWeight.bold, - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 10), - Text( - 'Lengkapi data berikut untuk mendaftar', - style: TextStyle(fontSize: 16, color: AppColors.textSecondary), - textAlign: TextAlign.center, - ), - ], - ); - } - - Widget _buildImportantInfo() { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.warningLight, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.warning.withOpacity(0.3)), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: AppColors.warning.withOpacity(0.2), - shape: BoxShape.circle, - ), - child: Icon(Icons.info_outline, size: 20, color: AppColors.warning), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Informasi Penting', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: AppColors.warning, - ), - ), - const SizedBox(height: 4), - Text( - 'Setelah pendaftaran lengkapi data diri untuk dapat melakukan sewa', - style: TextStyle( - fontSize: 13, - color: AppColors.textPrimary, - height: 1.4, - ), - ), - ], - ), - ), - ], ), ); } - Widget _buildRegistrationForm() { - return Form( - key: controller.formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildInputLabel('Email'), - _buildEmailField(), - const SizedBox(height: 16), - _buildInputLabel('Password'), - _buildPasswordField(), - const SizedBox(height: 16), - _buildInputLabel('Konfirmasi Password'), - _buildConfirmPasswordField(), - const SizedBox(height: 16), - _buildInputLabel('Nama Lengkap'), - _buildNameField(), - const SizedBox(height: 16), - _buildInputLabel('No HP'), - _buildPhoneField(), - const SizedBox(height: 16), - _buildInputLabel('Alamat Lengkap'), - _buildAlamatField(), - const SizedBox(height: 16), - // Removed: NIK, No HP, and Dropdown Daftar Sebagai - ], + Widget _buildRegistrationCard() { + return Card( + elevation: 4, + shadowColor: AppColors.shadow, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + child: Padding( + padding: const EdgeInsets.all(28.0), + child: Form( + key: controller.formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Welcome text + Text( + 'Daftar Akun', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + Text( + 'Lengkapi data berikut untuk mendaftar', + style: TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(height: 24), + + // Account Credentials Section + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.primaryLight.withOpacity(0.05), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: AppColors.primaryLight.withOpacity(0.2), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.account_circle_outlined, + color: AppColors.primary, + size: 20, + ), + const SizedBox(width: 8), + Text( + 'Informasi Akun', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ], + ), + const SizedBox(height: 16), + + // Email field + _buildInputLabel('Email'), + const SizedBox(height: 8), + _buildTextField( + hintText: 'Masukkan email anda', + prefixIcon: Icons.email_outlined, + keyboardType: TextInputType.emailAddress, + onChanged: (value) => controller.email.value = value, + validator: controller.validateEmail, + ), + const SizedBox(height: 16), + + // Password field + _buildInputLabel('Password'), + const SizedBox(height: 8), + Obx( + () => _buildTextField( + hintText: 'Masukkan password anda', + prefixIcon: Icons.lock_outline, + obscureText: !controller.isPasswordVisible.value, + onChanged: (value) => controller.password.value = value, + validator: controller.validatePassword, + suffixIcon: IconButton( + icon: Icon( + controller.isPasswordVisible.value + ? Icons.visibility + : Icons.visibility_off, + color: AppColors.iconGrey, + ), + onPressed: controller.togglePasswordVisibility, + ), + ), + ), + const SizedBox(height: 16), + + // Confirm Password field + _buildInputLabel('Konfirmasi Password'), + const SizedBox(height: 8), + Obx( + () => _buildTextField( + controller: controller.confirmPasswordController, + hintText: 'Masukkan ulang password anda', + prefixIcon: Icons.lock_outline, + obscureText: !controller.isConfirmPasswordVisible.value, + validator: controller.validateConfirmPassword, + suffixIcon: IconButton( + icon: Icon( + controller.isConfirmPasswordVisible.value + ? Icons.visibility + : Icons.visibility_off, + color: AppColors.iconGrey, + ), + onPressed: controller.toggleConfirmPasswordVisibility, + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 24), + + // Personal Data Section + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.accentLight.withOpacity(0.05), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: AppColors.accentLight.withOpacity(0.2), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.person_outline, + color: AppColors.accent, + size: 20, + ), + const SizedBox(width: 8), + Text( + 'Data Diri', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.accent, + ), + ), + ], + ), + const SizedBox(height: 16), + + // Name field + _buildInputLabel('Nama Lengkap'), + const SizedBox(height: 8), + _buildTextField( + controller: controller.nameController, + hintText: 'Masukkan nama lengkap anda', + prefixIcon: Icons.person_outline, + validator: controller.validateName, + ), + const SizedBox(height: 16), + + // NIK field + _buildInputLabel('NIK'), + const SizedBox(height: 8), + _buildTextField( + hintText: 'Masukkan NIK anda', + prefixIcon: Icons.credit_card_outlined, + keyboardType: TextInputType.number, + onChanged: (value) => controller.nik.value = value, + validator: (value) { + if (value == null || value.isEmpty) { + return 'NIK tidak boleh kosong'; + } + if (value.length != 16) { + return 'NIK harus 16 digit'; + } + if (!RegExp(r'^[0-9]+$').hasMatch(value)) { + return 'NIK hanya boleh berisi angka'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Tanggal Lahir field + _buildInputLabel('Tanggal Lahir'), + const SizedBox(height: 8), + Builder(builder: (context) => _buildDateField(context)), + const SizedBox(height: 16), + + // Phone field + _buildInputLabel('No HP'), + const SizedBox(height: 8), + _buildTextField( + hintText: 'Masukkan nomor HP anda', + prefixIcon: Icons.phone_outlined, + keyboardType: TextInputType.phone, + onChanged: + (value) => controller.phoneNumber.value = value, + validator: (value) { + if (value == null || value.isEmpty) { + return 'No HP tidak boleh kosong'; + } + if (!value.startsWith('08')) { + return 'Nomor HP harus diawali dengan 08'; + } + if (value.length < 10 || value.length > 13) { + return 'Nomor HP harus antara 10-13 digit'; + } + if (!RegExp(r'^[0-9]+$').hasMatch(value)) { + return 'Nomor HP hanya boleh berisi angka'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // RT/RW field + _buildInputLabel('RT/RW'), + const SizedBox(height: 8), + _buildTextField( + hintText: 'Contoh: 001/002', + prefixIcon: Icons.home_work_outlined, + onChanged: (value) => controller.rtRw.value = value, + validator: (value) { + if (value == null || value.isEmpty) { + return 'RT/RW tidak boleh kosong'; + } + if (!RegExp(r'^\d{1,3}\/\d{1,3}$').hasMatch(value)) { + return 'Format RT/RW tidak valid (contoh: 001/002)'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Kelurahan/Desa field + _buildInputLabel('Kelurahan/Desa'), + const SizedBox(height: 8), + _buildTextField( + hintText: 'Masukkan kelurahan/desa anda', + prefixIcon: Icons.location_city_outlined, + onChanged: (value) => controller.kelurahan.value = value, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Kelurahan/Desa tidak boleh kosong'; + } + if (value.length < 3) { + return 'Kelurahan/Desa minimal 3 karakter'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Kecamatan field + _buildInputLabel('Kecamatan'), + const SizedBox(height: 8), + _buildTextField( + hintText: 'Masukkan kecamatan anda', + prefixIcon: Icons.location_on_outlined, + onChanged: (value) => controller.kecamatan.value = value, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Kecamatan tidak boleh kosong'; + } + if (value.length < 3) { + return 'Kecamatan minimal 3 karakter'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Address field + _buildInputLabel('Alamat Lengkap'), + const SizedBox(height: 8), + _buildTextField( + hintText: 'Masukkan alamat lengkap anda', + prefixIcon: Icons.home_outlined, + onChanged: + (value) => controller.alamatLengkap.value = value, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Alamat lengkap tidak boleh kosong'; + } + if (value.length < 5) { + return 'Alamat terlalu pendek, minimal 5 karakter'; + } + return null; + }, + ), + ], + ), + ), + + const SizedBox(height: 24), + + // Important info + _buildImportantInfo(), + const SizedBox(height: 24), + + // Register button + Obx( + () => SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton( + onPressed: + controller.isLoading.value + ? null + : controller.registerUser, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: AppColors.buttonText, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: controller.isLoading.value ? 0 : 2, + shadowColor: AppColors.primary.withOpacity(0.4), + ), + child: + controller.isLoading.value + ? const SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Daftar', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + ), + const SizedBox(width: 8), + const Icon(Icons.arrow_forward, size: 18), + ], + ), + ), + ), + ), + + // Error message + Obx( + () => + controller.errorMessage.value.isNotEmpty + ? Container( + margin: const EdgeInsets.only(top: 16), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.errorLight, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.error_outline, + color: AppColors.error, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + controller.errorMessage.value, + style: TextStyle( + color: AppColors.error, + fontSize: 13, + ), + ), + ), + ], + ), + ) + : const SizedBox.shrink(), + ), + ], + ), + ), ), ); } @@ -236,248 +551,397 @@ class RegistrationView extends GetView { return Text( label, style: TextStyle( - fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary, + fontSize: 15, ), ); } - Widget _buildEmailField() { + Widget _buildTextField({ + TextEditingController? controller, + required String hintText, + required IconData prefixIcon, + TextInputType keyboardType = TextInputType.text, + bool obscureText = false, + Widget? suffixIcon, + Function(String)? onChanged, + String? Function(String?)? validator, + }) { + return TextFormField( + controller: controller, + keyboardType: keyboardType, + obscureText: obscureText, + onChanged: onChanged, + validator: validator, + style: TextStyle(fontSize: 16, color: AppColors.textPrimary), + decoration: InputDecoration( + hintText: hintText, + hintStyle: TextStyle(color: AppColors.textLight), + prefixIcon: Icon(prefixIcon, color: AppColors.iconGrey, size: 22), + suffixIcon: suffixIcon, + filled: true, + fillColor: AppColors.inputBackground, + contentPadding: const EdgeInsets.symmetric(vertical: 16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: AppColors.primary, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: AppColors.error, width: 1.5), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: AppColors.error, width: 1.5), + ), + ), + ); + } + + Widget _buildImportantInfo() { return Container( + padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: AppColors.surface, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: AppColors.shadow, - blurRadius: 8, - offset: const Offset(0, 2), + color: AppColors.warningLight, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, size: 20, color: AppColors.warning), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Setelah melakukan pendaftaran, silahkan simpan kode registrasi untuk cek status pendaftaran', + style: TextStyle( + fontSize: 13, + color: AppColors.textPrimary, + height: 1.4, + ), + ), ), ], ), - child: TextField( - onChanged: (value) => controller.email.value = value, - keyboardType: TextInputType.emailAddress, - style: TextStyle(fontSize: 16, color: AppColors.textPrimary), - decoration: InputDecoration( - hintText: 'Masukkan email anda', - hintStyle: TextStyle(color: AppColors.textLight), - prefixIcon: Icon(Icons.email_outlined, color: AppColors.primary), - border: InputBorder.none, - contentPadding: const EdgeInsets.symmetric(vertical: 16), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide(color: AppColors.primary, width: 1.5), - ), - ), - ), - ); - } - - Widget _buildPasswordField() { - return Obx( - () => Container( - decoration: BoxDecoration( - color: AppColors.surface, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: AppColors.shadow, - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: TextField( - onChanged: (value) => controller.password.value = value, - obscureText: !controller.isPasswordVisible.value, - style: TextStyle(fontSize: 16, color: AppColors.textPrimary), - decoration: InputDecoration( - hintText: 'Masukkan password anda', - hintStyle: TextStyle(color: AppColors.textLight), - prefixIcon: Icon(Icons.lock_outlined, color: AppColors.primary), - suffixIcon: IconButton( - icon: Icon( - controller.isPasswordVisible.value - ? Icons.visibility - : Icons.visibility_off, - color: AppColors.iconGrey, - ), - onPressed: controller.togglePasswordVisibility, - ), - border: InputBorder.none, - contentPadding: const EdgeInsets.symmetric(vertical: 16), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide(color: AppColors.primary, width: 1.5), - ), - ), - ), - ), - ); - } - - Widget _buildConfirmPasswordField() { - return Obx( - () => TextFormField( - controller: controller.confirmPasswordController, - obscureText: !controller.isConfirmPasswordVisible.value, - decoration: InputDecoration( - hintText: 'Masukkan ulang password anda', - suffixIcon: IconButton( - icon: Icon( - controller.isConfirmPasswordVisible.value - ? Icons.visibility - : Icons.visibility_off, - ), - onPressed: controller.toggleConfirmPasswordVisibility, - ), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14, - ), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Konfirmasi password tidak boleh kosong'; - } - if (value != controller.passwordController.text) { - return 'Password tidak cocok'; - } - return null; - }, - ), - ); - } - - Widget _buildNameField() { - return TextFormField( - controller: controller.nameController, - decoration: InputDecoration( - hintText: 'Masukkan nama lengkap anda', - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14, - ), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Nama lengkap tidak boleh kosong'; - } - return null; - }, - ); - } - - Widget _buildPhoneField() { - return TextFormField( - keyboardType: TextInputType.phone, - decoration: InputDecoration( - hintText: 'Masukkan nomor HP anda', - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14, - ), - ), - onChanged: (value) => controller.phoneNumber.value = value, - validator: (value) { - if (value == null || value.isEmpty) { - return 'No HP tidak boleh kosong'; - } - if (!value.startsWith('08') || value.length < 10) { - return 'Nomor HP tidak valid (harus diawali 08 dan minimal 10 digit)'; - } - return null; - }, - ); - } - - Widget _buildAlamatField() { - return TextFormField( - decoration: InputDecoration( - hintText: 'Masukkan alamat lengkap anda', - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14, - ), - ), - onChanged: (value) => controller.alamatLengkap.value = value, - validator: (value) { - if (value == null || value.isEmpty) { - return 'Alamat lengkap tidak boleh kosong'; - } - return null; - }, - ); - } - - Widget _buildRegisterButton() { - return Obx( - () => ElevatedButton( - onPressed: controller.isLoading.value ? null : controller.registerUser, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primary, - foregroundColor: AppColors.buttonText, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - elevation: 0, - disabledBackgroundColor: AppColors.primary.withOpacity(0.6), - ), - child: - controller.isLoading.value - ? const SizedBox( - height: 24, - width: 24, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2, - ), - ) - : const Text( - 'Daftar', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), - ), - ), ); } Widget _buildLoginLink() { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'Sudah punya akun? ', - style: TextStyle(color: AppColors.textSecondary, fontSize: 14), - ), - GestureDetector( - onTap: () { - Get.back(); // Back to login page - }, - child: Text( - 'Masuk', - style: TextStyle( - color: AppColors.primary, - fontWeight: FontWeight.bold, - fontSize: 14, + return Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Sudah punya akun?", + style: TextStyle(color: AppColors.textSecondary), + ), + TextButton( + onPressed: () => Get.back(), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + child: Text( + 'Masuk', + style: TextStyle( + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), ), ), + ], + ), + ); + } + + // Add button to check registration status + Widget _buildCheckStatusLink() { + return Center( + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: TextButton( + onPressed: () => _showCheckStatusDialog(Get.context!), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.search, size: 18, color: AppColors.accent), + const SizedBox(width: 8), + Text( + 'Cek Status Pendaftaran', + style: TextStyle( + fontWeight: FontWeight.bold, + color: AppColors.accent, + fontSize: 14, + ), + ), + ], + ), ), - ], + ), + ); + } + + // Show dialog to check registration status + void _showCheckStatusDialog(BuildContext context) { + final TextEditingController codeController = TextEditingController(); + final TextEditingController identifierController = TextEditingController(); + + showDialog( + context: context, + builder: (BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Dialog header + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.accent.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon( + Icons.search, + color: AppColors.accent, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Cek Status Pendaftaran', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + splashRadius: 24, + ), + ], + ), + const SizedBox(height: 16), + + // Registration code field + Text( + 'Kode Registrasi', + style: TextStyle( + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + fontSize: 14, + ), + ), + const SizedBox(height: 8), + TextField( + controller: codeController, + decoration: InputDecoration( + hintText: 'Masukkan kode registrasi', + hintStyle: TextStyle(color: AppColors.textLight), + prefixIcon: Icon( + Icons.confirmation_number_outlined, + color: AppColors.iconGrey, + size: 22, + ), + filled: true, + fillColor: AppColors.inputBackground, + contentPadding: const EdgeInsets.symmetric(vertical: 16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide( + color: AppColors.accent, + width: 1.5, + ), + ), + ), + ), + const SizedBox(height: 16), + + // Email/NIK/Phone field + Text( + 'Email/NIK/No HP', + style: TextStyle( + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + fontSize: 14, + ), + ), + const SizedBox(height: 8), + TextField( + controller: identifierController, + decoration: InputDecoration( + hintText: 'Masukkan email, NIK, atau no HP', + hintStyle: TextStyle(color: AppColors.textLight), + prefixIcon: Icon( + Icons.person_outline, + color: AppColors.iconGrey, + size: 22, + ), + filled: true, + fillColor: AppColors.inputBackground, + contentPadding: const EdgeInsets.symmetric(vertical: 16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide( + color: AppColors.accent, + width: 1.5, + ), + ), + ), + ), + const SizedBox(height: 24), + + // Submit button + SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton( + onPressed: () { + // TODO: Implement check status functionality + Navigator.pop(context); + + // Show a mock response for now + Get.snackbar( + 'Status Pendaftaran', + 'Status pendaftaran sedang diproses. Silakan tunggu konfirmasi lebih lanjut.', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: AppColors.infoLight, + colorText: AppColors.info, + icon: Icon(Icons.info_outline, color: AppColors.info), + duration: const Duration(seconds: 4), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.accent, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + elevation: 0, + ), + child: const Text( + 'Cek Status', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } + + // New method to build date picker field + Widget _buildDateField(BuildContext context) { + return GestureDetector( + onTap: () async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: DateTime.now().subtract( + const Duration(days: 365 * 18), + ), // Default to 18 years ago + firstDate: DateTime(1940), + lastDate: DateTime.now(), + builder: (BuildContext context, Widget? 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.tanggalLahir.value = picked; + } + }, + child: Obx(() { + final selectedDate = controller.tanggalLahir.value; + final displayText = + selectedDate != null + ? '${selectedDate.day.toString().padLeft(2, '0')}-${selectedDate.month.toString().padLeft(2, '0')}-${selectedDate.year}' + : 'Pilih tanggal lahir'; + + return Container( + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + color: AppColors.inputBackground, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Icon( + Icons.calendar_today_outlined, + color: AppColors.iconGrey, + size: 22, + ), + ), + Text( + displayText, + style: TextStyle( + fontSize: 16, + color: + selectedDate != null + ? AppColors.textPrimary + : AppColors.textLight, + ), + ), + ], + ), + ); + }), ); } } diff --git a/lib/app/modules/petugas_bumdes/bindings/petugas_akun_bank_binding.dart b/lib/app/modules/petugas_bumdes/bindings/petugas_akun_bank_binding.dart new file mode 100644 index 0000000..1ba4ac6 --- /dev/null +++ b/lib/app/modules/petugas_bumdes/bindings/petugas_akun_bank_binding.dart @@ -0,0 +1,16 @@ +import 'package:get/get.dart'; +import '../controllers/petugas_akun_bank_controller.dart'; +import '../../../data/providers/aset_provider.dart'; + +class PetugasAkunBankBinding extends Bindings { + @override + void dependencies() { + // Register AsetProvider if not already registered + if (!Get.isRegistered()) { + Get.put(AsetProvider(), permanent: true); + } + + // Register PetugasAkunBankController + Get.lazyPut(() => PetugasAkunBankController()); + } +} diff --git a/lib/app/modules/petugas_bumdes/bindings/petugas_detail_penyewa_binding.dart b/lib/app/modules/petugas_bumdes/bindings/petugas_detail_penyewa_binding.dart new file mode 100644 index 0000000..cc7e597 --- /dev/null +++ b/lib/app/modules/petugas_bumdes/bindings/petugas_detail_penyewa_binding.dart @@ -0,0 +1,11 @@ +import 'package:get/get.dart'; +import '../controllers/petugas_detail_penyewa_controller.dart'; + +class PetugasDetailPenyewaBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut( + () => PetugasDetailPenyewaController(), + ); + } +} diff --git a/lib/app/modules/petugas_bumdes/bindings/petugas_laporan_binding.dart b/lib/app/modules/petugas_bumdes/bindings/petugas_laporan_binding.dart new file mode 100644 index 0000000..1b5aa89 --- /dev/null +++ b/lib/app/modules/petugas_bumdes/bindings/petugas_laporan_binding.dart @@ -0,0 +1,16 @@ +import 'package:get/get.dart'; +import '../controllers/petugas_laporan_controller.dart'; +import '../../../data/providers/aset_provider.dart'; + +class PetugasLaporanBinding extends Bindings { + @override + void dependencies() { + // Register AsetProvider if not already registered + if (!Get.isRegistered()) { + Get.put(AsetProvider(), permanent: true); + } + + // Register PetugasLaporanController + Get.lazyPut(() => PetugasLaporanController()); + } +} diff --git a/lib/app/modules/petugas_bumdes/bindings/petugas_penyewa_binding.dart b/lib/app/modules/petugas_bumdes/bindings/petugas_penyewa_binding.dart new file mode 100644 index 0000000..91e569f --- /dev/null +++ b/lib/app/modules/petugas_bumdes/bindings/petugas_penyewa_binding.dart @@ -0,0 +1,12 @@ +import 'package:get/get.dart'; +import '../controllers/petugas_penyewa_controller.dart'; + +class PetugasPenyewaBinding extends Bindings { + @override + void dependencies() { + Get.put( + PetugasPenyewaController(), + permanent: true, + ); + } +} diff --git a/lib/app/modules/petugas_bumdes/controllers/petugas_akun_bank_controller.dart b/lib/app/modules/petugas_bumdes/controllers/petugas_akun_bank_controller.dart new file mode 100644 index 0000000..0132944 --- /dev/null +++ b/lib/app/modules/petugas_bumdes/controllers/petugas_akun_bank_controller.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../../../data/providers/aset_provider.dart'; + +class PetugasAkunBankController extends GetxController { + final AsetProvider asetProvider = Get.find(); + + // Observable variables + final isLoading = true.obs; + final bankAccounts = >[].obs; + final errorMessage = ''.obs; + + @override + void onInit() { + super.onInit(); + loadBankAccounts(); + } + + // Load bank accounts from the database + Future loadBankAccounts() async { + try { + isLoading.value = true; + errorMessage.value = ''; + + debugPrint('🏦 Loading bank accounts...'); + + // Fetch all bank accounts from the database + final response = await asetProvider.client + .from('akun_bank') + .select('id, nama_bank, nama_akun, no_rekening') + .order('nama_bank', ascending: true); + + if (response is List) { + bankAccounts.value = List>.from(response); + debugPrint('✅ Loaded ${bankAccounts.length} bank accounts'); + } else { + bankAccounts.value = []; + errorMessage.value = 'Failed to load bank accounts'; + debugPrint('❌ Failed to load bank accounts: Invalid response format'); + } + } catch (e) { + errorMessage.value = 'Error loading bank accounts: $e'; + debugPrint('❌ Error loading bank accounts: $e'); + } finally { + isLoading.value = false; + } + } + + // Add a new bank account + Future addBankAccount(Map accountData) async { + try { + debugPrint('🏦 Adding new bank account: ${accountData['nama_bank']}'); + + final response = await asetProvider.client + .from('akun_bank') + .insert(accountData) + .select('id'); + + if (response is List && response.isNotEmpty) { + debugPrint('✅ Bank account added successfully'); + await loadBankAccounts(); // Reload the list + return true; + } else { + debugPrint('❌ Failed to add bank account: No response'); + return false; + } + } catch (e) { + debugPrint('❌ Error adding bank account: $e'); + return false; + } + } + + // Update an existing bank account + Future updateBankAccount( + String id, + Map accountData, + ) async { + try { + debugPrint('🏦 Updating bank account ID: $id'); + + final response = await asetProvider.client + .from('akun_bank') + .update(accountData) + .eq('id', id); + + debugPrint('✅ Bank account updated successfully'); + await loadBankAccounts(); // Reload the list + return true; + } catch (e) { + debugPrint('❌ Error updating bank account: $e'); + return false; + } + } + + // Delete a bank account + Future deleteBankAccount(String id) async { + try { + debugPrint('🏦 Deleting bank account ID: $id'); + + final response = await asetProvider.client + .from('akun_bank') + .delete() + .eq('id', id); + + debugPrint('✅ Bank account deleted successfully'); + await loadBankAccounts(); // Reload the list + return true; + } catch (e) { + debugPrint('❌ Error deleting bank account: $e'); + return false; + } + } +} diff --git a/lib/app/modules/petugas_bumdes/controllers/petugas_aset_controller.dart b/lib/app/modules/petugas_bumdes/controllers/petugas_aset_controller.dart index 5cb73b4..d557e53 100644 --- a/lib/app/modules/petugas_bumdes/controllers/petugas_aset_controller.dart +++ b/lib/app/modules/petugas_bumdes/controllers/petugas_aset_controller.dart @@ -222,8 +222,63 @@ class PetugasAsetController extends GetxController { } // Delete an asset - void deleteAset(String id) { - asetList.removeWhere((aset) => aset['id'] == id); - applyFilters(); + Future deleteAset(String id) async { + try { + debugPrint('🗑️ Starting deletion process for asset ID: $id'); + + // Show loading indicator + Get.dialog( + const Center(child: CircularProgressIndicator()), + barrierDismissible: false, + ); + + // Call the provider to delete the asset + final success = await _asetProvider.deleteAset(id); + + // Close the loading dialog + Get.back(); + + if (success) { + // Remove the asset from our local list + asetList.removeWhere((aset) => aset['id'] == id); + // Apply filters to update the UI + applyFilters(); + + // Show success message + Get.snackbar( + 'Sukses', + 'Aset berhasil dihapus', + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.green, + colorText: Colors.white, + ); + return true; + } else { + // Show error message + Get.snackbar( + 'Gagal', + 'Terjadi kesalahan saat menghapus aset', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + return false; + } + } catch (e) { + // Close the loading dialog if still open + if (Get.isDialogOpen ?? false) { + Get.back(); + } + + // Show error message + Get.snackbar( + 'Error', + 'Gagal menghapus aset: ${e.toString()}', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + return false; + } } } diff --git a/lib/app/modules/petugas_bumdes/controllers/petugas_bumdes_dashboard_controller.dart b/lib/app/modules/petugas_bumdes/controllers/petugas_bumdes_dashboard_controller.dart index 9ddc359..e8a2737 100644 --- a/lib/app/modules/petugas_bumdes/controllers/petugas_bumdes_dashboard_controller.dart +++ b/lib/app/modules/petugas_bumdes/controllers/petugas_bumdes_dashboard_controller.dart @@ -5,6 +5,8 @@ import '../../../services/sewa_service.dart'; import '../../../services/service_manager.dart'; import '../../../data/models/pembayaran_model.dart'; import '../../../services/pembayaran_service.dart'; +import '../../../data/providers/aset_provider.dart'; +import '../../../data/providers/pesanan_provider.dart'; class PetugasBumdesDashboardController extends GetxController { AuthProvider? _authProvider; @@ -16,38 +18,45 @@ class PetugasBumdesDashboardController extends GetxController { final userName = ''.obs; // Revenue Statistics - final totalPendapatanBulanIni = 'Rp 8.500.000'.obs; - final totalPendapatanBulanLalu = 'Rp 7.200.000'.obs; - final persentaseKenaikan = '18%'.obs; + final totalPendapatanBulanIni = ''.obs; + final totalPendapatanBulanLalu = ''.obs; + final persentaseKenaikan = ''.obs; final isKenaikanPositif = true.obs; // Revenue by Category - final pendapatanSewa = 'Rp 5.200.000'.obs; - final persentaseSewa = 100.obs; + final pendapatanSewa = ''.obs; + final persentaseSewa = 0.obs; // Revenue Trends (last 6 months) final trendPendapatan = [].obs; // 6 bulan terakhir // Status Counters for Sewa Aset - final terlaksanaCount = 5.obs; - final dijadwalkanCount = 1.obs; - final aktifCount = 1.obs; - final dibatalkanCount = 3.obs; + final terlaksanaCount = 0.obs; + final dijadwalkanCount = 0.obs; + final aktifCount = 0.obs; + final dibatalkanCount = 0.obs; // Additional Sewa Aset Status Counters - final menungguPembayaranCount = 2.obs; - final periksaPembayaranCount = 1.obs; - final diterimaCount = 3.obs; - final pembayaranDendaCount = 1.obs; + final menungguPembayaranCount = 0.obs; + final periksaPembayaranCount = 0.obs; + final diterimaCount = 0.obs; + final pembayaranDendaCount = 0.obs; final periksaPembayaranDendaCount = 0.obs; - final selesaiCount = 4.obs; + final selesaiCount = 0.obs; // Status counts for Sewa - final pengajuanSewaCount = 5.obs; - final pemasanganCountSewa = 3.obs; - final sewaAktifCount = 10.obs; - final tagihanAktifCountSewa = 7.obs; - final periksaPembayaranCountSewa = 2.obs; + final pengajuanSewaCount = 0.obs; + final pemasanganCountSewa = 0.obs; + final sewaAktifCount = 0.obs; + final tagihanAktifCountSewa = 0.obs; + final periksaPembayaranCountSewa = 0.obs; + + // Tenant (Penyewa) Statistics + final penyewaPendingCount = 0.obs; + final penyewaActiveCount = 0.obs; + final penyewaSuspendedCount = 0.obs; + final penyewaTotalCount = 0.obs; + final isPenyewaStatsLoading = true.obs; // Statistik pendapatan final totalPendapatan = 0.obs; @@ -76,6 +85,7 @@ class PetugasBumdesDashboardController extends GetxController { print('\u2705 PetugasBumdesDashboardController initialized successfully'); countSewaByStatus(); fetchPembayaranStats(); + fetchPenyewaStats(); } Future countSewaByStatus() async { @@ -172,6 +182,55 @@ class PetugasBumdesDashboardController extends GetxController { } } + Future fetchPenyewaStats() async { + isPenyewaStatsLoading.value = true; + try { + if (_authProvider == null || _authProvider!.client == null) { + print('Auth provider or client is null'); + return; + } + + final data = await _authProvider!.client + .from('warga_desa') + .select('status, user_id') + .not('user_id', 'is', null); + + if (data != null) { + final List penyewaList = data as List; + + // Count penyewa by status + penyewaPendingCount.value = + penyewaList + .where( + (p) => p['status']?.toString().toLowerCase() == 'pending', + ) + .length; + + penyewaActiveCount.value = + penyewaList + .where((p) => p['status']?.toString().toLowerCase() == 'active') + .length; + + penyewaSuspendedCount.value = + penyewaList + .where( + (p) => p['status']?.toString().toLowerCase() == 'suspended', + ) + .length; + + penyewaTotalCount.value = penyewaList.length; + + print( + 'Penyewa stats - Pending: ${penyewaPendingCount.value}, Active: ${penyewaActiveCount.value}, Suspended: ${penyewaSuspendedCount.value}, Total: ${penyewaTotalCount.value}', + ); + } + } catch (e) { + print('Error fetching penyewa stats: $e'); + } finally { + isPenyewaStatsLoading.value = false; + } + } + void changeTab(int index) { try { currentTabIndex.value = index; @@ -194,6 +253,10 @@ class PetugasBumdesDashboardController extends GetxController { // Navigate to Sewa page navigateToSewa(); break; + case 4: + // Navigate to Penyewa page + navigateToPenyewa(); + break; } } catch (e) { print('Error changing tab: $e'); @@ -224,11 +287,42 @@ class PetugasBumdesDashboardController extends GetxController { } } + void navigateToPenyewa() { + try { + Get.offAllNamed(Routes.PETUGAS_PENYEWA); + } catch (e) { + print('Error navigating to Penyewa: $e'); + } + } + void logout() async { try { + // Clear providers data if (_authProvider != null) { + // Sign out from Supabase await _authProvider!.signOut(); + + // Clear auth provider data + _authProvider!.clearAuthData(); + + // Clear aset provider data + try { + final asetProvider = Get.find(); + asetProvider.clearCache(); + } catch (e) { + print('Error clearing AsetProvider: $e'); + } + + // Clear pesanan provider data + try { + final pesananProvider = Get.find(); + pesananProvider.clearCache(); + } catch (e) { + print('Error clearing PesananProvider: $e'); + } } + + // Navigate to login screen Get.offAllNamed(Routes.LOGIN); } catch (e) { print('Error during logout: $e'); diff --git a/lib/app/modules/petugas_bumdes/controllers/petugas_detail_penyewa_controller.dart b/lib/app/modules/petugas_bumdes/controllers/petugas_detail_penyewa_controller.dart new file mode 100644 index 0000000..db6e6d2 --- /dev/null +++ b/lib/app/modules/petugas_bumdes/controllers/petugas_detail_penyewa_controller.dart @@ -0,0 +1,232 @@ +import 'package:get/get.dart'; +import '../../../data/providers/auth_provider.dart'; + +class PetugasDetailPenyewaController extends GetxController { + final AuthProvider _authProvider = Get.find(); + + final isLoading = true.obs; + final penyewaDetail = Rx>({}); + final sewaHistory = >[].obs; + final userId = ''.obs; + + @override + void onInit() { + super.onInit(); + if (Get.arguments != null && Get.arguments['userId'] != null) { + userId.value = Get.arguments['userId']; + fetchPenyewaDetail(); + fetchSewaHistory(); + } else { + Get.back(); + Get.snackbar( + 'Error', + 'Data penyewa tidak ditemukan', + snackPosition: SnackPosition.BOTTOM, + ); + } + } + + Future fetchPenyewaDetail() async { + try { + isLoading.value = true; + + final data = + await _authProvider.client + .from('warga_desa') + .select('*') + .eq('user_id', userId.value) + .single(); + + if (data != null) { + penyewaDetail.value = Map.from(data); + print('Penyewa detail fetched: ${penyewaDetail.value}'); + } + } catch (e) { + print('Error fetching penyewa detail: $e'); + Get.snackbar( + 'Error', + 'Gagal memuat data penyewa', + snackPosition: SnackPosition.BOTTOM, + ); + } finally { + isLoading.value = false; + } + } + + Future fetchSewaHistory() async { + try { + final data = await _authProvider.client + .from('sewa_aset') + .select('*') + .eq('user_id', userId.value) + .order('created_at', ascending: false); + + if (data != null) { + sewaHistory.value = List>.from(data); + print('Sewa history fetched: ${sewaHistory.length} items'); + + // Process data for each item + for (int i = 0; i < sewaHistory.length; i++) { + final item = sewaHistory[i]; + + // Fetch tagihan data for this sewa_aset + try { + final tagihanResponse = + await _authProvider.client + .from('tagihan_sewa') + .select('tagihan_awal, denda, tagihan_dibayar') + .eq('sewa_aset_id', item['id']) + .maybeSingle(); + + if (tagihanResponse != null) { + // Add tagihan data to the item + sewaHistory[i] = {...item, 'tagihan_sewa': tagihanResponse}; + } + } catch (e) { + print('Error fetching tagihan for sewa_aset ${item['id']}: $e'); + } + + // Get the updated item after adding tagihan data + final updatedItem = sewaHistory[i]; + + // If this is a package rental (aset_id is null and paket_id exists) + if (updatedItem['aset_id'] == null && + updatedItem['paket_id'] != null) { + final String paketId = updatedItem['paket_id']; + + try { + // Get package name from paket table + final paketResponse = + await _authProvider.client + .from('paket') + .select('nama') + .eq('id', paketId) + .maybeSingle(); + + // Get package photo from foto_aset table + final fotoResponse = + await _authProvider.client + .from('foto_aset') + .select('foto_aset') + .eq('id_paket', paketId) + .limit(1) + .maybeSingle(); + + // Create a synthetic aset object for paket + Map syntheticAset = {}; + + if (paketResponse != null) { + syntheticAset['nama'] = paketResponse['nama'] ?? 'Paket'; + } + + if (fotoResponse != null) { + syntheticAset['foto_utama'] = fotoResponse['foto_aset']; + } + + // Update the item with the synthetic aset + sewaHistory[i] = { + ...updatedItem, + 'aset': syntheticAset, + 'tipe_pesanan': 'paket', + }; + } catch (e) { + print('Error fetching package details: $e'); + } + } + // If this is an asset rental (aset_id exists and paket_id is null) + else if (updatedItem['aset_id'] != null && + updatedItem['paket_id'] == null) { + final String asetId = updatedItem['aset_id']; + + try { + // Get asset name from aset table + final asetResponse = + await _authProvider.client + .from('aset') + .select('nama') + .eq('id', asetId) + .maybeSingle(); + + // Get asset photo from foto_aset table using id_aset + final fotoResponse = + await _authProvider.client + .from('foto_aset') + .select('foto_aset') + .eq('id_aset', asetId) + .limit(1) + .maybeSingle(); + + // Create aset object for individual asset + Map asetData = {}; + + if (asetResponse != null) { + asetData['nama'] = asetResponse['nama'] ?? 'Aset'; + } + + if (fotoResponse != null) { + asetData['foto_utama'] = fotoResponse['foto_aset']; + } + + // Update the item with the aset data + sewaHistory[i] = { + ...updatedItem, + 'aset': asetData, + 'tipe_pesanan': 'aset', + }; + } catch (e) { + print('Error fetching asset details: $e'); + } + } + } + } + } catch (e) { + print('Error fetching sewa history: $e'); + } + } + + Future updatePenyewaStatus(String status, String keterangan) async { + try { + isLoading.value = true; + + await _authProvider.client + .from('warga_desa') + .update({ + 'status': status, + 'keterangan': keterangan, + 'updated_at': DateTime.now().toIso8601String(), + }) + .eq('user_id', userId.value); + + // Refresh data + await fetchPenyewaDetail(); + + Get.snackbar( + 'Berhasil', + 'Status penyewa berhasil diperbarui', + snackPosition: SnackPosition.BOTTOM, + ); + } catch (e) { + print('Error updating penyewa status: $e'); + Get.snackbar( + 'Error', + 'Gagal memperbarui status penyewa', + snackPosition: SnackPosition.BOTTOM, + ); + } finally { + isLoading.value = false; + } + } + + String getStatusLabel(String? status) { + switch (status?.toLowerCase()) { + case 'active': + return 'Aktif'; + case 'pending': + return 'Menunggu Verifikasi'; + case 'suspended': + return 'Dinonaktifkan'; + default: + return 'Tidak diketahui'; + } + } +} diff --git a/lib/app/modules/petugas_bumdes/controllers/petugas_laporan_controller.dart b/lib/app/modules/petugas_bumdes/controllers/petugas_laporan_controller.dart new file mode 100644 index 0000000..3e02c0a --- /dev/null +++ b/lib/app/modules/petugas_bumdes/controllers/petugas_laporan_controller.dart @@ -0,0 +1,1728 @@ +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:printing/printing.dart'; +import 'package:path_provider/path_provider.dart'; +import 'dart:io'; +import '../../../data/providers/aset_provider.dart'; +import '../../../theme/app_colors_petugas.dart'; + +class PetugasLaporanController extends GetxController { + final AsetProvider asetProvider = Get.find(); + + // Observable variables + final isLoading = false.obs; + final isPdfReady = false.obs; + final pdfBytes = Rx(null); + final reportData = {}.obs; + + // Filter variables + final selectedMonth = RxInt(DateTime.now().month); + final selectedYear = RxInt(DateTime.now().year); + + // Available months and years for dropdown + final months = >[].obs; + final years = [].obs; + + @override + void onInit() { + super.onInit(); + _initMonthsAndYears(); + } + + // Initialize months and years for dropdown selection + void _initMonthsAndYears() { + final currentDate = DateTime.now(); + final currentMonth = currentDate.month; + final currentYear = currentDate.year; + + // Initialize months + final monthNames = [ + 'Januari', + 'Februari', + 'Maret', + 'April', + 'Mei', + 'Juni', + 'Juli', + 'Agustus', + 'September', + 'Oktober', + 'November', + 'Desember', + ]; + + months.clear(); + for (int i = 1; i <= 12; i++) { + // Only include months up to current month for current year + if (selectedYear.value < currentYear || i <= currentMonth) { + months.add({'value': i, 'label': monthNames[i - 1]}); + } + } + + // Initialize years (start from 2023 or adjust as needed) + years.clear(); + for (int i = 2023; i <= currentYear; i++) { + years.add(i); + } + + // Set default to current month and year + selectedMonth.value = currentMonth; + selectedYear.value = currentYear; + } + + // Update available months when year changes + void onYearChanged(int? year) { + if (year != null) { + selectedYear.value = year; + + // Refresh months list based on selected year + final currentDate = DateTime.now(); + final currentMonth = currentDate.month; + final currentYear = currentDate.year; + + final monthNames = [ + 'Januari', + 'Februari', + 'Maret', + 'April', + 'Mei', + 'Juni', + 'Juli', + 'Agustus', + 'September', + 'Oktober', + 'November', + 'Desember', + ]; + + months.clear(); + for (int i = 1; i <= 12; i++) { + // Only include months up to current month for current year + if (selectedYear.value < currentYear || i <= currentMonth) { + months.add({'value': i, 'label': monthNames[i - 1]}); + } + } + + // Adjust selected month if necessary + if (selectedYear.value == currentYear && + selectedMonth.value > currentMonth) { + selectedMonth.value = currentMonth; + } + + // Reset PDF if it was previously generated + isPdfReady.value = false; + pdfBytes.value = null; + } + } + + // Update selected month + void onMonthChanged(int? month) { + if (month != null) { + selectedMonth.value = month; + + // Reset PDF if it was previously generated + isPdfReady.value = false; + pdfBytes.value = null; + } + } + + // Generate report based on selected month and year + Future generateReport() async { + try { + isLoading.value = true; + + // Format selected month and year for display + final monthNames = [ + 'Januari', + 'Februari', + 'Maret', + 'April', + 'Mei', + 'Juni', + 'Juli', + 'Agustus', + 'September', + 'Oktober', + 'November', + 'Desember', + ]; + final monthName = monthNames[selectedMonth.value - 1]; + + // Calculate date range for the selected month + final startDate = DateTime(selectedYear.value, selectedMonth.value, 1); + final endDate = DateTime( + selectedYear.value, + selectedMonth.value + 1, + 0, + ); // Last day of month + + debugPrint('📊 Generating report for ${monthName} ${selectedYear.value}'); + debugPrint( + '📅 Date range: ${DateFormat('yyyy-MM-dd').format(startDate)} to ${DateFormat('yyyy-MM-dd').format(endDate)}', + ); + + // Fetch data from database + await _fetchReportData(startDate, endDate); + + // Generate PDF + final pdfData = await _generatePdf( + Map.from(reportData.value), + ); + pdfBytes.value = pdfData; + isPdfReady.value = true; + + debugPrint('✅ Report generated successfully'); + } catch (e) { + debugPrint('❌ Error generating report: $e'); + Get.snackbar( + 'Gagal', + 'Gagal membuat laporan: $e', + backgroundColor: Colors.red[100], + colorText: Colors.red[900], + snackPosition: SnackPosition.BOTTOM, + ); + } finally { + isLoading.value = false; + } + } + + // Fetch data for report + Future _fetchReportData(DateTime startDate, DateTime endDate) async { + try { + final reportDataMap = {}; + + // Format dates for database queries + final startDateStr = DateFormat('yyyy-MM-dd').format(startDate); + final endDateStr = DateFormat('yyyy-MM-dd').format(endDate); + + // Calculate previous month date range + final prevMonthStart = DateTime( + startDate.month == 1 ? startDate.year - 1 : startDate.year, + startDate.month == 1 ? 12 : startDate.month - 1, + 1, + ); + final prevMonthEnd = DateTime(startDate.year, startDate.month, 0); + final prevMonthStartStr = DateFormat('yyyy-MM-dd').format(prevMonthStart); + final prevMonthEndStr = DateFormat('yyyy-MM-dd').format(prevMonthEnd); + + debugPrint('📊 Previous month: $prevMonthStartStr to $prevMonthEndStr'); + + // 1. Fetch rental orders for the period + final rentalOrders = await asetProvider.client + .from('sewa_aset') + .select(''' + id, + created_at, + user_id, + aset_id, + paket_id, + waktu_mulai, + waktu_selesai, + status, + total, + tipe_pesanan, + kuantitas + ''') + .gte('created_at', startDateStr) + .lte('created_at', endDateStr) + .order('created_at'); + + // 1.1 Fetch previous month rental orders for comparison + final prevMonthRentalOrders = await asetProvider.client + .from('sewa_aset') + .select('id, status, total') + .gte('created_at', prevMonthStartStr) + .lte('created_at', prevMonthEndStr); + + // 2. Get users data (renters) + final userIds = + (rentalOrders as List) + .map((order) => order['user_id'].toString()) + .toSet() + .toList(); + final users = await asetProvider.client + .from('warga_desa') + .select('id, nama_lengkap, email, no_hp') + .inFilter('id', userIds); + + // 3. Get assets data + final allAsset = await asetProvider.client + .from('aset') + .select('id, nama') + .order('nama'); + + final assetIds = + (rentalOrders as List) + .where((order) => order['tipe_pesanan'] == 'tunggal') + .map((order) => order['aset_id'].toString()) + .where((id) => id != null) + .toSet() + .toList(); + + final assets = + assetIds.isNotEmpty + ? await asetProvider.client + .from('aset') + .select('id, nama') + .inFilter('id', assetIds) + : []; + + // 4. Get packages data + final allPackage = await asetProvider.client + .from('paket') + .select('id, nama') + .order('nama'); + + final packageIds = + (rentalOrders as List) + .where((order) => order['tipe_pesanan'] == 'paket') + .map((order) => order['paket_id'].toString()) + .where((id) => id != null) + .toSet() + .toList(); + + final packages = + packageIds.isNotEmpty + ? await asetProvider.client + .from('paket') + .select('id, nama') + .inFilter('id', packageIds) + : []; + + // 5. Get tagihan_sewa data + final sewaIds = + (rentalOrders as List) + .map((order) => order['id'].toString()) + .toList(); + final tagihan = await asetProvider.client + .from('tagihan_sewa') + .select( + 'id, sewa_aset_id, durasi, satuan_waktu, harga_sewa, tagihan_awal, status_tagihan_awal, denda, status_denda', + ) + .inFilter('sewa_aset_id', sewaIds); + + // 6. Get payment data + final tagihanIds = + (tagihan as List).map((t) => t['id'].toString()).toList(); + final payments = + tagihanIds.isNotEmpty + ? await asetProvider.client + .from('pembayaran') + .select( + 'id, tagihan_sewa_id, metode_pembayaran, jenis_tagihan, total_pembayaran, status, waktu_pembayaran, created_at', + ) + .inFilter('tagihan_sewa_id', tagihanIds) + : []; + + // Process data for the report + final userMap = {for (var user in users) user['id'].toString(): user}; + final assetMap = { + for (var asset in assets) asset['id'].toString(): asset, + }; + final packageMap = {for (var pkg in packages) pkg['id'].toString(): pkg}; + final tagihanMap = { + for (var t in tagihan) t['sewa_aset_id'].toString(): t, + }; + + // Calculate summary statistics + int totalOrders = rentalOrders.length; + double totalRevenue = 0; + int completedOrders = 0; + int canceledOrders = 0; + + // Count distinct users (renters) with completed orders + final Set uniqueRenters = {}; + + // Get completed orders filtered by updated_at for the selected month + final completedOrdersWithDetails = await asetProvider.client + .from('sewa_aset') + .select(''' + id, + user_id, + aset_id, + paket_id, + total, + tipe_pesanan, + updated_at + ''') + .eq('status', 'SELESAI') + .gte('updated_at', startDateStr) + .lte('updated_at', endDateStr); + + final canceledOrdersData = await asetProvider.client + .from('sewa_aset') + .select('id, user_id, status, updated_at') + .eq('status', 'DIBATALKAN') + .gte('updated_at', startDateStr) + .lte('updated_at', endDateStr); + + // Count completed orders + completedOrders = completedOrdersWithDetails.length; + + // Count canceled orders + canceledOrders = canceledOrdersData.length; + + // Add unique renters from completed orders + for (var order in completedOrdersWithDetails) { + final userId = order['user_id'].toString(); + uniqueRenters.add(userId); + } + + // Calculate total revenue from payments + for (var payment in payments) { + if (payment['status'] == 'selesai') { + // Use waktu_pembayaran to determine if payment falls within the current month + final paymentDate = DateTime.parse( + payment['waktu_pembayaran'] ?? payment['created_at'], + ); + if (paymentDate.isAfter( + startDate.subtract(const Duration(days: 1)), + ) && + paymentDate.isBefore(endDate.add(const Duration(days: 1)))) { + totalRevenue += (payment['total_pembayaran'] as num).toDouble(); + } + } + } + + // Calculate previous month revenue for comparison + double prevMonthRevenue = 0; + + // Get all payments (not just for the previous month orders) + final allPayments = await asetProvider.client + .from('pembayaran') + .select('total_pembayaran, status, waktu_pembayaran, created_at') + .order('waktu_pembayaran'); + + // Filter payments for previous month based on waktu_pembayaran + for (var payment in allPayments) { + if (payment['status'] == 'selesai') { + final paymentDate = DateTime.parse( + payment['waktu_pembayaran'] ?? payment['created_at'], + ); + if (paymentDate.isAfter( + prevMonthStart.subtract(const Duration(days: 1)), + ) && + paymentDate.isBefore(prevMonthEnd.add(const Duration(days: 1)))) { + prevMonthRevenue += (payment['total_pembayaran'] as num).toDouble(); + } + } + } + + // Calculate percentage change + double? prevMonthComparison; + if (prevMonthRevenue > 0) { + prevMonthComparison = + ((totalRevenue - prevMonthRevenue) / prevMonthRevenue) * 100; + prevMonthComparison = double.parse( + prevMonthComparison.toStringAsFixed(2), + ); + } + + // Prepare daily revenue data for chart + final dailyRevenue = {}; + DateTime currentDate = startDate.copyWith(); + while (currentDate.isBefore(endDate) || + currentDate.isAtSameMomentAs(endDate)) { + final dateStr = DateFormat('yyyy-MM-dd').format(currentDate); + dailyRevenue[dateStr] = 0; + currentDate = currentDate.add(const Duration(days: 1)); + } + + // Fill in daily revenue data based on waktu_pembayaran + for (var payment in payments) { + if (payment['status'] == 'selesai') { + final paymentDate = DateFormat('yyyy-MM-dd').format( + DateTime.parse( + payment['waktu_pembayaran'] ?? payment['created_at'], + ), + ); + if (dailyRevenue.containsKey(paymentDate)) { + dailyRevenue[paymentDate] = + dailyRevenue[paymentDate]! + + (payment['total_pembayaran'] as num).toDouble(); + } + } + } + + // Store all collected data in the reportData object + reportDataMap['period'] = { + 'month': selectedMonth.value, + 'monthName': + months.firstWhere( + (m) => m['value'] == selectedMonth.value, + )['label'], + 'year': selectedYear.value, + 'startDate': startDateStr, + 'endDate': endDateStr, + }; + + reportDataMap['summary'] = { + 'totalOrders': totalOrders, + 'totalRevenue': totalRevenue, + 'completedOrders': completedOrders, + 'canceledOrders': canceledOrders, + 'uniqueRenters': uniqueRenters.length, + }; + + reportDataMap['prevMonthRevenue'] = prevMonthRevenue; + reportDataMap['prevMonthComparison'] = prevMonthComparison; + + reportDataMap['rentalOrders'] = rentalOrders; + reportDataMap['users'] = userMap; + reportDataMap['assets'] = assetMap; + reportDataMap['packages'] = packageMap; + reportDataMap['tagihan'] = tagihanMap; + reportDataMap['payments'] = payments; + reportDataMap['dailyRevenue'] = dailyRevenue; + reportDataMap['allAsset'] = allAsset; + reportDataMap['allPackage'] = allPackage; + + // Save completed orders with details for asset performance analysis + reportDataMap['completedOrdersWithDetails'] = completedOrdersWithDetails; + + // Calculate payment type summary (Sewa and Denda) + double totalSewa = 0; + double totalDenda = 0; + + for (var payment in payments) { + if (payment['status'] == 'selesai') { + // Use waktu_pembayaran to determine if payment falls within the current month + final paymentDate = DateTime.parse( + payment['waktu_pembayaran'] ?? payment['created_at'], + ); + if (paymentDate.isAfter( + startDate.subtract(const Duration(days: 1)), + ) && + paymentDate.isBefore(endDate.add(const Duration(days: 1)))) { + // Group by jenis_tagihan + final jenisTagihan = + payment['jenis_tagihan']?.toString()?.toLowerCase() ?? ''; + final amount = (payment['total_pembayaran'] as num).toDouble(); + + if (jenisTagihan == 'sewa') { + totalSewa += amount; + } else if (jenisTagihan == 'denda') { + totalDenda += amount; + } + } + } + } + + // Add payment type summary data + reportDataMap['paymentTypeSummary'] = { + 'totalSewa': totalSewa, + 'totalDenda': totalDenda, + }; + + // Prepare daily payment data for the daily payment table + final dailyPaymentData = >{}; + DateTime paymentDate = startDate.copyWith(); + while (paymentDate.isBefore(endDate) || + paymentDate.isAtSameMomentAs(endDate)) { + final dateStr = DateFormat('yyyy-MM-dd').format(paymentDate); + dailyPaymentData[dateStr] = {'sewa': 0, 'denda': 0, 'total': 0}; + paymentDate = paymentDate.add(const Duration(days: 1)); + } + + // Fill in daily payment data based on waktu_pembayaran and jenis_tagihan + for (var payment in payments) { + if (payment['status'] == 'selesai') { + final paymentDate = DateTime.parse( + payment['waktu_pembayaran'] ?? payment['created_at'], + ); + final dateStr = DateFormat('yyyy-MM-dd').format(paymentDate); + + if (dailyPaymentData.containsKey(dateStr)) { + final jenisTagihan = + payment['jenis_tagihan']?.toString()?.toLowerCase() ?? ''; + final amount = (payment['total_pembayaran'] as num).toDouble(); + + if (jenisTagihan == 'sewa') { + dailyPaymentData[dateStr]!['sewa'] = + dailyPaymentData[dateStr]!['sewa']! + amount; + } else if (jenisTagihan == 'denda') { + dailyPaymentData[dateStr]!['denda'] = + dailyPaymentData[dateStr]!['denda']! + amount; + } + + dailyPaymentData[dateStr]!['total'] = + dailyPaymentData[dateStr]!['total']! + amount; + } + } + } + + reportDataMap['dailyPaymentData'] = dailyPaymentData; + + // Update the observable reportData + reportData.value = reportDataMap; + } catch (e) { + debugPrint('❌ Error fetching report data: $e'); + throw Exception('Failed to fetch report data: $e'); + } + } + + // Generate PDF report + Future _generatePdf(Map reportData) async { + final pdf = pw.Document(); + + // Try to load logo (wrapped in try-catch in case logo is not available) + pw.MemoryImage? logo; + try { + final logoData = await rootBundle.load('assets/images/logo.png'); + logo = pw.MemoryImage(logoData.buffer.asUint8List()); + } catch (e) { + debugPrint('⚠️ Logo not found: $e'); + } + + // Use basic fonts + final font = pw.Font.helvetica(); + final fontBold = pw.Font.helveticaBold(); + + // Title + final title = 'Laporan Bulanan BUMDes Rental'; + final period = + '${_getMonthName(selectedMonth.value)} ${selectedYear.value}'; + + // Data preparation + final orders = reportData['rentalOrders'] as List; + final List> typedOrders = + orders + .map( + (order) => + Map.from(order as Map), + ) + .toList(); + final users = Map.from( + reportData['users'] as Map, + ); + final assets = Map.from( + reportData['assets'] as Map, + ); + final packages = Map.from( + reportData['packages'] as Map, + ); + final tagihan = Map.from( + reportData['tagihan'] as Map, + ); + final payments = reportData['payments'] as List; + final List> typedPayments = + payments + .map( + (payment) => + Map.from(payment as Map), + ) + .toList(); + + // 1. Asset Performance Analysis + final assetRentalFrequency = {}; + final assetRevenue = {}; + final allAssetIds = + (reportData['allAsset'] as List) + .map((asset) => asset['id'].toString()) + .toList(); + final rentedAssetIds = {}; + + // 2. Package Performance Analysis + final packageRentalFrequency = {}; + final packageRevenue = {}; + final allPackageIds = + (reportData['allPackage'] as List) + .map((pkg) => pkg['id'].toString()) + .toList(); + final rentedPackageIds = {}; + + // 4. Payment and Financial Analysis + final paymentMethods = {}; + final unpaidTransactions = >[]; + final penaltyPayments = >[]; + + // Process data for analysis - only include completed orders + final completedOrdersWithDetails = + reportData['completedOrdersWithDetails'] as List; + for (final order in completedOrdersWithDetails) { + final String orderId = order['id'].toString(); + final bool isPackage = order['tipe_pesanan'] == 'paket'; + final String itemId = + isPackage + ? order['paket_id']?.toString() ?? '' + : order['aset_id']?.toString() ?? ''; + + if (itemId.isEmpty) continue; // Skip if no valid item ID + + final double total = double.parse(order['total']?.toString() ?? '0'); + + // Asset/Package frequency and revenue + if (isPackage) { + rentedPackageIds.add(itemId); + packageRentalFrequency[itemId] = + (packageRentalFrequency[itemId] ?? 0) + 1; + packageRevenue[itemId] = (packageRevenue[itemId] ?? 0) + total; + } else { + rentedAssetIds.add(itemId); + assetRentalFrequency[itemId] = (assetRentalFrequency[itemId] ?? 0) + 1; + assetRevenue[itemId] = (assetRevenue[itemId] ?? 0) + total; + } + + // Payment analysis + bool isPaid = false; + bool hasPenalty = false; + double penaltyAmount = 0; + + for (final payment in typedPayments) { + if (payment['tagihan_sewa_id'].toString() == + tagihan[orderId]?['id']?.toString()) { + isPaid = payment['status'] == 'selesai'; + final method = payment['metode_pembayaran'] ?? 'Tidak diketahui'; + paymentMethods[method] = + (paymentMethods[method] ?? 0) + + double.parse(payment['total_pembayaran'].toString()); + + // Check for penalty + if (payment['denda'] != null && payment['denda'] > 0) { + hasPenalty = true; + penaltyAmount = double.parse(payment['denda'].toString()); + } + } + } + + // Add to unpaid transactions + if (!isPaid) { + unpaidTransactions.add({ + 'id': orderId, + 'user': + users[order['user_id'].toString()]?['nama_lengkap'] ?? 'Unknown', + 'item': + isPackage + ? (packages[itemId]?['nama'] ?? 'Unknown Package') + : (assets[itemId]?['nama'] ?? 'Unknown Asset'), + 'total': total, + 'date': order['created_at'] ?? 'Unknown', + }); + } + + // Add to penalty payments + if (hasPenalty) { + penaltyPayments.add({ + 'id': orderId, + 'user': + users[order['user_id'].toString()]?['nama_lengkap'] ?? 'Unknown', + 'item': + isPackage + ? (packages[itemId]?['nama'] ?? 'Unknown Package') + : (assets[itemId]?['nama'] ?? 'Unknown Asset'), + 'penalty': penaltyAmount, + 'date': order['created_at'] ?? 'Unknown', + }); + } + } + + // Initialize frequency and revenue for all assets + for (final assetId in allAssetIds) { + // Initialize with 0 for frequency if not already set + if (!assetRentalFrequency.containsKey(assetId)) { + assetRentalFrequency[assetId] = 0; + } + + // Initialize with 0 for revenue if not already set + if (!assetRevenue.containsKey(assetId)) { + assetRevenue[assetId] = 0; + } + } + + // Find non-rented assets and packages + final nonRentedAssetIds = + allAssetIds.where((id) => !rentedAssetIds.contains(id)).toList(); + final nonRentedPackageIds = + allPackageIds.where((id) => !rentedPackageIds.contains(id)).toList(); + + // Sort assets and packages by frequency and revenue + final topAssetsByFrequency = + assetRentalFrequency.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + final topAssetsByRevenue = + assetRevenue.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + + // Create a list of all assets with their frequency and revenue + final allAssetsWithData = + (reportData['allAsset'] as List).map((asset) { + final assetId = asset['id'].toString(); + final assetName = asset['nama'] ?? 'Unknown Asset'; + final frequency = assetRentalFrequency[assetId] ?? 0; + final revenue = assetRevenue[assetId] ?? 0; + return { + 'id': assetId, + 'name': assetName, + 'frequency': frequency, + 'revenue': revenue, + }; + }).toList(); + + // Sort by frequency (descending) for frequency table + allAssetsWithData.sort( + (a, b) => (b['frequency'] as int).compareTo(a['frequency'] as int), + ); + final assetsByFrequency = List.from(allAssetsWithData); + + // Sort by revenue (descending) for revenue table + allAssetsWithData.sort( + (a, b) => (b['revenue'] as double).compareTo(a['revenue'] as double), + ); + final assetsByRevenue = List.from(allAssetsWithData); + + // Create a list of all packages with their frequency and revenue + final allPackagesWithData = + (reportData['allPackage'] as List).map((pkg) { + final packageId = pkg['id'].toString(); + final packageName = pkg['nama'] ?? 'Unknown Package'; + final frequency = packageRentalFrequency[packageId] ?? 0; + final revenue = packageRevenue[packageId] ?? 0; + return { + 'id': packageId, + 'name': packageName, + 'frequency': frequency, + 'revenue': revenue, + }; + }).toList(); + + // Sort by frequency (descending) for frequency table + allPackagesWithData.sort( + (a, b) => (b['frequency'] as int).compareTo(a['frequency'] as int), + ); + final packagesByFrequency = List.from(allPackagesWithData); + + // Sort by revenue (descending) for revenue table + allPackagesWithData.sort( + (a, b) => (b['revenue'] as double).compareTo(a['revenue'] as double), + ); + final packagesByRevenue = List.from(allPackagesWithData); + + // Sort packages by frequency and revenue + final topPackagesByFrequency = + packageRentalFrequency.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + final topPackagesByRevenue = + packageRevenue.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + + // Daily revenue data (already in reportData) + final dailyRevenue = reportData['dailyRevenue'] as Map; + + // Create the PDF + pdf.addPage( + pw.MultiPage( + pageFormat: PdfPageFormat.a4, + margin: const pw.EdgeInsets.all(40), + header: (pw.Context context) { + return pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + 'BUMDes Cahaya Buana Paku', + style: pw.TextStyle( + font: fontBold, + fontSize: 24, + color: PdfColors.blue900, + ), + ), + pw.SizedBox(height: 5), + pw.Text( + 'Laporan Bulanan Penyewaan Aset', + style: pw.TextStyle( + font: font, + fontSize: 16, + color: PdfColors.grey800, + ), + ), + pw.SizedBox(height: 3), + pw.Text( + 'Periode: $period', + style: pw.TextStyle( + font: font, + fontSize: 14, + color: PdfColors.grey700, + ), + ), + pw.SizedBox(height: 3), + pw.Text( + 'Tanggal Cetak: ${DateFormat('dd MMMM yyyy').format(DateTime.now())}', + style: pw.TextStyle( + font: font, + fontSize: 12, + color: PdfColors.grey700, + ), + ), + ], + ), + if (logo != null) + pw.Container(width: 60, height: 60, child: pw.Image(logo)), + ], + ); + }, + footer: (pw.Context context) { + return pw.Container( + alignment: pw.Alignment.centerRight, + margin: const pw.EdgeInsets.only(top: 10), + child: pw.Text( + 'Halaman ${context.pageNumber} dari ${context.pagesCount}', + style: pw.TextStyle( + font: font, + fontSize: 12, + color: PdfColors.grey700, + ), + ), + ); + }, + build: (pw.Context context) { + return [ + // Executive Summary + pw.Container( + padding: const pw.EdgeInsets.all(15), + decoration: pw.BoxDecoration( + color: PdfColors.blue50, + borderRadius: pw.BorderRadius.circular(10), + ), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + 'RINGKASAN EKSEKUTIF', + style: pw.TextStyle( + font: fontBold, + fontSize: 14, + color: PdfColors.blue900, + ), + ), + pw.SizedBox(height: 10), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + _buildSummaryItem( + 'Total Pendapatan', + 'Rp ${_formatCurrency(reportData['summary']['totalRevenue'])}', + font, + fontBold, + ), + _buildSummaryItem( + 'Transaksi Selesai', + '${reportData['summary']['completedOrders']}', + font, + fontBold, + ), + _buildSummaryItem( + 'Transaksi Dibatalkan', + '${reportData['summary']['canceledOrders']}', + font, + fontBold, + ), + ], + ), + pw.SizedBox(height: 10), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + _buildSummaryItem( + 'Total Penyewa', + '${reportData['summary']['uniqueRenters']}', + font, + fontBold, + ), + _buildSummaryItem( + 'Perbandingan dengan Bulan Lalu', + reportData['prevMonthComparison'] != null + ? '${reportData['prevMonthComparison'] >= 0 ? '+' : ''}${reportData['prevMonthComparison']}%' + : 'Tidak ada data', + font, + fontBold, + ), + pw.SizedBox(width: 10), // Empty placeholder for alignment + ], + ), + ], + ), + ), + pw.SizedBox(height: 20), + + // 1. Asset Performance Analysis + pw.Text( + '1. ANALISIS KINERJA ASET', + style: pw.TextStyle( + font: fontBold, + fontSize: 16, + color: PdfColors.blue900, + ), + ), + pw.SizedBox(height: 10), + + // Top assets by frequency + pw.Text( + 'Aset dengan Frekuensi Penyewaan', + style: pw.TextStyle( + font: fontBold, + fontSize: 14, + color: PdfColors.blue800, + ), + ), + pw.SizedBox(height: 5), + pw.Table( + border: null, + columnWidths: { + 0: const pw.FlexColumnWidth(1), + 1: const pw.FlexColumnWidth(4), + 2: const pw.FlexColumnWidth(2), + }, + children: [ + pw.TableRow( + decoration: pw.BoxDecoration( + color: PdfColors.blue700, + borderRadius: pw.BorderRadius.circular(2), + ), + children: [ + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text( + 'No', + style: pw.TextStyle( + font: fontBold, + color: PdfColors.white, + ), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text( + 'Nama Aset', + style: pw.TextStyle( + font: fontBold, + color: PdfColors.white, + ), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text( + 'Frekuensi', + style: pw.TextStyle( + font: fontBold, + color: PdfColors.white, + ), + ), + ), + ], + ), + ...assetsByFrequency.asMap().entries.map((entry) { + final index = entry.key; + final asset = entry.value; + return pw.TableRow( + decoration: pw.BoxDecoration( + border: pw.Border( + bottom: pw.BorderSide( + color: PdfColors.grey300, + width: 0.5, + ), + ), + ), + children: [ + pw.Padding( + padding: const pw.EdgeInsets.symmetric(vertical: 8), + child: pw.Text( + '${index + 1}', + style: pw.TextStyle(font: font, fontSize: 11), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.symmetric(vertical: 8), + child: pw.Text( + asset['name'] as String, + style: pw.TextStyle(font: font, fontSize: 11), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.symmetric(vertical: 8), + child: pw.Text( + '${asset['frequency']}', + style: pw.TextStyle(font: font, fontSize: 11), + ), + ), + ], + ); + }).toList(), + ], + ), + pw.SizedBox(height: 10), + + // Top assets by revenue + pw.Text( + 'Aset dengan Total Pendapatan', + style: pw.TextStyle( + font: fontBold, + fontSize: 14, + color: PdfColors.blue800, + ), + ), + pw.SizedBox(height: 5), + pw.Table( + border: null, + columnWidths: { + 0: const pw.FlexColumnWidth(1), + 1: const pw.FlexColumnWidth(4), + 2: const pw.FlexColumnWidth(2), + }, + children: [ + pw.TableRow( + decoration: pw.BoxDecoration( + color: PdfColors.blue700, + borderRadius: pw.BorderRadius.circular(2), + ), + children: [ + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text( + 'No', + style: pw.TextStyle( + font: fontBold, + color: PdfColors.white, + ), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text( + 'Nama Aset', + style: pw.TextStyle( + font: fontBold, + color: PdfColors.white, + ), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text( + 'Pendapatan', + style: pw.TextStyle( + font: fontBold, + color: PdfColors.white, + ), + ), + ), + ], + ), + ...assetsByRevenue.asMap().entries.map((entry) { + final index = entry.key; + final asset = entry.value; + final double revenue = asset['revenue'] as double; + return pw.TableRow( + decoration: pw.BoxDecoration( + border: pw.Border( + bottom: pw.BorderSide( + color: PdfColors.grey300, + width: 0.5, + ), + ), + ), + children: [ + pw.Padding( + padding: const pw.EdgeInsets.symmetric(vertical: 8), + child: pw.Text( + '${index + 1}', + style: pw.TextStyle(font: font, fontSize: 11), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.symmetric(vertical: 8), + child: pw.Text( + asset['name'] as String, + style: pw.TextStyle(font: font, fontSize: 11), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.symmetric(vertical: 8), + child: pw.Text( + revenue > 0 ? 'Rp ${_formatCurrency(revenue)}' : '-', + style: pw.TextStyle(font: font, fontSize: 11), + ), + ), + ], + ); + }).toList(), + ], + ), + pw.SizedBox(height: 10), + + // 2. Package Performance Analysis + pw.Text( + '2. ANALISIS KINERJA PAKET', + style: pw.TextStyle( + font: fontBold, + fontSize: 16, + color: PdfColors.blue900, + ), + ), + pw.SizedBox(height: 10), + + // Top packages by frequency + pw.Text( + 'Paket dengan Frekuensi Penyewaan', + style: pw.TextStyle( + font: fontBold, + fontSize: 14, + color: PdfColors.blue800, + ), + ), + pw.SizedBox(height: 5), + pw.Table( + border: pw.TableBorder.all(), + columnWidths: { + 0: const pw.FlexColumnWidth(1), + 1: const pw.FlexColumnWidth(4), + 2: const pw.FlexColumnWidth(2), + }, + children: [ + pw.TableRow( + decoration: pw.BoxDecoration(color: PdfColors.grey300), + children: [ + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text( + 'No', + style: pw.TextStyle(fontWeight: pw.FontWeight.bold), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text( + 'Nama Paket', + style: pw.TextStyle(fontWeight: pw.FontWeight.bold), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text( + 'Frekuensi', + style: pw.TextStyle(fontWeight: pw.FontWeight.bold), + ), + ), + ], + ), + ...packagesByFrequency.asMap().entries.map((entry) { + final index = entry.key; + final pkg = entry.value; + return pw.TableRow( + children: [ + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text('${index + 1}'), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text(pkg['name'] as String), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text('${pkg['frequency']}'), + ), + ], + ); + }).toList(), + ], + ), + pw.SizedBox(height: 10), + + // Top packages by revenue + pw.Text( + 'Paket dengan Total Pendapatan', + style: pw.TextStyle( + font: fontBold, + fontSize: 14, + color: PdfColors.blue800, + ), + ), + pw.SizedBox(height: 5), + pw.Table( + border: pw.TableBorder.all(), + columnWidths: { + 0: const pw.FlexColumnWidth(1), + 1: const pw.FlexColumnWidth(4), + 2: const pw.FlexColumnWidth(2), + }, + children: [ + pw.TableRow( + decoration: pw.BoxDecoration(color: PdfColors.grey300), + children: [ + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text( + 'No', + style: pw.TextStyle(fontWeight: pw.FontWeight.bold), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text( + 'Nama Paket', + style: pw.TextStyle(fontWeight: pw.FontWeight.bold), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text( + 'Pendapatan', + style: pw.TextStyle(fontWeight: pw.FontWeight.bold), + ), + ), + ], + ), + ...packagesByRevenue.asMap().entries.map((entry) { + final index = entry.key; + final pkg = entry.value; + final double revenue = pkg['revenue'] as double; + return pw.TableRow( + children: [ + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text('${index + 1}'), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text(pkg['name'] as String), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text( + revenue > 0 ? 'Rp ${_formatCurrency(revenue)}' : '-', + ), + ), + ], + ); + }).toList(), + ], + ), + pw.SizedBox(height: 10), + + // 3. Payments and Finance + pw.Text( + '3. PEMBAYARAN DAN KEUANGAN', + style: pw.TextStyle( + font: fontBold, + fontSize: 16, + color: PdfColors.blue900, + ), + ), + pw.SizedBox(height: 10), + + // Payment methods summary + pw.Text( + 'Ringkasan Pembayaran berdasarkan Metode Pembayaran', + style: pw.TextStyle( + font: fontBold, + fontSize: 14, + color: PdfColors.blue800, + ), + ), + pw.SizedBox(height: 5), + pw.Table( + border: pw.TableBorder.all(), + columnWidths: { + 0: const pw.FlexColumnWidth(1), + 1: const pw.FlexColumnWidth(4), + 2: const pw.FlexColumnWidth(2), + }, + children: [ + pw.TableRow( + decoration: pw.BoxDecoration(color: PdfColors.grey300), + children: [ + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text( + 'No', + style: pw.TextStyle(fontWeight: pw.FontWeight.bold), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text( + 'Metode Pembayaran', + style: pw.TextStyle(fontWeight: pw.FontWeight.bold), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text( + 'Total', + style: pw.TextStyle(fontWeight: pw.FontWeight.bold), + ), + ), + ], + ), + ...paymentMethods.entries.toList().asMap().entries.map((entry) { + final index = entry.key; + final methodEntry = entry.value; + return pw.TableRow( + children: [ + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text('${index + 1}'), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text(methodEntry.key), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text( + 'Rp ${_formatCurrency(methodEntry.value)}', + ), + ), + ], + ); + }).toList(), + ], + ), + pw.SizedBox(height: 15), + + // Daily Payment Table + pw.Text( + 'Pembayaran Harian', + style: pw.TextStyle( + font: fontBold, + fontSize: 14, + color: PdfColors.blue800, + ), + ), + pw.SizedBox(height: 5), + pw.Table( + border: pw.TableBorder.all(), + columnWidths: { + 0: const pw.FlexColumnWidth(1), // No + 1: const pw.FlexColumnWidth(3), // Tanggal + 2: const pw.FlexColumnWidth(2), // Sewa + 3: const pw.FlexColumnWidth(2), // Denda + 4: const pw.FlexColumnWidth(2), // Total + }, + children: [ + // Header row + pw.TableRow( + decoration: pw.BoxDecoration(color: PdfColors.grey300), + children: [ + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text( + 'No', + style: pw.TextStyle(fontWeight: pw.FontWeight.bold), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text( + 'Tanggal', + style: pw.TextStyle(fontWeight: pw.FontWeight.bold), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text( + 'Sewa', + style: pw.TextStyle(fontWeight: pw.FontWeight.bold), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text( + 'Denda', + style: pw.TextStyle(fontWeight: pw.FontWeight.bold), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text( + 'Total', + style: pw.TextStyle(fontWeight: pw.FontWeight.bold), + ), + ), + ], + ), + + // Data rows + ...(reportData['dailyPaymentData'] as Map) + .entries + .toList() + .asMap() + .entries + .map((entry) { + final index = entry.key; + final dateStr = entry.value.key; + final data = entry.value.value as Map; + + // Format the date for display + final date = DateTime.parse(dateStr); + final formattedDate = DateFormat( + 'dd MMMM yyyy', + ).format(date); + + return pw.TableRow( + children: [ + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text('${index + 1}'), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text(formattedDate), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text( + 'Rp ${_formatCurrency(data['sewa'] ?? 0)}', + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text( + 'Rp ${_formatCurrency(data['denda'] ?? 0)}', + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text( + 'Rp ${_formatCurrency(data['total'] ?? 0)}', + ), + ), + ], + ); + }) + .toList(), + + // Total row + pw.TableRow( + decoration: pw.BoxDecoration(color: PdfColors.grey200), + children: [ + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text(''), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text( + 'TOTAL', + style: pw.TextStyle(fontWeight: pw.FontWeight.bold), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text( + 'Rp ${_formatCurrency(reportData['paymentTypeSummary']?['totalSewa'] ?? 0)}', + style: pw.TextStyle(fontWeight: pw.FontWeight.bold), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text( + 'Rp ${_formatCurrency(reportData['paymentTypeSummary']?['totalDenda'] ?? 0)}', + style: pw.TextStyle(fontWeight: pw.FontWeight.bold), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(5), + child: pw.Text( + 'Rp ${_formatCurrency((reportData['paymentTypeSummary']?['totalSewa'] ?? 0) + (reportData['paymentTypeSummary']?['totalDenda'] ?? 0))}', + style: pw.TextStyle(fontWeight: pw.FontWeight.bold), + ), + ), + ], + ), + ], + ), + pw.SizedBox(height: 10), + + // Signature + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.end, + children: [ + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.center, + children: [ + pw.Text('Dibuat oleh,', style: pw.TextStyle(font: font)), + pw.SizedBox(height: 60), + pw.Text( + '____________________', + style: pw.TextStyle(font: font), + ), + pw.SizedBox(height: 5), + pw.Text('Petugas BUMDes', style: pw.TextStyle(font: font)), + ], + ), + ], + ), + ]; + }, + ), + ); + + return pdf.save(); + } + + // Helper method to get month name from month number + String _getMonthName(int month) { + const monthNames = [ + 'Januari', + 'Februari', + 'Maret', + 'April', + 'Mei', + 'Juni', + 'Juli', + 'Agustus', + 'September', + 'Oktober', + 'November', + 'Desember', + ]; + + if (month >= 1 && month <= 12) { + return monthNames[month - 1]; + } + return 'Unknown'; + } + + // Widget builder for summary items in PDF + pw.Widget _buildSummaryItem( + String title, + String value, + pw.Font regularFont, + pw.Font boldFont, + ) { + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + title, + style: pw.TextStyle( + font: regularFont, + fontSize: 12, + color: PdfColors.grey700, + ), + ), + pw.SizedBox(height: 5), + pw.Text( + value, + style: pw.TextStyle( + font: boldFont, + fontSize: 14, + color: PdfColors.blue900, + ), + ), + ], + ); + } + + String _formatCurrency(dynamic value) { + if (value == null) return '0'; + final formatter = NumberFormat('#,###'); + return formatter.format(double.parse(value.toString())); + } + + // Preview the generated PDF + void previewPdf() { + if (isPdfReady.value && pdfBytes.value != null) { + Printing.layoutPdf( + onLayout: (PdfPageFormat format) async => pdfBytes.value!, + ); + } else { + Get.snackbar( + 'Tidak Ada Laporan', + 'Harap generate laporan terlebih dahulu', + backgroundColor: Colors.amber[100], + colorText: Colors.amber[900], + snackPosition: SnackPosition.BOTTOM, + ); + } + } + + // Save PDF to device + Future savePdf() async { + if (!isPdfReady.value || pdfBytes.value == null) { + Get.snackbar( + 'Tidak Ada Laporan', + 'Harap generate laporan terlebih dahulu', + backgroundColor: Colors.amber[100], + colorText: Colors.amber[900], + snackPosition: SnackPosition.BOTTOM, + ); + return; + } + + try { + final dir = await getApplicationDocumentsDirectory(); + final monthNames = [ + 'Januari', + 'Februari', + 'Maret', + 'April', + 'Mei', + 'Juni', + 'Juli', + 'Agustus', + 'September', + 'Oktober', + 'November', + 'Desember', + ]; + + final monthName = monthNames[selectedMonth.value - 1]; + final fileName = 'Laporan_${monthName}_${selectedYear.value}.pdf'; + final file = File('${dir.path}/$fileName'); + + await file.writeAsBytes(pdfBytes.value!); + + Get.snackbar( + 'Berhasil', + 'Laporan disimpan di ${file.path}', + backgroundColor: Colors.green[100], + colorText: Colors.green[900], + snackPosition: SnackPosition.BOTTOM, + duration: const Duration(seconds: 5), + ); + + // Open file + // Note: You might need additional plugins for this functionality + // such as open_file or url_launcher depending on platform + } catch (e) { + debugPrint('❌ Error saving PDF: $e'); + Get.snackbar( + 'Gagal', + 'Gagal menyimpan laporan: $e', + backgroundColor: Colors.red[100], + colorText: Colors.red[900], + snackPosition: SnackPosition.BOTTOM, + ); + } + } + + // Print PDF + void printPdf() { + if (!isPdfReady.value || pdfBytes.value == null) { + Get.snackbar( + 'Tidak Ada Laporan', + 'Harap generate laporan terlebih dahulu', + backgroundColor: Colors.amber[100], + colorText: Colors.amber[900], + snackPosition: SnackPosition.BOTTOM, + ); + return; + } + + Printing.layoutPdf( + onLayout: (PdfPageFormat format) async => pdfBytes.value!, + name: + 'Laporan_${reportData['period']['monthName']}_${reportData['period']['year']}', + ); + } +} diff --git a/lib/app/modules/petugas_bumdes/controllers/petugas_paket_controller.dart b/lib/app/modules/petugas_bumdes/controllers/petugas_paket_controller.dart index 90e22b2..147cc2b 100644 --- a/lib/app/modules/petugas_bumdes/controllers/petugas_paket_controller.dart +++ b/lib/app/modules/petugas_bumdes/controllers/petugas_paket_controller.dart @@ -8,7 +8,7 @@ import 'package:bumrent_app/app/data/providers/aset_provider.dart'; class PetugasPaketController extends GetxController { // Dependencies final AsetProvider _asetProvider = Get.find(); - + // State final RxBool isLoading = false.obs; final RxString searchQuery = ''.obs; @@ -16,7 +16,7 @@ class PetugasPaketController extends GetxController { final RxString sortBy = 'Terbaru'.obs; final RxList packages = [].obs; final RxList filteredPackages = [].obs; - + // Sort options for the dropdown final List sortOptions = [ 'Terbaru', @@ -26,18 +26,19 @@ class PetugasPaketController extends GetxController { 'Nama A-Z', 'Nama Z-A', ]; - + // For backward compatibility final RxList> paketList = >[].obs; - final RxList> filteredPaketList = >[].obs; - + final RxList> filteredPaketList = + >[].obs; + // Logger late final Logger _logger; - + @override void onInit() { super.onInit(); - + // Initialize logger _logger = Logger( printer: PrettyPrinter( @@ -47,39 +48,42 @@ class PetugasPaketController extends GetxController { printEmojis: true, ), ); - + // Load initial data fetchPackages(); } - + /// Fetch packages from the API Future fetchPackages() async { try { isLoading.value = true; _logger.i('🔄 [fetchPackages] Fetching packages...'); - + final result = await _asetProvider.getAllPaket(); - + if (result.isEmpty) { _logger.w('ℹ️ [fetchPackages] No packages found'); packages.clear(); filteredPackages.clear(); return; } - + packages.assignAll(result); filteredPackages.assignAll(result); - + // Update legacy list for backward compatibility _updateLegacyPaketList(); - - _logger.i('✅ [fetchPackages] Successfully loaded ${result.length} packages'); - + + _logger.i( + '✅ [fetchPackages] Successfully loaded ${result.length} packages', + ); } catch (e, stackTrace) { - _logger.e('❌ [fetchPackages] Error fetching packages', - error: e, - stackTrace: stackTrace); - + _logger.e( + '❌ [fetchPackages] Error fetching packages', + error: e, + stackTrace: stackTrace, + ); + Get.snackbar( 'Error', 'Gagal memuat data paket. Silakan coba lagi.', @@ -91,97 +95,113 @@ class PetugasPaketController extends GetxController { isLoading.value = false; } } - + /// Update legacy paketList for backward compatibility void _updateLegacyPaketList() { try { _logger.d('🔄 [_updateLegacyPaketList] Updating legacy paketList...'); - - final List> legacyList = packages.map((pkg) { - return { - 'id': pkg.id, - 'nama': pkg.nama, - 'deskripsi': pkg.deskripsi, - 'harga': pkg.harga, - 'kuantitas': pkg.kuantitas, - 'status': pkg.status, // Add status to legacy mapping - 'foto': pkg.foto, - 'foto_paket': pkg.foto_paket, - 'images': pkg.images, - 'satuanWaktuSewa': pkg.satuanWaktuSewa, - 'created_at': pkg.createdAt, - 'updated_at': pkg.updatedAt, - }; - }).toList(); - + + final List> legacyList = + packages.map((pkg) { + return { + 'id': pkg.id, + 'nama': pkg.nama, + 'deskripsi': pkg.deskripsi, + 'harga': pkg.harga, + 'kuantitas': pkg.kuantitas, + 'status': pkg.status, // Add status to legacy mapping + 'foto': pkg.foto, + 'foto_paket': pkg.foto_paket, + 'images': pkg.images, + 'satuanWaktuSewa': pkg.satuanWaktuSewa, + 'created_at': pkg.createdAt, + 'updated_at': pkg.updatedAt, + }; + }).toList(); + paketList.assignAll(legacyList); filteredPaketList.assignAll(legacyList); - - _logger.d('✅ [_updateLegacyPaketList] Updated ${legacyList.length} packages'); - + + _logger.d( + '✅ [_updateLegacyPaketList] Updated ${legacyList.length} packages', + ); } catch (e, stackTrace) { - _logger.e('❌ [_updateLegacyPaketList] Error updating legacy list', - error: e, - stackTrace: stackTrace); + _logger.e( + '❌ [_updateLegacyPaketList] Error updating legacy list', + error: e, + stackTrace: stackTrace, + ); } } - + /// For backward compatibility Future loadPaketData() async { _logger.d('ℹ️ [loadPaketData] Using fetchPackages() instead'); await fetchPackages(); } - + /// Filter packages based on search query and category void filterPaket() { try { _logger.d('🔄 [filterPaket] Filtering packages...'); - + if (searchQuery.value.isEmpty && selectedCategory.value == 'Semua') { filteredPackages.value = List.from(packages); filteredPaketList.value = List.from(paketList); } else { // Filter new packages - filteredPackages.value = packages.where((paket) { - final matchesSearch = searchQuery.value.isEmpty || - paket.nama.toLowerCase().contains(searchQuery.value.toLowerCase()); - - // For now, we're not using categories in the new model - // You can add category filtering if needed - final matchesCategory = selectedCategory.value == 'Semua'; - - return matchesSearch && matchesCategory; - }).toList(); - + filteredPackages.value = + packages.where((paket) { + final matchesSearch = + searchQuery.value.isEmpty || + paket.nama.toLowerCase().contains( + searchQuery.value.toLowerCase(), + ); + + // For now, we're not using categories in the new model + // You can add category filtering if needed + final matchesCategory = selectedCategory.value == 'Semua'; + + return matchesSearch && matchesCategory; + }).toList(); + // Also update legacy list for backward compatibility - filteredPaketList.value = paketList.where((paket) { - final matchesSearch = searchQuery.value.isEmpty || - (paket['nama']?.toString() ?? '').toLowerCase() - .contains(searchQuery.value.toLowerCase()); - - // For legacy support, check if category exists - final matchesCategory = selectedCategory.value == 'Semua' || - (paket['kategori']?.toString() ?? '') == selectedCategory.value; - - return matchesSearch && matchesCategory; - }).toList(); + filteredPaketList.value = + paketList.where((paket) { + final matchesSearch = + searchQuery.value.isEmpty || + (paket['nama']?.toString() ?? '').toLowerCase().contains( + searchQuery.value.toLowerCase(), + ); + + // For legacy support, check if category exists + final matchesCategory = + selectedCategory.value == 'Semua' || + (paket['kategori']?.toString() ?? '') == + selectedCategory.value; + + return matchesSearch && matchesCategory; + }).toList(); } - + sortFilteredList(); - _logger.d('✅ [filterPaket] Filtered to ${filteredPackages.length} packages'); - + _logger.d( + '✅ [filterPaket] Filtered to ${filteredPackages.length} packages', + ); } catch (e, stackTrace) { - _logger.e('❌ [filterPaket] Error filtering packages', - error: e, - stackTrace: stackTrace); + _logger.e( + '❌ [filterPaket] Error filtering packages', + error: e, + stackTrace: stackTrace, + ); } } - + /// Sort the filtered list based on the selected sort option void sortFilteredList() { try { _logger.d('🔄 [sortFilteredList] Sorting packages by ${sortBy.value}'); - + // Sort new packages switch (sortBy.value) { case 'Terbaru': @@ -203,44 +223,63 @@ class PetugasPaketController extends GetxController { filteredPackages.sort((a, b) => b.nama.compareTo(a.nama)); break; } - + // Also sort legacy list for backward compatibility switch (sortBy.value) { case 'Terbaru': - filteredPaketList.sort((a, b) => - ((b['created_at'] ?? '') as String).compareTo((a['created_at'] ?? '') as String)); + filteredPaketList.sort( + (a, b) => ((b['created_at'] ?? '') as String).compareTo( + (a['created_at'] ?? '') as String, + ), + ); break; case 'Terlama': - filteredPaketList.sort((a, b) => - ((a['created_at'] ?? '') as String).compareTo((b['created_at'] ?? '') as String)); + filteredPaketList.sort( + (a, b) => ((a['created_at'] ?? '') as String).compareTo( + (b['created_at'] ?? '') as String, + ), + ); break; case 'Harga Tertinggi': - filteredPaketList.sort((a, b) => - ((b['harga'] ?? 0) as int).compareTo((a['harga'] ?? 0) as int)); + filteredPaketList.sort( + (a, b) => + ((b['harga'] ?? 0) as int).compareTo((a['harga'] ?? 0) as int), + ); break; case 'Harga Terendah': - filteredPaketList.sort((a, b) => - ((a['harga'] ?? 0) as int).compareTo((b['harga'] ?? 0) as int)); + filteredPaketList.sort( + (a, b) => + ((a['harga'] ?? 0) as int).compareTo((b['harga'] ?? 0) as int), + ); break; case 'Nama A-Z': - filteredPaketList.sort((a, b) => - ((a['nama'] ?? '') as String).compareTo((b['nama'] ?? '') as String)); + filteredPaketList.sort( + (a, b) => ((a['nama'] ?? '') as String).compareTo( + (b['nama'] ?? '') as String, + ), + ); break; case 'Nama Z-A': - filteredPaketList.sort((a, b) => - ((b['nama'] ?? '') as String).compareTo((a['nama'] ?? '') as String)); + filteredPaketList.sort( + (a, b) => ((b['nama'] ?? '') as String).compareTo( + (a['nama'] ?? '') as String, + ), + ); break; } - - _logger.d('✅ [sortFilteredList] Sorted ${filteredPackages.length} packages'); - + + _logger.d( + '✅ [sortFilteredList] Sorted ${filteredPackages.length} packages', + ); } catch (e, stackTrace) { - _logger.e('❌ [sortFilteredList] Error sorting packages', - error: e, - stackTrace: stackTrace); + _logger.e( + '❌ [sortFilteredList] Error sorting packages', + error: e, + stackTrace: stackTrace, + ); } } - + // Set search query dan filter paket void setSearchQuery(String query) { searchQuery.value = query; @@ -263,7 +302,7 @@ class PetugasPaketController extends GetxController { Future addPaket(Map paketData) async { try { isLoading.value = true; - + // Convert to PaketModel final newPaket = PaketModel.fromJson({ ...paketData, @@ -271,12 +310,12 @@ class PetugasPaketController extends GetxController { 'created_at': DateTime.now().toIso8601String(), 'updated_at': DateTime.now().toIso8601String(), }); - + // Add to the list packages.add(newPaket); _updateLegacyPaketList(); filterPaket(); - + Get.back(); Get.snackbar( 'Sukses', @@ -285,12 +324,13 @@ class PetugasPaketController extends GetxController { backgroundColor: Colors.green, colorText: Colors.white, ); - } catch (e, stackTrace) { - _logger.e('❌ [addPaket] Error adding package', - error: e, - stackTrace: stackTrace); - + _logger.e( + '❌ [addPaket] Error adding package', + error: e, + stackTrace: stackTrace, + ); + Get.snackbar( 'Error', 'Gagal menambahkan paket. Silakan coba lagi.', @@ -307,23 +347,28 @@ class PetugasPaketController extends GetxController { Future editPaket(String id, Map updatedData) async { try { isLoading.value = true; - + final index = packages.indexWhere((pkg) => pkg.id == id); if (index >= 0) { // Update the package final updatedPaket = packages[index].copyWith( nama: updatedData['nama']?.toString() ?? packages[index].nama, - deskripsi: updatedData['deskripsi']?.toString() ?? packages[index].deskripsi, - kuantitas: (updatedData['kuantitas'] is int) - ? updatedData['kuantitas'] - : (int.tryParse(updatedData['kuantitas']?.toString() ?? '0') ?? packages[index].kuantitas), + deskripsi: + updatedData['deskripsi']?.toString() ?? packages[index].deskripsi, + kuantitas: + (updatedData['kuantitas'] is int) + ? updatedData['kuantitas'] + : (int.tryParse( + updatedData['kuantitas']?.toString() ?? '0', + ) ?? + packages[index].kuantitas), updatedAt: DateTime.now(), ); - + packages[index] = updatedPaket; _updateLegacyPaketList(); filterPaket(); - + Get.back(); Get.snackbar( 'Sukses', @@ -334,10 +379,12 @@ class PetugasPaketController extends GetxController { ); } } catch (e, stackTrace) { - _logger.e('❌ [editPaket] Error updating package', - error: e, - stackTrace: stackTrace); - + _logger.e( + '❌ [editPaket] Error updating package', + error: e, + stackTrace: stackTrace, + ); + Get.snackbar( 'Error', 'Gagal memperbarui paket. Silakan coba lagi.', @@ -353,39 +400,76 @@ class PetugasPaketController extends GetxController { // Hapus paket Future deletePaket(String id) async { try { - isLoading.value = true; - - // Remove from the main list - packages.removeWhere((pkg) => pkg.id == id); - _updateLegacyPaketList(); - filterPaket(); - - Get.back(); - Get.snackbar( - 'Sukses', - 'Paket berhasil dihapus', - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.green, - colorText: Colors.white, + _logger.i( + '🔄 [deletePaket] Starting deletion process for package ID: $id', ); - + + // Show a loading dialog + Get.dialog( + const Center(child: CircularProgressIndicator()), + barrierDismissible: false, + ); + + // Call the provider to delete the package and all related data from Supabase + final success = await _asetProvider.deletePaket(id); + + // Close the loading dialog + Get.back(); + + if (success) { + _logger.i('✅ [deletePaket] Package deleted successfully from database'); + + // Remove the package from the UI lists + packages.removeWhere((pkg) => pkg.id == id); + _updateLegacyPaketList(); + filterPaket(); + + // Show success message + Get.snackbar( + 'Sukses', + 'Paket berhasil dihapus dari sistem', + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.green, + colorText: Colors.white, + duration: const Duration(seconds: 3), + ); + } else { + _logger.e('❌ [deletePaket] Failed to delete package from database'); + + // Show error message + Get.snackbar( + 'Gagal', + 'Terjadi kesalahan saat menghapus paket', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + duration: const Duration(seconds: 3), + ); + } } catch (e, stackTrace) { - _logger.e('❌ [deletePaket] Error deleting package', - error: e, - stackTrace: stackTrace); - + _logger.e( + '❌ [deletePaket] Error deleting package', + error: e, + stackTrace: stackTrace, + ); + + // Close the loading dialog if still open + if (Get.isDialogOpen ?? false) { + Get.back(); + } + + // Show error message Get.snackbar( 'Error', - 'Gagal menghapus paket. Silakan coba lagi.', + 'Gagal menghapus paket: ${e.toString()}', snackPosition: SnackPosition.BOTTOM, backgroundColor: Colors.red, colorText: Colors.white, + duration: const Duration(seconds: 3), ); - } finally { - isLoading.value = false; } } - + /// Format price to Rupiah currency String formatPrice(num price) { return 'Rp ${NumberFormat('#,##0', 'id_ID').format(price)}'; diff --git a/lib/app/modules/petugas_bumdes/controllers/petugas_penyewa_controller.dart b/lib/app/modules/petugas_bumdes/controllers/petugas_penyewa_controller.dart new file mode 100644 index 0000000..ad0e8bb --- /dev/null +++ b/lib/app/modules/petugas_bumdes/controllers/petugas_penyewa_controller.dart @@ -0,0 +1,196 @@ +import 'package:get/get.dart'; +import '../../../data/providers/auth_provider.dart'; + +class PetugasPenyewaController extends GetxController { + final AuthProvider _authProvider = Get.find(); + + // Reactive variables + final isLoading = true.obs; + final penyewaList = >[].obs; + final filteredPenyewaList = >[].obs; + final filterStatus = 'all'.obs; + final currentTabIndex = 0.obs; + final searchQuery = ''.obs; + + @override + void onInit() { + super.onInit(); + fetchPenyewaList(); + } + + @override + void onReady() { + super.onReady(); + // Refresh data when the page is first loaded + refreshData(); + } + + // Method to refresh data when returning to the page + void refreshData() { + fetchPenyewaList(); + } + + void changeTab(int index) { + currentTabIndex.value = index; + applyFilters(); + } + + void updateSearchQuery(String query) { + searchQuery.value = query; + applyFilters(); + } + + void applyFilters() { + if (penyewaList.isEmpty) return; + + // First apply status filter based on current tab + String statusFilter; + switch (currentTabIndex.value) { + case 0: // Verifikasi + statusFilter = 'pending'; + break; + case 1: // Aktif + statusFilter = 'active'; + break; + case 2: // Ditangguhkan + statusFilter = 'suspended'; + break; + default: + statusFilter = 'all'; + } + + // Filter by status + var result = + statusFilter == 'all' + ? penyewaList + : penyewaList + .where((p) => p['status']?.toLowerCase() == statusFilter) + .toList(); + + // Then apply search filter if there's a query + if (searchQuery.value.isNotEmpty) { + final query = searchQuery.value.toLowerCase(); + result = + result + .where( + (p) => + (p['nama_lengkap']?.toString().toLowerCase().contains( + query, + ) ?? + false) || + (p['email']?.toString().toLowerCase().contains(query) ?? + false), + ) + .toList(); + } + + filteredPenyewaList.value = result; + } + + Future fetchPenyewaList() async { + try { + isLoading.value = true; + + // Get all penyewa data without filtering + final data = + await _authProvider.client + .from('warga_desa') + .select( + 'user_id, nama_lengkap, email, nik, no_hp, avatar, status, keterangan', + ) + as List; + + // Filter out rows where user_id is null + final filteredData = data.where((row) => row['user_id'] != null).toList(); + + // Get total sewa count for each user + final enrichedData = await _enrichWithSewaCount(filteredData); + + penyewaList.value = enrichedData; + + // Apply filters to update filteredPenyewaList + applyFilters(); + } catch (e) { + print('Error fetching penyewa list: $e'); + penyewaList.value = []; + filteredPenyewaList.value = []; + } finally { + isLoading.value = false; + } + } + + Future>> _enrichWithSewaCount( + List penyewaData, + ) async { + final result = >[]; + + for (var penyewa in penyewaData) { + final userId = penyewa['user_id']; + + // Count total sewa for this user + final sewaCount = await _countUserSewa(userId); + + // Create a new map with all the original data plus the total_sewa count + final enrichedPenyewa = Map.from(penyewa); + enrichedPenyewa['total_sewa'] = sewaCount; + + result.add(enrichedPenyewa); + } + + return result; + } + + Future _countUserSewa(String userId) async { + try { + final response = await _authProvider.client + .from('sewa_aset') + .select('id') + .eq('user_id', userId); + + return (response as List).length; + } catch (e) { + print('Error counting sewa for user $userId: $e'); + return 0; + } + } + + void viewPenyewaDetail(String userId) { + // Navigate to penyewa detail page (to be implemented) + print('View detail for penyewa with ID: $userId'); + + // Get.toNamed(Routes.PETUGAS_PENYEWA_DETAIL, arguments: {'user_id': userId}); + } + + void updatePenyewaStatus( + String userId, + String newStatus, + String keterangan, + ) async { + try { + isLoading.value = true; + + await _authProvider.client + .from('warga_desa') + .update({'status': newStatus, 'keterangan': keterangan}) + .eq('user_id', userId); + + // Refresh the list + await fetchPenyewaList(); + + Get.snackbar( + 'Berhasil', + 'Status penyewa berhasil diperbarui', + snackPosition: SnackPosition.BOTTOM, + ); + } catch (e) { + print('Error updating penyewa status: $e'); + Get.snackbar( + 'Gagal', + 'Terjadi kesalahan saat memperbarui status penyewa', + snackPosition: SnackPosition.BOTTOM, + ); + } finally { + isLoading.value = false; + } + } +} diff --git a/lib/app/modules/petugas_bumdes/controllers/petugas_sewa_controller.dart b/lib/app/modules/petugas_bumdes/controllers/petugas_sewa_controller.dart index 377f019..37600ec 100644 --- a/lib/app/modules/petugas_bumdes/controllers/petugas_sewa_controller.dart +++ b/lib/app/modules/petugas_bumdes/controllers/petugas_sewa_controller.dart @@ -50,22 +50,24 @@ class PetugasSewaController extends GetxController { void _updateFilteredList() { filteredSewaList.value = sewaList.where((sewa) { - final query = searchQuery.value.toLowerCase(); - // Apply search filter: nama warga, id pesanan, atau asetId - final matchesSearch = - sewa.wargaNama.toLowerCase().contains(query) || - sewa.id.toLowerCase().contains(query) || - (sewa.asetId != null && - sewa.asetId!.toLowerCase().contains(query)); + final query = searchQuery.value.toLowerCase(); + // Apply search filter: nama warga, id pesanan, atau asetId + final matchesSearch = + sewa.wargaNama.toLowerCase().contains(query) || + sewa.id.toLowerCase().contains(query) || + (sewa.asetId != null && + sewa.asetId!.toLowerCase().contains(query)); - // Apply status filter if not 'Semua' - final matchesStatus = - selectedStatusFilter.value == 'Semua' || - sewa.status.toUpperCase() == - selectedStatusFilter.value.toUpperCase(); + // Apply status filter if not 'Semua' + final matchesStatus = + selectedStatusFilter.value == 'Semua' || + sewa.status.toUpperCase() == + selectedStatusFilter.value.toUpperCase(); - return matchesSearch && matchesStatus; - }).toList(); + return matchesSearch && matchesStatus; + }).toList() + // Sort filtered results by tanggal_pemesanan in descending order (newest first) + ..sort((a, b) => b.tanggalPemesanan.compareTo(a.tanggalPemesanan)); } // Load sewa data (mock data for now) @@ -74,6 +76,8 @@ class PetugasSewaController extends GetxController { try { final data = await SewaService().fetchAllSewa(); + // Sort data by tanggal_pemesanan in descending order (newest first) + data.sort((a, b) => b.tanggalPemesanan.compareTo(a.tanggalPemesanan)); sewaList.assignAll(data); } catch (e) { print('Error loading sewa data: $e'); @@ -101,23 +105,27 @@ class PetugasSewaController extends GetxController { void resetFilters() { selectedStatusFilter.value = 'Semua'; searchQuery.value = ''; - filteredSewaList.value = sewaList; + // Assign a sorted copy of sewaList to filteredSewaList + filteredSewaList.value = List.from(sewaList) + ..sort((a, b) => b.tanggalPemesanan.compareTo(a.tanggalPemesanan)); } void applyFilters() { filteredSewaList.value = sewaList.where((sewa) { - bool matchesStatus = - selectedStatusFilter.value == 'Semua' || - sewa.status.toUpperCase() == - selectedStatusFilter.value.toUpperCase(); - bool matchesSearch = - searchQuery.value.isEmpty || - sewa.wargaNama.toLowerCase().contains( - searchQuery.value.toLowerCase(), - ); - return matchesStatus && matchesSearch; - }).toList(); + bool matchesStatus = + selectedStatusFilter.value == 'Semua' || + sewa.status.toUpperCase() == + selectedStatusFilter.value.toUpperCase(); + bool matchesSearch = + searchQuery.value.isEmpty || + sewa.wargaNama.toLowerCase().contains( + searchQuery.value.toLowerCase(), + ); + return matchesStatus && matchesSearch; + }).toList() + // Sort filtered results by tanggal_pemesanan in descending order (newest first) + ..sort((a, b) => b.tanggalPemesanan.compareTo(a.tanggalPemesanan)); } // Format price to rupiah diff --git a/lib/app/modules/petugas_bumdes/views/petugas_akun_bank_view.dart b/lib/app/modules/petugas_bumdes/views/petugas_akun_bank_view.dart new file mode 100644 index 0000000..388abe5 --- /dev/null +++ b/lib/app/modules/petugas_bumdes/views/petugas_akun_bank_view.dart @@ -0,0 +1,365 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/petugas_akun_bank_controller.dart'; +import '../controllers/petugas_bumdes_dashboard_controller.dart'; +import '../widgets/petugas_side_navbar.dart'; +import '../../../theme/app_colors_petugas.dart'; + +class PetugasAkunBankView extends GetView { + const PetugasAkunBankView({super.key}); + + @override + Widget build(BuildContext context) { + // Get dashboard controller for side navbar + final dashboardController = Get.find(); + + return Scaffold( + appBar: AppBar( + title: const Text('Kelola Akun Bank'), + backgroundColor: AppColorsPetugas.navyBlue, + foregroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Get.back(), + ), + ), + body: Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + + if (controller.errorMessage.value.isNotEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 48, color: Colors.red[300]), + const SizedBox(height: 16), + Text( + controller.errorMessage.value, + style: const TextStyle(color: Colors.red), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: controller.loadBankAccounts, + child: const Text('Coba Lagi'), + ), + ], + ), + ); + } + + if (controller.bankAccounts.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.account_balance, size: 48, color: Colors.grey[400]), + const SizedBox(height: 16), + Text( + 'Belum ada akun bank', + style: TextStyle(fontSize: 18, color: Colors.grey[600]), + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: () => _showAddEditBankAccountDialog(context), + icon: const Icon(Icons.add), + label: const Text('Tambah Akun Bank'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.blueGrotto, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + ), + ], + ), + ); + } + + return Stack( + children: [ + RefreshIndicator( + onRefresh: controller.loadBankAccounts, + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: controller.bankAccounts.length, + itemBuilder: (context, index) { + final account = controller.bankAccounts[index]; + return _buildBankAccountCard(context, account); + }, + ), + ), + Positioned( + bottom: 16, + right: 16, + child: FloatingActionButton( + onPressed: () => _showAddEditBankAccountDialog(context), + backgroundColor: AppColorsPetugas.blueGrotto, + child: const Icon(Icons.add), + ), + ), + ], + ); + }), + ); + } + + Widget _buildBankAccountCard( + BuildContext context, + Map account, + ) { + return Card( + margin: const EdgeInsets.only(bottom: 16), + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColorsPetugas.blueGrotto.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.account_balance, + color: AppColorsPetugas.blueGrotto, + size: 24, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + account['nama_bank'] ?? '', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + account['nama_akun'] ?? '', + style: TextStyle(fontSize: 14, color: Colors.grey[700]), + ), + ], + ), + ), + PopupMenuButton( + onSelected: (value) { + if (value == 'edit') { + _showAddEditBankAccountDialog(context, account); + } else if (value == 'delete') { + _showDeleteConfirmationDialog(context, account); + } + }, + itemBuilder: + (context) => [ + const PopupMenuItem( + value: 'edit', + child: Row( + children: [ + Icon(Icons.edit, size: 18), + SizedBox(width: 8), + Text('Edit'), + ], + ), + ), + const PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Icon(Icons.delete, size: 18, color: Colors.red), + SizedBox(width: 8), + Text( + 'Hapus', + style: TextStyle(color: Colors.red), + ), + ], + ), + ), + ], + ), + ], + ), + const Divider(height: 24), + Row( + children: [ + const Icon(Icons.credit_card, size: 16, color: Colors.grey), + const SizedBox(width: 8), + Text( + 'No. Rekening: ${account['no_rekening'] ?? ''}', + style: TextStyle(fontSize: 14, color: Colors.grey[700]), + ), + ], + ), + ], + ), + ), + ); + } + + void _showAddEditBankAccountDialog( + BuildContext context, [ + Map? account, + ]) { + final isEditing = account != null; + final formKey = GlobalKey(); + + String bankName = account?['nama_bank'] ?? ''; + String accountName = account?['nama_akun'] ?? ''; + String accountNumber = account?['no_rekening'] ?? ''; + + Get.dialog( + Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isEditing ? 'Edit Akun Bank' : 'Tambah Akun Bank', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 24), + Form( + key: formKey, + child: Column( + children: [ + TextFormField( + initialValue: bankName, + decoration: const InputDecoration( + labelText: 'Nama Bank', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.account_balance), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Nama bank tidak boleh kosong'; + } + return null; + }, + onChanged: (value) => bankName = value, + ), + const SizedBox(height: 16), + TextFormField( + initialValue: accountName, + decoration: const InputDecoration( + labelText: 'Nama Pemilik Rekening', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Nama pemilik rekening tidak boleh kosong'; + } + return null; + }, + onChanged: (value) => accountName = value, + ), + const SizedBox(height: 16), + TextFormField( + initialValue: accountNumber, + decoration: const InputDecoration( + labelText: 'Nomor Rekening', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.credit_card), + ), + keyboardType: TextInputType.number, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Nomor rekening tidak boleh kosong'; + } + return null; + }, + onChanged: (value) => accountNumber = value, + ), + ], + ), + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Get.back(), + child: const Text('Batal'), + ), + const SizedBox(width: 16), + ElevatedButton( + onPressed: () { + if (formKey.currentState!.validate()) { + final accountData = { + 'nama_bank': bankName, + 'nama_akun': accountName, + 'no_rekening': accountNumber, + }; + + if (isEditing) { + controller.updateBankAccount( + account['id'], + accountData, + ); + } else { + controller.addBankAccount(accountData); + } + + Get.back(); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.blueGrotto, + foregroundColor: Colors.white, + ), + child: Text(isEditing ? 'Simpan' : 'Tambah'), + ), + ], + ), + ], + ), + ), + ), + ); + } + + void _showDeleteConfirmationDialog( + BuildContext context, + Map account, + ) { + Get.dialog( + AlertDialog( + title: const Text('Konfirmasi Hapus'), + content: Text( + 'Apakah Anda yakin ingin menghapus akun bank ${account['nama_bank']} - ${account['nama_akun']}?', + ), + actions: [ + TextButton(onPressed: () => Get.back(), child: const Text('Batal')), + TextButton( + onPressed: () { + controller.deleteBankAccount(account['id']); + Get.back(); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Hapus'), + ), + ], + ), + ); + } +} diff --git a/lib/app/modules/petugas_bumdes/views/petugas_aset_view.dart b/lib/app/modules/petugas_bumdes/views/petugas_aset_view.dart index 71c20d2..cc70fad 100644 --- a/lib/app/modules/petugas_bumdes/views/petugas_aset_view.dart +++ b/lib/app/modules/petugas_bumdes/views/petugas_aset_view.dart @@ -267,7 +267,6 @@ class _PetugasAsetViewState extends State child: Material( color: Colors.transparent, child: InkWell( - onTap: () => _showAssetDetails(context, aset), child: Row( children: [ // Asset image @@ -671,366 +670,11 @@ class _PetugasAsetViewState extends State } } - void _showAssetDetails(BuildContext context, Map aset) { - final isAvailable = aset['tersedia'] == true; - - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) { - return Container( - height: MediaQuery.of(context).size.height * 0.85, - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Header with gradient - Container( - padding: const EdgeInsets.fromLTRB(24, 16, 24, 24), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - AppColorsPetugas.blueGrotto, - AppColorsPetugas.navyBlue, - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: const BorderRadius.vertical( - top: Radius.circular(20), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Close button and availability badge - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: - isAvailable - ? AppColorsPetugas.successLight - : AppColorsPetugas.errorLight, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: - isAvailable - ? AppColorsPetugas.success - : AppColorsPetugas.error, - width: 1, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - isAvailable ? Icons.check_circle : Icons.cancel, - color: - isAvailable - ? AppColorsPetugas.success - : AppColorsPetugas.error, - size: 16, - ), - const SizedBox(width: 4), - Text( - isAvailable ? 'Tersedia' : 'Tidak Tersedia', - style: TextStyle( - fontSize: 12, - color: - isAvailable - ? AppColorsPetugas.success - : AppColorsPetugas.error, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - GestureDetector( - onTap: () => Navigator.pop(context), - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.3), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.close, - color: Colors.white, - size: 20, - ), - ), - ), - ], - ), - - const SizedBox(height: 16), - - // Category badge - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 4, - ), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.3), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - aset['kategori'], - style: const TextStyle( - fontSize: 12, - color: Colors.white, - fontWeight: FontWeight.w500, - ), - ), - ), - - const SizedBox(height: 12), - - // Asset name - Text( - aset['nama'], - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - - const SizedBox(height: 8), - - // Price - Row( - children: [ - const Icon( - Icons.monetization_on, - color: Colors.white, - size: 18, - ), - const SizedBox(width: 8), - Text( - '${controller.formatPrice(aset['harga'])} ${aset['satuan']}', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - ], - ), - ], - ), - ), - - // Asset details - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Quick info cards - Row( - children: [ - _buildInfoCard( - Icons.inventory_2, - 'Stok', - '${aset['stok']} unit', - flex: 1, - ), - const SizedBox(width: 16), - _buildInfoCard( - Icons.category, - 'Jenis', - aset['jenis'], - flex: 1, - ), - ], - ), - - const SizedBox(height: 24), - - // Description section - Text( - 'Deskripsi', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: AppColorsPetugas.navyBlue, - ), - ), - const SizedBox(height: 8), - Text( - aset['deskripsi'], - style: TextStyle( - fontSize: 14, - color: AppColorsPetugas.textPrimary, - height: 1.5, - ), - ), - - const SizedBox(height: 32), - ], - ), - ), - ), - - // Action buttons - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - boxShadow: [ - BoxShadow( - color: AppColorsPetugas.shadowColor, - blurRadius: 10, - offset: const Offset(0, -5), - ), - ], - ), - child: Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () { - Navigator.pop(context); - _showAddEditAssetDialog(context, aset: aset); - }, - icon: const Icon(Icons.edit), - label: const Text('Edit'), - style: OutlinedButton.styleFrom( - foregroundColor: AppColorsPetugas.blueGrotto, - side: BorderSide(color: AppColorsPetugas.blueGrotto), - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: ElevatedButton.icon( - onPressed: () { - Navigator.pop(context); - _showDeleteConfirmation(context, aset); - }, - icon: const Icon(Icons.delete), - label: const Text('Hapus'), - style: ElevatedButton.styleFrom( - backgroundColor: AppColorsPetugas.error, - foregroundColor: Colors.white, - elevation: 0, - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), - ), - ], - ), - ), - ], - ), - ); - }, - ); - } - - Widget _buildInfoCard( - IconData icon, - String label, - String value, { - int flex = 1, - }) { - return Expanded( - flex: flex, - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColorsPetugas.babyBlueBright, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColorsPetugas.babyBlue), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(icon, size: 16, color: AppColorsPetugas.blueGrotto), - const SizedBox(width: 8), - Text( - label, - style: TextStyle( - fontSize: 12, - color: AppColorsPetugas.textSecondary, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - value, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: AppColorsPetugas.navyBlue, - ), - ), - ], - ), - ), - ); - } - - Widget _buildDetailItem(String label, String value) { - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: TextStyle( - fontSize: 13, - color: AppColorsPetugas.textSecondary, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 6), - Text( - value, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: AppColorsPetugas.textPrimary, - ), - ), - ], - ), - ); - } - void _showAddEditAssetDialog( BuildContext context, { Map? aset, }) { final isEditing = aset != null; - final jenisOptions = ['Sewa', 'Langganan']; - final typeOptions = ['Elektronik', 'Furniture', 'Kendaraan', 'Lainnya']; // In a real app, this would have proper form handling with controllers showDialog( @@ -1333,22 +977,11 @@ class _PetugasAsetViewState extends State const SizedBox(width: 16), Expanded( child: ElevatedButton( - onPressed: () { + onPressed: () async { Navigator.pop(context); - controller.deleteAset(aset['id']); - Get.snackbar( - 'Aset Dihapus', - 'Aset berhasil dihapus dari sistem', - backgroundColor: AppColorsPetugas.error, - colorText: Colors.white, - snackPosition: SnackPosition.BOTTOM, - margin: const EdgeInsets.all(16), - borderRadius: 10, - icon: const Icon( - Icons.check_circle, - color: Colors.white, - ), - ); + // Let the controller handle the deletion and showing the snackbar + await controller.deleteAset(aset['id']); + // The controller will show appropriate success or error messages }, style: ElevatedButton.styleFrom( backgroundColor: AppColorsPetugas.error, diff --git a/lib/app/modules/petugas_bumdes/views/petugas_bumdes_cbp_view.dart b/lib/app/modules/petugas_bumdes/views/petugas_bumdes_cbp_view.dart index 81264c2..ea5d50b 100644 --- a/lib/app/modules/petugas_bumdes/views/petugas_bumdes_cbp_view.dart +++ b/lib/app/modules/petugas_bumdes/views/petugas_bumdes_cbp_view.dart @@ -327,7 +327,7 @@ class PetugasBumdesCbpView extends GetView { leading: const Icon(Icons.subscriptions_outlined), title: const Text('Kelola Langganan'), onTap: () { - Get.offAllNamed(Routes.PETUGAS_LANGGANAN); + Get.offAllNamed(Routes.PETUGAS_BUMDES_DASHBOARD); }, ), ListTile( diff --git a/lib/app/modules/petugas_bumdes/views/petugas_bumdes_dashboard_view.dart b/lib/app/modules/petugas_bumdes/views/petugas_bumdes_dashboard_view.dart index 7f48e1e..b4c66f8 100644 --- a/lib/app/modules/petugas_bumdes/views/petugas_bumdes_dashboard_view.dart +++ b/lib/app/modules/petugas_bumdes/views/petugas_bumdes_dashboard_view.dart @@ -6,6 +6,7 @@ import '../widgets/petugas_bumdes_bottom_navbar.dart'; import '../widgets/petugas_side_navbar.dart'; import '../../../theme/app_colors_petugas.dart'; import '../../../utils/format_utils.dart'; +import '../views/petugas_penyewa_view.dart'; class PetugasBumdesDashboardView extends GetView { @@ -64,6 +65,8 @@ class PetugasBumdesDashboardView case 3: return 'Permintaan Sewa'; case 4: + return 'Penyewa'; + case 5: return 'Profil BUMDes'; default: return 'Dashboard Petugas BUMDES'; @@ -81,6 +84,8 @@ class PetugasBumdesDashboardView case 3: return _buildSewaTab(); case 4: + return const PetugasPenyewaView(); + case 5: return _buildBumdesTab(); default: return _buildDashboardTab(); @@ -96,6 +101,16 @@ class PetugasBumdesDashboardView _buildWelcomeCard(), const SizedBox(height: 24), + // Tenant Statistics Section + _buildSectionHeader( + 'Statistik Penyewa', + AppColorsPetugas.blueGrotto, + Icons.people_outline, + ), + _buildTenantStatistics(), + + const SizedBox(height: 24), + // Detail Status Sewa Aset section with improved header _buildSectionHeader( 'Detail Status Sewa Aset', @@ -771,33 +786,29 @@ class PetugasBumdesDashboardView } Widget _buildRevenueSummary() { - return Row( + return Column( children: [ - Expanded( - child: Obx(() { - final stats = controller.pembayaranStats; - final totalTunai = stats['totalTunai'] ?? 0.0; - return _buildRevenueQuickInfo( - 'Tunai', - formatRupiah(totalTunai), - AppColorsPetugas.navyBlue, - Icons.payments, - ); - }), - ), - const SizedBox(width: 12), - Expanded( - child: Obx(() { - final stats = controller.pembayaranStats; - final totalTransfer = stats['totalTransfer'] ?? 0.0; - return _buildRevenueQuickInfo( - 'Transfer', - formatRupiah(totalTransfer), - AppColorsPetugas.blueGrotto, - Icons.account_balance, - ); - }), - ), + Obx(() { + final stats = controller.pembayaranStats; + final totalTunai = stats['totalTunai'] ?? 0.0; + return _buildRevenueQuickInfo( + 'Tunai', + formatRupiah(totalTunai), + AppColorsPetugas.navyBlue, + Icons.payments, + ); + }), + const SizedBox(height: 12), + Obx(() { + final stats = controller.pembayaranStats; + final totalTransfer = stats['totalTransfer'] ?? 0.0; + return _buildRevenueQuickInfo( + 'Transfer', + formatRupiah(totalTransfer), + AppColorsPetugas.blueGrotto, + Icons.account_balance, + ); + }), ], ); } @@ -976,7 +987,13 @@ class PetugasBumdesDashboardView children: [ Container( width: 35, - height: 170 * percentage, + height: + percentage.isNaN || percentage <= 0 + ? 10.0 + : (170 * percentage).clamp( + 10.0, + 170.0, + ), decoration: BoxDecoration( borderRadius: const BorderRadius.vertical( @@ -1243,6 +1260,288 @@ class PetugasBumdesDashboardView ), ); } + + // New widget for tenant statistics + Widget _buildTenantStatistics() { + return Obx(() { + if (controller.isPenyewaStatsLoading.value) { + return const Center( + child: Padding( + padding: EdgeInsets.all(20.0), + child: CircularProgressIndicator(), + ), + ); + } + + return Card( + elevation: 2, + shadowColor: AppColorsPetugas.shadowColor, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + // Use LayoutBuilder to make the grid responsive + LayoutBuilder( + builder: (context, constraints) { + return GridView.count( + crossAxisCount: 3, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisSpacing: 14, + mainAxisSpacing: 14, + childAspectRatio: 0.75, + children: [ + _buildTenantStatusItem( + 'Menunggu Verifikasi', + controller.penyewaPendingCount.value.toString(), + AppColorsPetugas.warning, + Icons.pending_outlined, + ), + _buildTenantStatusItem( + 'Aktif', + controller.penyewaActiveCount.value.toString(), + AppColorsPetugas.success, + Icons.check_circle_outline, + ), + _buildTenantStatusItem( + 'Ditangguhkan', + controller.penyewaSuspendedCount.value.toString(), + AppColorsPetugas.error, + Icons.block_outlined, + ), + ], + ); + }, + ), + const SizedBox(height: 24), + // Tenant distribution visualization + _buildTenantDistributionBar(), + ], + ), + ), + ); + }); + } + + Widget _buildTenantStatusItem( + String title, + String value, + Color color, + IconData icon, + ) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: color.withOpacity(0.15), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ], + border: Border.all(color: color.withOpacity(0.1), width: 1), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon(icon, color: color, size: 24), + ), + const SizedBox(height: 8), + Text( + value, + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: color, + ), + ), + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text( + title, + style: TextStyle( + fontSize: 10, + height: 1.2, + color: AppColorsPetugas.textSecondary, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + + Widget _buildTenantDistributionBar() { + // Calculate the total count for all tenant statuses + final total = controller.penyewaTotalCount.value; + + // Calculate percentages for each status (avoid division by zero) + final pendingPercent = + total > 0 ? controller.penyewaPendingCount.value / total : 0.0; + final activePercent = + total > 0 ? controller.penyewaActiveCount.value / total : 0.0; + final suspendedPercent = + total > 0 ? controller.penyewaSuspendedCount.value / total : 0.0; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Distribusi Status Penyewa', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.blueGrotto, + ), + ), + const SizedBox(height: 16), + // Only show distribution bar if there are any tenants + if (total > 0) + Stack( + children: [ + // Background for the progress bar + Container( + height: 12, + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(6), + ), + ), + // Actual progress bar segments + Row( + children: [ + if (pendingPercent > 0) + _buildProgressSegment( + pendingPercent, + AppColorsPetugas.warning, + isFirst: true, + ), + if (activePercent > 0) + _buildProgressSegment( + activePercent, + AppColorsPetugas.success, + ), + if (suspendedPercent > 0) + _buildProgressSegment( + suspendedPercent, + AppColorsPetugas.error, + isLast: true, + ), + ], + ), + ], + ) + else + Container( + height: 12, + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Text( + 'Belum ada data penyewa', + style: TextStyle( + fontSize: 10, + color: AppColorsPetugas.textSecondary, + ), + ), + ), + ), + const SizedBox(height: 16), + // Use row layout for legends + Wrap( + spacing: 16, + runSpacing: 8, + children: [ + if (pendingPercent > 0 || total == 0) + _buildStatusLegend( + 'Menunggu Verifikasi', + AppColorsPetugas.warning, + pendingPercent, + ), + if (activePercent > 0 || total == 0) + _buildStatusLegend( + 'Aktif', + AppColorsPetugas.success, + activePercent, + ), + if (suspendedPercent > 0 || total == 0) + _buildStatusLegend( + 'Ditangguhkan', + AppColorsPetugas.error, + suspendedPercent, + ), + ], + ), + ], + ); + } + + Widget _buildProgressSegment( + double percentage, + Color color, { + bool isFirst = false, + bool isLast = false, + }) { + final flex = (percentage * 100).round(); + if (flex <= 0) return const SizedBox.shrink(); + + return Flexible( + flex: flex, + child: Container( + height: 12, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.horizontal( + left: isFirst ? const Radius.circular(6) : Radius.zero, + right: isLast ? const Radius.circular(6) : Radius.zero, + ), + ), + ), + ); + } + + Widget _buildStatusLegend(String text, Color color, double percentage) { + final count = (percentage * 100).round(); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 4), + Text( + '$text ${count > 0 ? '($count%)' : ''}', + style: TextStyle( + fontSize: 10, + color: Colors.black87, + fontWeight: count > 20 ? FontWeight.w500 : FontWeight.normal, + ), + ), + ], + ); + } } // Custom clipper for creating pie/donut chart segments diff --git a/lib/app/modules/petugas_bumdes/views/petugas_detail_penyewa_view.dart b/lib/app/modules/petugas_bumdes/views/petugas_detail_penyewa_view.dart new file mode 100644 index 0000000..e21eaf4 --- /dev/null +++ b/lib/app/modules/petugas_bumdes/views/petugas_detail_penyewa_view.dart @@ -0,0 +1,1234 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/petugas_detail_penyewa_controller.dart'; +import '../../../theme/app_colors_petugas.dart'; +import 'package:intl/intl.dart'; +import '../../../routes/app_routes.dart'; +import '../controllers/petugas_penyewa_controller.dart'; + +class PetugasDetailPenyewaView extends GetView { + const PetugasDetailPenyewaView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.grey.shade50, + appBar: AppBar( + title: const Text( + 'Detail Penyewa', + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 18), + ), + backgroundColor: AppColorsPetugas.navyBlue, + foregroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + // Navigate back to the petugas_penyewa page + Get.back(); + + // Get the PetugasPenyewaController and refresh the data + final penyewaController = Get.find(); + penyewaController.refreshData(); + }, + ), + actions: [ + Obx(() { + if (controller.isLoading.value) { + return const SizedBox.shrink(); + } + + final status = + controller.penyewaDetail.value['status'] + ?.toString() + .toLowerCase(); + + if (status == 'pending') { + return Row( + children: [ + _buildActionButton( + context: context, + icon: Icons.close_rounded, + color: Colors.red.shade100, + iconColor: Colors.red, + tooltip: 'Tolak', + onPressed: () => _showRejectDialog(context), + ), + const SizedBox(width: 8), + _buildActionButton( + context: context, + icon: Icons.check_rounded, + color: Colors.green.shade100, + iconColor: Colors.green, + tooltip: 'Terima', + onPressed: () => _showApproveDialog(context), + ), + const SizedBox(width: 8), + ], + ); + } else if (status == 'suspended') { + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: _buildActionButton( + context: context, + icon: Icons.check_circle_outline_rounded, + color: Colors.green.shade100, + iconColor: Colors.green, + tooltip: 'Aktifkan', + onPressed: () => _showApproveDialog(context), + ), + ); + } else if (status == 'active') { + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: _buildActionButton( + context: context, + icon: Icons.block_rounded, + color: Colors.red.shade100, + iconColor: Colors.red, + tooltip: 'Nonaktifkan', + onPressed: () => _showSuspendDialog(context), + ), + ); + } + + return const SizedBox.shrink(); + }), + ], + ), + body: Obx(() { + if (controller.isLoading.value) { + return const Center( + child: CircularProgressIndicator( + color: AppColorsPetugas.blueGrotto, + ), + ); + } + + final penyewa = controller.penyewaDetail.value; + + if (penyewa.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.person_off_rounded, + size: 64, + color: Colors.grey.shade400, + ), + const SizedBox(height: 16), + Text( + 'Data penyewa tidak ditemukan', + style: TextStyle(fontSize: 16, color: Colors.grey.shade600), + ), + ], + ), + ); + } + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildProfileHeader(penyewa), + _buildInfoCards(penyewa), + _buildRentalHistory(), + ], + ), + ); + }), + ); + } + + Widget _buildActionButton({ + required BuildContext context, + required IconData icon, + required Color color, + required Color iconColor, + required String tooltip, + required VoidCallback onPressed, + }) { + return Container( + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + child: IconButton( + icon: Icon(icon, color: iconColor), + onPressed: onPressed, + tooltip: tooltip, + splashRadius: 24, + ), + ); + } + + Widget _buildProfileHeader(Map penyewa) { + final status = penyewa['status']?.toString().toLowerCase(); + Color statusColor; + String statusText; + IconData statusIcon; + + switch (status) { + case 'active': + statusColor = Colors.green; + statusText = 'Aktif'; + statusIcon = Icons.check_circle_rounded; + break; + case 'pending': + statusColor = Colors.orange; + statusText = 'Menunggu Verifikasi'; + statusIcon = Icons.pending_rounded; + break; + case 'suspended': + statusColor = Colors.red; + statusText = 'Dinonaktifkan'; + statusIcon = Icons.block_rounded; + break; + default: + statusColor = Colors.grey; + statusText = 'Tidak diketahui'; + statusIcon = Icons.help_rounded; + } + + return Stack( + clipBehavior: Clip.none, + alignment: Alignment.bottomCenter, + children: [ + Container( + width: double.infinity, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColorsPetugas.navyBlue, AppColorsPetugas.blueGrotto], + stops: const [0.3, 1.0], + ), + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(30), + bottomRight: Radius.circular(30), + ), + boxShadow: [ + BoxShadow( + color: AppColorsPetugas.navyBlue.withOpacity(0.3), + blurRadius: 15, + offset: const Offset(0, 10), + spreadRadius: 2, + ), + ], + ), + child: Column( + children: [ + const SizedBox(height: 20), + // Avatar + Hero( + tag: 'avatar-${penyewa['user_id'] ?? ''}', + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 4), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 10, + offset: const Offset(0, 5), + ), + ], + ), + child: CircleAvatar( + radius: 50, + backgroundColor: AppColorsPetugas.babyBlueLight, + backgroundImage: + penyewa['avatar'] != null && + penyewa['avatar'].toString().isNotEmpty + ? NetworkImage(penyewa['avatar']) + : null, + child: + penyewa['avatar'] == null || + penyewa['avatar'].toString().isEmpty + ? const Icon( + Icons.person, + size: 50, + color: Colors.white, + ) + : null, + ), + ), + ), + const SizedBox(height: 16), + // Name + Text( + penyewa['nama_lengkap'] ?? 'Nama tidak tersedia', + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.white, + shadows: [ + Shadow( + color: Colors.black26, + blurRadius: 2, + offset: Offset(0, 1), + ), + ], + ), + ), + const SizedBox(height: 8), + // Email + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.15), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.email_outlined, + size: 16, + color: Colors.white, + ), + const SizedBox(width: 6), + Text( + penyewa['email'] ?? 'Email tidak tersedia', + style: const TextStyle( + fontSize: 14, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(height: 40), + ], + ), + ), + Positioned( + bottom: -20, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 4), + spreadRadius: 1, + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(statusIcon, size: 18, color: statusColor), + const SizedBox(width: 8), + Text( + statusText, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: statusColor, + ), + ), + ], + ), + ), + ), + ], + ); + } + + Widget _buildInfoCards(Map penyewa) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 32, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionTitle('Informasi Pribadi', Icons.person_rounded), + const SizedBox(height: 16), + + // Personal Info Card + _buildInfoCard( + [ + _buildInfoItem( + icon: Icons.credit_card_rounded, + label: 'NIK', + value: penyewa['nik'] ?? 'Tidak tersedia', + ), + _buildInfoItem( + icon: Icons.phone_rounded, + label: 'Nomor HP', + value: penyewa['no_hp'] ?? 'Tidak tersedia', + ), + _buildInfoItem( + icon: Icons.calendar_today_rounded, + label: 'Tanggal Lahir', + value: _formatBirthDate(penyewa['tanggal_lahir']), + ), + ], + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Colors.blue.shade50, Colors.white], + stops: const [0.0, 0.5], + ), + icon: Icons.person_rounded, + iconColor: AppColorsPetugas.blueGrotto, + ), + + const SizedBox(height: 24), + _buildSectionTitle('Alamat', Icons.home_rounded), + const SizedBox(height: 16), + + // Address Info Card + _buildInfoCard( + [ + _buildInfoItem( + icon: Icons.location_on_rounded, + label: 'Alamat', + value: penyewa['alamat'] ?? 'Tidak tersedia', + ), + _buildInfoItem( + icon: Icons.location_city_rounded, + label: 'RT/RW', + value: penyewa['rt_rw'] ?? 'Tidak tersedia', + ), + _buildInfoItem( + icon: Icons.apartment_rounded, + label: 'Kelurahan', + value: penyewa['kelurahan'] ?? 'Tidak tersedia', + ), + _buildInfoItem( + icon: Icons.map_rounded, + label: 'Kecamatan', + value: penyewa['kecamatan'] ?? 'Tidak tersedia', + ), + ], + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Colors.teal.shade50, Colors.white], + stops: const [0.0, 0.5], + ), + icon: Icons.home_rounded, + iconColor: Colors.teal, + ), + + // Keterangan (if available) + if (penyewa['keterangan'] != null && + penyewa['keterangan'].toString().isNotEmpty) ...[ + const SizedBox(height: 24), + _buildSectionTitle('Keterangan', Icons.note_rounded), + const SizedBox(height: 16), + + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: + penyewa['status'] == 'suspended' + ? Colors.red.shade50 + : Colors.white, + borderRadius: BorderRadius.circular(16), + gradient: + penyewa['status'] == 'suspended' + ? LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Colors.red.shade50, Colors.white], + stops: const [0.0, 0.7], + ) + : LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Colors.amber.shade50, Colors.white], + stops: const [0.0, 0.7], + ), + boxShadow: [ + BoxShadow( + color: + penyewa['status'] == 'suspended' + ? Colors.red.withOpacity(0.1) + : Colors.amber.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 5), + spreadRadius: 1, + ), + ], + border: + penyewa['status'] == 'suspended' + ? Border.all(color: Colors.red.shade200, width: 1) + : null, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: + penyewa['status'] == 'suspended' + ? Colors.red.withOpacity(0.1) + : Colors.amber.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon( + penyewa['status'] == 'suspended' + ? Icons.warning_rounded + : Icons.note_rounded, + color: + penyewa['status'] == 'suspended' + ? Colors.red.shade700 + : Colors.amber.shade700, + size: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Text( + penyewa['keterangan'], + style: TextStyle( + fontSize: 15, + color: + penyewa['status'] == 'suspended' + ? Colors.red.shade900 + : Colors.black87, + height: 1.5, + ), + ), + ), + ], + ), + ), + ], + ], + ), + ); + } + + Widget _buildSectionTitle(String title, IconData icon) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: AppColorsPetugas.navyBlue.withOpacity(0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppColorsPetugas.navyBlue.withOpacity(0.1), + width: 1, + ), + ), + child: Row( + children: [ + Icon(icon, size: 20, color: AppColorsPetugas.blueGrotto), + const SizedBox(width: 8), + Text( + title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + ), + ], + ), + ); + } + + Widget _buildInfoCard( + List children, { + Gradient? gradient, + IconData? icon, + Color? iconColor, + }) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + gradient: gradient, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 5), + spreadRadius: 1, + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ), + ); + } + + Widget _buildInfoItem({ + required IconData icon, + required String label, + required String value, + Color? valueColor, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColorsPetugas.babyBlueLight.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Icon(icon, size: 20, color: AppColorsPetugas.blueGrotto), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + value, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: valueColor ?? Colors.black87, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildRentalHistory() { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionTitle('Riwayat Sewa', Icons.history_rounded), + const SizedBox(height: 16), + + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Colors.indigo.shade50, Colors.white], + stops: const [0.0, 0.6], + ), + boxShadow: [ + BoxShadow( + color: Colors.indigo.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 5), + spreadRadius: 1, + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColorsPetugas.blueGrotto.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.history_rounded, + size: 18, + color: AppColorsPetugas.blueGrotto, + ), + ), + const SizedBox(width: 8), + Text( + 'Aktivitas Sewa', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.grey[800], + ), + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: AppColorsPetugas.blueGrotto.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Obx( + () => Text( + 'Total: ${controller.sewaHistory.length}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.blueGrotto, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Obx(() { + if (controller.sewaHistory.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey.shade100, + shape: BoxShape.circle, + ), + child: Icon( + Icons.history_rounded, + size: 48, + color: Colors.grey[400], + ), + ), + const SizedBox(height: 16), + Text( + 'Belum ada riwayat sewa', + style: TextStyle( + fontSize: 16, + color: Colors.grey[500], + ), + ), + const SizedBox(height: 8), + Text( + 'Penyewa belum memiliki transaksi sewa', + style: TextStyle( + fontSize: 14, + color: Colors.grey[400], + ), + ), + ], + ), + ), + ); + } + + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: controller.sewaHistory.length, + itemBuilder: (context, index) { + final sewa = controller.sewaHistory[index]; + final aset = sewa['aset'] as Map?; + final tagihan = + sewa['tagihan_sewa'] as Map?; + + // Extract tagihan values with null safety + final tagihanAwal = tagihan?['tagihan_awal'] ?? 0; + final denda = tagihan?['denda'] ?? 0; + final totalDibayar = tagihan?['tagihan_dibayar'] ?? 0; + final totalTagihan = tagihanAwal + denda; + + return Card( + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 3, + shadowColor: Colors.black.withOpacity(0.1), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Colors.white, Colors.grey.shade50], + ), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Asset image + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity( + 0.1, + ), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: + aset != null && + aset['foto_utama'] != null + ? Image.network( + aset['foto_utama'], + width: 70, + height: 70, + fit: BoxFit.cover, + errorBuilder: + ( + context, + error, + stackTrace, + ) => Container( + width: 70, + height: 70, + color: Colors.grey[200], + child: const Icon( + Icons + .image_not_supported, + ), + ), + ) + : Container( + width: 70, + height: 70, + color: Colors.grey[200], + child: const Icon( + Icons.image_not_supported, + ), + ), + ), + ), + const SizedBox(width: 12), + // Asset details + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + aset?['nama'] ?? + 'Aset tidak tersedia', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: + BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.calendar_today_outlined, + size: 12, + color: Colors.grey[600], + ), + const SizedBox(width: 4), + Text( + _formatDate( + sewa['waktu_mulai'], + sewa['waktu_selesai'], + ), + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + ], + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + _buildStatusChip(sewa['status']), + Container( + padding: + const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: AppColorsPetugas + .blueGrotto + .withOpacity(0.1), + borderRadius: + BorderRadius.circular(8), + ), + child: Text( + _formatCurrency(totalTagihan), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13, + color: + AppColorsPetugas + .blueGrotto, + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ], + ), + ), + ), + ); + }, + ); + }), + ], + ), + ), + ], + ), + ); + } + + Widget _buildStatusChip(String? status) { + Color bgColor; + Color textColor; + String label; + IconData icon; + + switch (status?.toUpperCase()) { + case 'MENUNGGU PEMBAYARAN': + bgColor = Colors.orange[100]!; + textColor = Colors.orange[800]!; + label = 'Menunggu Pembayaran'; + icon = Icons.payment_outlined; + break; + case 'PERIKSA PEMBAYARAN': + bgColor = Colors.blue[100]!; + textColor = Colors.blue[800]!; + label = 'Periksa Pembayaran'; + icon = Icons.receipt_long_outlined; + break; + case 'DITERIMA': + bgColor = Colors.green[100]!; + textColor = Colors.green[800]!; + label = 'Diterima'; + icon = Icons.check_circle_outline; + break; + case 'PEMBAYARAN DENDA': + bgColor = Colors.red[100]!; + textColor = Colors.red[800]!; + label = 'Pembayaran Denda'; + icon = Icons.warning_amber_outlined; + break; + case 'PERIKSA PEMBAYARAN DENDA': + bgColor = Colors.purple[100]!; + textColor = Colors.purple[800]!; + label = 'Periksa Pembayaran Denda'; + icon = Icons.receipt_outlined; + break; + case 'SELESAI': + bgColor = Colors.teal[100]!; + textColor = Colors.teal[800]!; + label = 'Selesai'; + icon = Icons.task_alt_outlined; + break; + case 'DIBATALKAN': + bgColor = Colors.grey[100]!; + textColor = Colors.grey[800]!; + label = 'Dibatalkan'; + icon = Icons.cancel_outlined; + break; + default: + bgColor = Colors.grey[100]!; + textColor = Colors.grey[800]!; + label = status ?? 'Tidak diketahui'; + icon = Icons.help_outline; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 12, color: textColor), + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: textColor, + ), + ), + ], + ), + ); + } + + String _formatDate(dynamic startDate, dynamic endDate) { + try { + final start = DateTime.parse(startDate.toString()); + final end = DateTime.parse(endDate.toString()); + + final dateFormat = DateFormat('dd MMM yyyy'); + + if (start.year == end.year && + start.month == end.month && + start.day == end.day) { + return dateFormat.format(start); + } else { + return '${dateFormat.format(start)} - ${dateFormat.format(end)}'; + } + } catch (e) { + return 'Tanggal tidak valid'; + } + } + + String _formatBirthDate(dynamic birthDate) { + if (birthDate == null || birthDate.toString().isEmpty) { + return 'Tidak tersedia'; + } + + try { + final date = DateTime.parse(birthDate.toString()); + final dateFormat = DateFormat('dd MMMM yyyy'); + return dateFormat.format(date); + } catch (e) { + return 'Tanggal tidak valid'; + } + } + + String _formatCurrency(dynamic amount) { + if (amount == null) return 'Rp0'; + + final formatter = NumberFormat.currency( + locale: 'id', + symbol: 'Rp', + decimalDigits: 0, + ); + + try { + final value = double.parse(amount.toString()); + return formatter.format(value); + } catch (e) { + return 'Rp0'; + } + } + + void _showApproveDialog(BuildContext context) { + showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Konfirmasi Aktivasi'), + content: const Text( + 'Apakah Anda yakin ingin mengaktifkan penyewa ini?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + ElevatedButton( + onPressed: () async { + Navigator.pop(context); + + // Update status penyewa + await controller.updatePenyewaStatus( + 'active', + 'Akun diaktifkan oleh petugas', + ); + + // Navigate back to petugas_penyewa page + Get.back(); + + // Refresh the penyewa list + final penyewaController = + Get.find(); + penyewaController.refreshData(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + ), + child: const Text('Aktifkan'), + ), + ], + ), + ); + } + + void _showSuspendDialog(BuildContext context) { + final reasonController = TextEditingController(); + + showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Konfirmasi Penonaktifan'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Apakah Anda yakin ingin menonaktifkan sementara penyewa ini?', + ), + const SizedBox(height: 16), + TextField( + controller: reasonController, + decoration: const InputDecoration( + labelText: 'Alasan Penonaktifan', + border: OutlineInputBorder(), + ), + maxLines: 3, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + ElevatedButton( + onPressed: () async { + Navigator.pop(context); // Tutup dialog terlebih dahulu + + final reason = + reasonController.text.isNotEmpty + ? reasonController.text + : 'Dinonaktifkan oleh petugas'; + + // Update status penyewa + await controller.updatePenyewaStatus('suspended', reason); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + child: const Text('Nonaktifkan'), + ), + ], + ), + ); + } + + void _showRejectDialog(BuildContext context) { + final reasonController = TextEditingController(); + + showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Konfirmasi Penolakan'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Apakah Anda yakin ingin menolak pendaftaran penyewa ini?', + ), + const SizedBox(height: 16), + TextField( + controller: reasonController, + decoration: const InputDecoration( + labelText: 'Alasan Penolakan', + border: OutlineInputBorder(), + ), + maxLines: 3, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + ElevatedButton( + onPressed: () async { + Navigator.pop(context); // Tutup dialog terlebih dahulu + + final reason = + reasonController.text.isNotEmpty + ? reasonController.text + : 'Ditolak oleh petugas'; + + // Update status penyewa + await controller.updatePenyewaStatus('dibatalkan', reason); + + // Navigate back to petugas_penyewa page + Get.offNamed(Routes.PETUGAS_PENYEWA); + + // Refresh the penyewa list + final penyewaController = + Get.find(); + penyewaController.refreshData(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + child: const Text('Tolak'), + ), + ], + ), + ); + } +} diff --git a/lib/app/modules/petugas_bumdes/views/petugas_detail_sewa_view.dart b/lib/app/modules/petugas_bumdes/views/petugas_detail_sewa_view.dart index 45d5f9a..76c5482 100644 --- a/lib/app/modules/petugas_bumdes/views/petugas_detail_sewa_view.dart +++ b/lib/app/modules/petugas_bumdes/views/petugas_detail_sewa_view.dart @@ -1070,7 +1070,8 @@ class _PetugasDetailSewaViewState extends State { ), ) : ((sewa.status == 'MENUNGGU PEMBAYARAN' || - sewa.status == 'PERIKSA PEMBAYARAN')) + sewa.status == 'PERIKSA PEMBAYARAN' || + sewa.status == 'DITERIMA')) ? Padding( padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), child: Row( @@ -1078,14 +1079,68 @@ class _PetugasDetailSewaViewState extends State { Expanded( child: ElevatedButton( onPressed: () async { - controller.rejectSewa(sewa.id); - await refreshSewaData(); - Get.snackbar( - 'Sewa Dibatalkan', - 'Status sewa telah diubah menjadi DIBATALKAN', - backgroundColor: Colors.red, - colorText: Colors.white, - snackPosition: SnackPosition.BOTTOM, + // Show confirmation dialog + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text('Konfirmasi Pembatalan'), + content: Text( + 'Apakah Anda yakin ingin membatalkan sewa ini?', + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of( + context, + ).pop(); // Close dialog + }, + child: Text( + 'Batal', + style: TextStyle(color: Colors.grey), + ), + ), + ElevatedButton( + onPressed: () async { + Navigator.of( + context, + ).pop(); // Close dialog + + // Update status to DIBATALKAN + final asetProvider = + Get.find(); + await asetProvider.updateSewaAsetStatus( + sewaAsetId: sewa.id, + status: 'DIBATALKAN', + ); + + // Update local state + controller.rejectSewa(sewa.id); + await refreshSewaData(); + + // Show success notification + Get.snackbar( + 'Sewa Dibatalkan', + 'Status sewa telah diubah menjadi DIBATALKAN', + backgroundColor: Colors.red, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text('Konfirmasi'), + ), + ], + ); + }, ); }, style: ElevatedButton.styleFrom( @@ -1275,7 +1330,7 @@ class _PetugasDetailSewaViewState extends State { } // Always add cancel option if not already completed or canceled - if (status != 'Selesai' && status != 'Dibatalkan') { + if (status != 'SELESAI' && status != 'DIBATALKAN') { menuItems.add( PopupMenuItem( value: 'cancel', @@ -1384,14 +1439,57 @@ class _PetugasDetailSewaViewState extends State { case 'cancel': // Update status to "Dibatalkan" - controller.rejectSewa(sewa.id); - Get.back(); - Get.snackbar( - 'Sewa Dibatalkan', - 'Sewa aset telah dibatalkan', - backgroundColor: Colors.red, - colorText: Colors.white, - snackPosition: SnackPosition.BOTTOM, + showDialog( + context: Get.context!, + builder: (BuildContext context) { + return AlertDialog( + title: Text('Konfirmasi Pembatalan'), + content: Text('Apakah Anda yakin ingin membatalkan sewa ini?'), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); // Close dialog + }, + child: Text('Batal', style: TextStyle(color: Colors.grey)), + ), + ElevatedButton( + onPressed: () async { + Navigator.of(context).pop(); // Close dialog + + // Update status to DIBATALKAN + final asetProvider = Get.find(); + await asetProvider.updateSewaAsetStatus( + sewaAsetId: sewa.id, + status: 'DIBATALKAN', + ); + + // Update local state + controller.rejectSewa(sewa.id); + await refreshSewaData(); + + // Show success notification + Get.snackbar( + 'Sewa Dibatalkan', + 'Status sewa telah diubah menjadi DIBATALKAN', + backgroundColor: Colors.red, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text('Konfirmasi'), + ), + ], + ); + }, ); break; } diff --git a/lib/app/modules/petugas_bumdes/views/petugas_langganan_view.dart b/lib/app/modules/petugas_bumdes/views/petugas_langganan_view.dart deleted file mode 100644 index 0519ecb..0000000 --- a/lib/app/modules/petugas_bumdes/views/petugas_langganan_view.dart +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/lib/app/modules/petugas_bumdes/views/petugas_laporan_view.dart b/lib/app/modules/petugas_bumdes/views/petugas_laporan_view.dart new file mode 100644 index 0000000..8743a1d --- /dev/null +++ b/lib/app/modules/petugas_bumdes/views/petugas_laporan_view.dart @@ -0,0 +1,275 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:printing/printing.dart'; +import 'package:pdf/pdf.dart'; +import '../controllers/petugas_laporan_controller.dart'; +import '../controllers/petugas_bumdes_dashboard_controller.dart'; +import '../widgets/petugas_side_navbar.dart'; +import '../../../theme/app_colors_petugas.dart'; + +class PetugasLaporanView extends GetView { + const PetugasLaporanView({super.key}); + + @override + Widget build(BuildContext context) { + // Get dashboard controller for side navbar + final dashboardController = Get.find(); + + return Scaffold( + appBar: AppBar( + title: const Text('Laporan Bulanan'), + backgroundColor: AppColorsPetugas.navyBlue, + foregroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Get.back(), + ), + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Filter Section + Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Filter Laporan', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: Obx( + () => DropdownButtonFormField( + decoration: const InputDecoration( + labelText: 'Bulan', + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + value: controller.selectedMonth.value, + items: + controller.months.map>(( + month, + ) { + return DropdownMenuItem( + value: month['value'] as int, + child: Text(month['label'] as String), + ); + }).toList(), + onChanged: controller.onMonthChanged, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Obx( + () => DropdownButtonFormField( + decoration: const InputDecoration( + labelText: 'Tahun', + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + value: controller.selectedYear.value, + items: + controller.years.map>(( + year, + ) { + return DropdownMenuItem( + value: year, + child: Text(year.toString()), + ); + }).toList(), + onChanged: controller.onYearChanged, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: Obx( + () => ElevatedButton.icon( + onPressed: + controller.isLoading.value + ? null + : controller.generateReport, + icon: + controller.isLoading.value + ? Container( + width: 24, + height: 24, + padding: const EdgeInsets.all(2.0), + child: const CircularProgressIndicator( + color: Colors.white, + strokeWidth: 3, + ), + ) + : const Icon(Icons.refresh), + label: Text( + controller.isLoading.value + ? 'Memproses...' + : 'Generate Laporan', + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.blueGrotto, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + vertical: 12, + ), + disabledBackgroundColor: AppColorsPetugas + .blueGrotto + .withOpacity(0.6), + ), + ), + ), + ), + ], + ), + ], + ), + ), + ), + + const SizedBox(height: 16), + + // Report Preview Section + Expanded( + child: Obx(() { + if (controller.isLoading.value) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Menghasilkan laporan...'), + ], + ), + ); + } + + if (!controller.isPdfReady.value || + controller.pdfBytes.value == null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.description_outlined, + size: 80, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + Text( + 'Belum ada laporan', + style: TextStyle( + fontSize: 18, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 8), + const Text( + 'Pilih bulan dan tahun lalu klik "Generate Laporan"', + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Preview Laporan', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Expanded( + child: Card( + elevation: 2, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: PdfPreview( + build: (format) => controller.pdfBytes.value!, + canChangeOrientation: false, + canChangePageFormat: false, + canDebug: false, + allowPrinting: false, + allowSharing: false, + initialPageFormat: PdfPageFormat.a4, + pdfFileName: + 'Laporan_${controller.reportData['period']?['monthName'] ?? ''}_${controller.reportData['period']?['year'] ?? ''}.pdf', + ), + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton.icon( + onPressed: controller.savePdf, + icon: const Icon(Icons.save_alt), + label: const Text('Simpan PDF'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.navyBlue, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + ), + ), + const SizedBox(width: 16), + ElevatedButton.icon( + onPressed: controller.printPdf, + icon: const Icon(Icons.print), + label: const Text('Cetak'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.blueGrotto, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + ), + ), + ], + ), + ], + ); + }), + ), + ], + ), + ), + ); + } +} diff --git a/lib/app/modules/petugas_bumdes/views/petugas_paket_view.dart b/lib/app/modules/petugas_bumdes/views/petugas_paket_view.dart index 65eb5ea..066d621 100644 --- a/lib/app/modules/petugas_bumdes/views/petugas_paket_view.dart +++ b/lib/app/modules/petugas_bumdes/views/petugas_paket_view.dart @@ -376,7 +376,6 @@ class PetugasPaketView extends GetView { child: Material( color: Colors.transparent, child: InkWell( - onTap: () => _showPaketDetails(context, paket), child: Row( children: [ // Paket image or icon @@ -796,294 +795,104 @@ class PetugasPaketView extends GetView { ); } - void _showPaketDetails(BuildContext context, dynamic paket) { - // Handle both Map and PaketModel for backward compatibility - final isPaketModel = paket is PaketModel; - final String nama = - isPaketModel - ? paket.nama - : (paket['nama']?.toString() ?? 'Paket Tanpa Nama'); - final String? deskripsi = - isPaketModel ? paket.deskripsi : paket['deskripsi']?.toString(); - final bool isAvailable = - isPaketModel - ? (paket.kuantitas > 0) - : ((paket['kuantitas'] as int?) ?? 0) > 0; - final dynamic harga = - isPaketModel - ? (paket.satuanWaktuSewa.isNotEmpty - ? paket.satuanWaktuSewa.first['harga'] - : paket.harga) - : (paket['harga'] ?? 0); - // Items are not part of the PaketModel, so we'll use an empty list - final List> items = []; - showModalBottomSheet( - context: context, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (context) { - return Container( - padding: const EdgeInsets.all(16), - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.75, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - nama, - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: AppColorsPetugas.navyBlue, - ), - ), - ), - IconButton( - icon: Icon(Icons.close, color: AppColorsPetugas.blueGrotto), - onPressed: () => Navigator.pop(context), - ), - ], - ), - const SizedBox(height: 16), - Expanded( - child: ListView( - children: [ - Card( - margin: EdgeInsets.zero, - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildDetailItem( - 'Harga', - 'Rp ${_formatPrice(harga)}', - ), - _buildDetailItem( - 'Status', - isAvailable ? 'Tersedia' : 'Tidak Tersedia', - ), - _buildDetailItem('Deskripsi', deskripsi ?? '-'), - ], - ), - ), - ), - const SizedBox(height: 16), - Text( - 'Item dalam Paket', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: AppColorsPetugas.navyBlue, - ), - ), - const SizedBox(height: 8), - Card( - margin: EdgeInsets.zero, - child: ListView.separated( - physics: const NeverScrollableScrollPhysics(), - shrinkWrap: true, - itemCount: items.length, - separatorBuilder: - (context, index) => const Divider(height: 1), - itemBuilder: (context, index) { - final item = items[index]; - return ListTile( - leading: CircleAvatar( - backgroundColor: AppColorsPetugas.babyBlue, - child: Icon( - Icons.inventory_2_outlined, - color: AppColorsPetugas.blueGrotto, - size: 16, - ), - ), - title: Text(item['nama']), - trailing: Text( - '${item['jumlah']} unit', - style: TextStyle( - color: AppColorsPetugas.blueGrotto, - fontWeight: FontWeight.bold, - ), - ), - ); - }, - ), - ), - ], - ), - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () { - Navigator.pop(context); - Get.toNamed( - Routes.PETUGAS_TAMBAH_PAKET, - arguments: paket, - ); - }, - icon: const Icon(Icons.edit), - label: const Text('Edit'), - style: OutlinedButton.styleFrom( - foregroundColor: AppColorsPetugas.blueGrotto, - side: BorderSide(color: AppColorsPetugas.blueGrotto), - padding: const EdgeInsets.symmetric(vertical: 12), - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: ElevatedButton.icon( - onPressed: () { - Navigator.pop(context); - _showDeleteConfirmation(context, paket); - }, - icon: const Icon(Icons.delete), - label: const Text('Hapus'), - style: ElevatedButton.styleFrom( - backgroundColor: AppColorsPetugas.error, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 12), - ), - ), - ), - ], - ), - ], - ), - ); - }, - ); - } - - Widget _buildDetailItem(String label, String value) { - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: TextStyle(fontSize: 14, color: AppColorsPetugas.blueGrotto), - ), - const SizedBox(height: 4), - Text( - value, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: AppColorsPetugas.navyBlue, - ), - ), - ], - ), - ); - } - - void _showAddEditPaketDialog(BuildContext context, {dynamic paket}) { - // Handle both Map and PaketModel for backward compatibility - final isPaketModel = paket is PaketModel; - final String? id = isPaketModel ? paket.id : paket?['id']; - final String title = id == null ? 'Tambah Paket' : 'Edit Paket'; - final isEditing = paket != null; - - // This would be implemented with proper form validation in a real app - showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: Text( - title, - style: TextStyle(color: AppColorsPetugas.navyBlue), - ), - content: const Text( - 'Form pengelolaan paket akan ditampilkan di sini dengan field untuk nama, kategori, harga, deskripsi, status, dan item-item dalam paket.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text( - 'Batal', - style: TextStyle(color: Colors.grey.shade600), - ), - ), - ElevatedButton( - onPressed: () { - Navigator.pop(context); - // In a real app, we would save the form data - Get.snackbar( - isEditing ? 'Paket Diperbarui' : 'Paket Ditambahkan', - isEditing - ? 'Paket berhasil diperbarui' - : 'Paket baru berhasil ditambahkan', - backgroundColor: AppColorsPetugas.success, - colorText: Colors.white, - snackPosition: SnackPosition.BOTTOM, - ); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppColorsPetugas.blueGrotto, - ), - child: Text(isEditing ? 'Simpan' : 'Tambah'), - ), - ], - ); - }, - ); - } - void _showDeleteConfirmation(BuildContext context, dynamic paket) { // Handle both Map and PaketModel for backward compatibility final isPaketModel = paket is PaketModel; final String id = isPaketModel ? paket.id : (paket['id']?.toString() ?? ''); final String nama = isPaketModel ? paket.nama : (paket['nama']?.toString() ?? 'Paket'); - showDialog( context: context, builder: (context) { - return AlertDialog( - title: Text( - 'Konfirmasi Hapus', - style: TextStyle(color: AppColorsPetugas.navyBlue), + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), ), - content: Text('Apakah Anda yakin ingin menghapus paket "$nama"?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text( - 'Batal', - style: TextStyle(color: Colors.grey.shade600), - ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Warning icon + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColorsPetugas.errorLight, + shape: BoxShape.circle, + ), + child: Icon( + Icons.delete_forever, + color: AppColorsPetugas.error, + size: 32, + ), + ), + + const SizedBox(height: 24), + + // Title and message + Text( + 'Konfirmasi Hapus', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + textAlign: TextAlign.center, + ), + + const SizedBox(height: 12), + + Text( + 'Apakah Anda yakin ingin menghapus paket "$nama"? Tindakan ini tidak dapat dibatalkan.', + style: TextStyle( + fontSize: 14, + color: AppColorsPetugas.textPrimary, + ), + textAlign: TextAlign.center, + ), + + const SizedBox(height: 24), + + // Action buttons + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.pop(context), + style: OutlinedButton.styleFrom( + foregroundColor: AppColorsPetugas.textPrimary, + side: BorderSide(color: AppColorsPetugas.divider), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text('Batal'), + ), + ), + const SizedBox(width: 16), + Expanded( + child: ElevatedButton( + onPressed: () { + Navigator.pop(context); + controller.deletePaket(id); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.error, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text('Hapus'), + ), + ), + ], + ), + ], ), - ElevatedButton( - onPressed: () { - Navigator.pop(context); - controller.deletePaket(id); - Get.snackbar( - 'Paket Dihapus', - 'Paket berhasil dihapus dari sistem', - backgroundColor: AppColorsPetugas.error, - colorText: Colors.white, - snackPosition: SnackPosition.BOTTOM, - ); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppColorsPetugas.error, - ), - child: const Text('Hapus'), - ), - ], + ), ); }, ); diff --git a/lib/app/modules/petugas_bumdes/views/petugas_penyewa_view.dart b/lib/app/modules/petugas_bumdes/views/petugas_penyewa_view.dart new file mode 100644 index 0000000..e4b85e5 --- /dev/null +++ b/lib/app/modules/petugas_bumdes/views/petugas_penyewa_view.dart @@ -0,0 +1,724 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/petugas_penyewa_controller.dart'; +import '../controllers/petugas_bumdes_dashboard_controller.dart'; +import '../../../theme/app_colors_petugas.dart'; +import '../widgets/petugas_bumdes_bottom_navbar.dart'; +import '../widgets/petugas_side_navbar.dart'; +import '../../../routes/app_routes.dart'; + +class PetugasPenyewaView extends StatefulWidget { + const PetugasPenyewaView({Key? key}) : super(key: key); + + @override + State createState() => _PetugasPenyewaViewState(); +} + +class _PetugasPenyewaViewState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + late PetugasPenyewaController controller; + late PetugasBumdesDashboardController dashboardController; + + final List tabTitles = ['Verifikasi', 'Aktif', 'Ditangguhkan']; + + @override + void initState() { + super.initState(); + controller = Get.find(); + dashboardController = Get.find(); + + _tabController = TabController(length: 3, vsync: this); + + // Add listener to sync tab selection with controller's filter + _tabController.addListener(_onTabChanged); + } + + void _onTabChanged() { + if (!_tabController.indexIsChanging) { + controller.changeTab(_tabController.index); + } + } + + @override + void dispose() { + _tabController.removeListener(_onTabChanged); + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + dashboardController.changeTab(0); + return false; + }, + child: Scaffold( + appBar: AppBar( + title: const Text( + 'Daftar Penyewa', + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 18), + ), + backgroundColor: AppColorsPetugas.navyBlue, + foregroundColor: Colors.white, + elevation: 0, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(60), + child: Container( + decoration: const BoxDecoration( + color: AppColorsPetugas.navyBlue, + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(16), + bottomRight: Radius.circular(16), + ), + ), + child: TabBar( + controller: _tabController, + isScrollable: true, + indicator: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(30), + ), + indicatorSize: TabBarIndicatorSize.tab, + indicatorPadding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 4, + ), + labelColor: Colors.white, + unselectedLabelColor: Colors.white.withOpacity(0.7), + labelStyle: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + unselectedLabelStyle: const TextStyle( + fontWeight: FontWeight.w400, + fontSize: 14, + ), + tabs: + tabTitles + .map( + (title) => Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: Tab(text: title), + ), + ) + .toList(), + dividerColor: Colors.transparent, + ), + ), + ), + ), + drawer: PetugasSideNavbar(controller: dashboardController), + drawerEdgeDragWidth: 60, + drawerScrimColor: Colors.black.withOpacity(0.6), + backgroundColor: Colors.grey.shade50, + body: Column( + children: [ + _buildSearchBar(), + Expanded( + child: TabBarView( + controller: _tabController, + children: + [0, 1, 2].map((index) { + return Obx(() { + if (controller.isLoading.value) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator( + color: AppColorsPetugas.blueGrotto, + strokeWidth: 3, + ), + const SizedBox(height: 16), + Text( + 'Memuat data...', + style: TextStyle( + color: AppColorsPetugas.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } + + if (controller.filteredPenyewaList.isEmpty) { + return _buildEmptyState(); + } + + return _buildPenyewaList(); + }); + }).toList(), + ), + ), + ], + ), + bottomNavigationBar: Obx( + () => PetugasBumdesBottomNavbar( + selectedIndex: dashboardController.currentTabIndex.value, + onItemTapped: (index) => dashboardController.changeTab(index), + ), + ), + ), + ); + } + + Widget _buildSearchBar() { + // Add controller for TextField so it can be cleared + final TextEditingController searchController = TextEditingController( + text: controller.searchQuery.value, + ); + + return Container( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: TextField( + controller: searchController, + onChanged: controller.updateSearchQuery, + decoration: InputDecoration( + hintText: 'Cari nama atau email...', + hintStyle: TextStyle(color: Colors.grey.shade400, fontSize: 14), + prefixIcon: Container( + padding: const EdgeInsets.all(12), + child: Icon( + Icons.search_rounded, + color: AppColorsPetugas.blueGrotto, + size: 22, + ), + ), + filled: true, + fillColor: Colors.grey.shade50, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(50), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(50), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(50), + borderSide: BorderSide.none, + ), + contentPadding: EdgeInsets.zero, + isDense: true, + suffixIcon: Obx( + () => + controller.searchQuery.value.isNotEmpty + ? IconButton( + icon: Icon( + Icons.close, + color: AppColorsPetugas.textSecondary, + size: 20, + ), + onPressed: () { + searchController.clear(); + controller.updateSearchQuery(''); + }, + ) + : SizedBox.shrink(), + ), + ), + ), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.people_outline, size: 80, color: Colors.grey[400]), + const SizedBox(height: 16), + Obx(() { + String message = 'Belum ada data penyewa'; + + if (controller.searchQuery.isNotEmpty) { + message = 'Tidak ada hasil yang cocok dengan pencarian'; + } else { + switch (controller.currentTabIndex.value) { + case 0: + message = 'Tidak ada penyewa yang menunggu verifikasi'; + break; + case 1: + message = 'Tidak ada penyewa aktif'; + break; + case 2: + message = 'Tidak ada penyewa yang ditangguhkan'; + break; + } + } + + return Text( + message, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.grey[600], + ), + ); + }), + const SizedBox(height: 8), + Text( + 'Data penyewa akan muncul di sini', + style: TextStyle(fontSize: 14, color: Colors.grey[500]), + ), + ], + ), + ); + } + + Widget _buildPenyewaList() { + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: controller.filteredPenyewaList.length, + itemBuilder: (context, index) { + final penyewa = controller.filteredPenyewaList[index]; + return Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(16), + child: InkWell( + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header with avatar and badge + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColorsPetugas.babyBlueLight.withOpacity(0.2), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + children: [ + // Avatar with border + Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: CircleAvatar( + radius: 24, + backgroundColor: AppColorsPetugas.babyBlueLight, + backgroundImage: + penyewa['avatar'] != null && + penyewa['avatar'] + .toString() + .isNotEmpty + ? NetworkImage(penyewa['avatar']) + : null, + child: + penyewa['avatar'] == null || + penyewa['avatar'].toString().isEmpty + ? const Icon( + Icons.person, + color: Colors.white, + ) + : null, + ), + ), + const SizedBox(width: 16), + // Name and email + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + penyewa['nama_lengkap'] ?? + 'Nama tidak tersedia', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Icon( + Icons.email_outlined, + size: 14, + color: Colors.grey[600], + ), + const SizedBox(width: 4), + Expanded( + child: Text( + penyewa['email'] ?? + 'Email tidak tersedia', + style: TextStyle( + fontSize: 13, + color: Colors.grey[600], + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ), + ), + _buildStatusBadge(penyewa['status']), + ], + ), + ), + + // Content section + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Show additional info only for Aktif and Ditangguhkan tabs + if (controller.currentTabIndex.value != 0) ...[ + Row( + children: [ + _buildInfoChip( + Icons.credit_card_outlined, + 'NIK: ${penyewa['nik'] ?? 'Tidak tersedia'}', + ), + const SizedBox(width: 8), + _buildInfoChip( + Icons.phone_outlined, + penyewa['no_hp'] ?? 'No. HP tidak tersedia', + ), + ], + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildInfoTile( + 'Total Sewa', + penyewa['total_sewa']?.toString() ?? '0', + Icons.shopping_bag_outlined, + AppColorsPetugas.blueGrotto, + ), + if (controller.currentTabIndex.value == 1 || + controller.currentTabIndex.value == 2) + _buildActionChip( + label: 'Lihat Detail', + color: AppColorsPetugas.blueGrotto, + icon: Icons.visibility, + onTap: () { + Get.toNamed( + Routes.PETUGAS_DETAIL_PENYEWA, + arguments: { + 'userId': penyewa['user_id'], + }, + ); + }, + ), + ], + ), + ], + + // Add "Detail" button for Verifikasi tab + if (controller.currentTabIndex.value == 0) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: _buildActionChip( + label: 'Lihat Detail & Verifikasi', + color: AppColorsPetugas.blueGrotto, + icon: Icons.visibility, + onTap: () { + Get.toNamed( + Routes.PETUGAS_DETAIL_PENYEWA, + arguments: { + 'userId': penyewa['user_id'], + }, + ); + }, + ), + ), + ], + ), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + } + + Widget _buildInfoChip(IconData icon, String label) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.grey.shade200), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: Colors.grey[600]), + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + fontSize: 12, + color: Colors.grey[700], + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } + + Widget _buildActionChip({ + required String label, + required Color color, + required VoidCallback onTap, + IconData? icon, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: color.withOpacity(0.5)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 14, color: color), + const SizedBox(width: 4), + ], + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: color, + ), + ), + ], + ), + ), + ); + } + + Widget _buildInfoTile( + String label, + String value, + IconData icon, + Color color, + ) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: color.withOpacity(0.05), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon(icon, size: 14, color: color), + ), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle(fontSize: 11, color: Colors.grey[600]), + ), + Text( + value, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: color, + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildStatusBadge(String? status) { + Color bgColor; + Color textColor; + String label; + + switch (status?.toLowerCase()) { + case 'active': + bgColor = Colors.green[100]!; + textColor = Colors.green[800]!; + label = 'Aktif'; + break; + case 'pending': + bgColor = Colors.orange[100]!; + textColor = Colors.orange[800]!; + label = 'Menunggu'; + break; + case 'suspended': + bgColor = Colors.red[100]!; + textColor = Colors.red[800]!; + label = 'Dinonaktifkan'; + break; + default: + bgColor = Colors.grey[100]!; + textColor = Colors.grey[800]!; + label = 'Tidak diketahui'; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + label, + style: TextStyle( + color: textColor, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + void _showApproveDialog(BuildContext context, String userId) { + showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Konfirmasi Aktivasi'), + content: const Text( + 'Apakah Anda yakin ingin mengaktifkan penyewa ini?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + controller.updatePenyewaStatus( + userId, + 'active', + 'Akun diaktifkan oleh petugas', + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + ), + child: const Text('Aktifkan'), + ), + ], + ), + ); + } + + void _showRejectDialog(BuildContext context, String userId) { + final reasonController = TextEditingController(); + + showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Konfirmasi Penolakan'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Apakah Anda yakin ingin menolak penyewa ini?'), + const SizedBox(height: 16), + TextField( + controller: reasonController, + decoration: const InputDecoration( + labelText: 'Alasan Penolakan', + border: OutlineInputBorder(), + ), + maxLines: 3, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + final reason = + reasonController.text.isNotEmpty + ? reasonController.text + : 'Ditolak oleh petugas'; + controller.updatePenyewaStatus(userId, 'suspended', reason); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + child: const Text('Tolak'), + ), + ], + ), + ); + } +} diff --git a/lib/app/modules/petugas_bumdes/views/petugas_sewa_view.dart b/lib/app/modules/petugas_bumdes/views/petugas_sewa_view.dart index 1696bb4..72867c6 100644 --- a/lib/app/modules/petugas_bumdes/views/petugas_sewa_view.dart +++ b/lib/app/modules/petugas_bumdes/views/petugas_sewa_view.dart @@ -481,25 +481,26 @@ class _PetugasSewaViewState extends State ), ), - // Price - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 5, - ), - decoration: BoxDecoration( - color: AppColorsPetugas.blueGrotto.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - controller.formatPrice(sewa.totalTagihan), - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: AppColorsPetugas.blueGrotto, + // Price - only show if total_tagihan > 0 + if (sewa.totalTagihan > 0) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + color: AppColorsPetugas.blueGrotto.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + controller.formatPrice(sewa.totalTagihan), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.blueGrotto, + ), ), ), - ), ], ), ), @@ -574,7 +575,7 @@ class _PetugasSewaViewState extends State ), const SizedBox(width: 4), Text( - '${sewa.waktuMulai.toIso8601String().substring(0, 10)} - ${sewa.waktuSelesai.toIso8601String().substring(0, 10)}', + _formatDateRange(sewa), style: TextStyle( fontSize: 12, color: AppColorsPetugas.textSecondary, @@ -602,6 +603,37 @@ class _PetugasSewaViewState extends State ); } + String _formatDateRange(SewaModel sewa) { + final startDate = sewa.waktuMulai; + final endDate = sewa.waktuSelesai; + + // Format dates as dd-mm-yyyy + String formattedStartDate = + '${startDate.day.toString().padLeft(2, '0')}-${startDate.month.toString().padLeft(2, '0')}-${startDate.year}'; + String formattedEndDate = + '${endDate.day.toString().padLeft(2, '0')}-${endDate.month.toString().padLeft(2, '0')}-${endDate.year}'; + + // Check if rental unit is "jam" (hour) + if (sewa.namaSatuanWaktu?.toLowerCase() == 'jam') { + // Format as "dd-mm-yyyy icon jam 09.00-15.00" + String startTime = + '${startDate.hour.toString().padLeft(2, '0')}.${startDate.minute.toString().padLeft(2, '0')}'; + String endTime = + '${endDate.hour.toString().padLeft(2, '0')}.${endDate.minute.toString().padLeft(2, '0')}'; + return '$formattedStartDate ⏱ $startTime-$endTime'; + } + // If same day but not hourly, just show the date + else if (startDate.day == endDate.day && + startDate.month == endDate.month && + startDate.year == endDate.year) { + return formattedStartDate; + } + // Different days - show date range + else { + return '$formattedStartDate - $formattedEndDate'; + } + } + void _showFilterBottomSheet() { Get.bottomSheet( Container( diff --git a/lib/app/modules/petugas_bumdes/widgets/petugas_bumdes_bottom_navbar.dart b/lib/app/modules/petugas_bumdes/widgets/petugas_bumdes_bottom_navbar.dart index 1ba74aa..8c0daae 100644 --- a/lib/app/modules/petugas_bumdes/widgets/petugas_bumdes_bottom_navbar.dart +++ b/lib/app/modules/petugas_bumdes/widgets/petugas_bumdes_bottom_navbar.dart @@ -64,6 +64,14 @@ class PetugasBumdesBottomNavbar extends StatelessWidget { isSelected: selectedIndex == 3, onTap: () => onItemTapped(3), ), + _buildNavItem( + context: context, + icon: Icons.people_outlined, + activeIcon: Icons.people, + label: 'Penyewa', + isSelected: selectedIndex == 4, + onTap: () => onItemTapped(4), + ), ], ), ); @@ -79,7 +87,7 @@ class PetugasBumdesBottomNavbar extends StatelessWidget { required VoidCallback onTap, }) { final primaryColor = AppColors.primary; - final tabWidth = MediaQuery.of(context).size.width / 4; + final tabWidth = MediaQuery.of(context).size.width / 5; return Material( color: Colors.transparent, diff --git a/lib/app/modules/petugas_bumdes/widgets/petugas_side_navbar.dart b/lib/app/modules/petugas_bumdes/widgets/petugas_side_navbar.dart index 8454f9c..2a25274 100644 --- a/lib/app/modules/petugas_bumdes/widgets/petugas_side_navbar.dart +++ b/lib/app/modules/petugas_bumdes/widgets/petugas_side_navbar.dart @@ -3,6 +3,7 @@ import 'package:get/get.dart'; import '../../../theme/app_colors.dart'; import '../../../theme/app_colors_petugas.dart'; import '../controllers/petugas_bumdes_dashboard_controller.dart'; +import '../../../routes/app_routes.dart'; class PetugasSideNavbar extends StatelessWidget { final PetugasBumdesDashboardController controller; @@ -148,6 +149,30 @@ class PetugasSideNavbar extends StatelessWidget { isSelected: controller.currentTabIndex.value == 3, onTap: () => controller.changeTab(3), ), + _buildMenuItem( + icon: Icons.people_outlined, + activeIcon: Icons.people, + title: 'Penyewa', + subtitle: 'Kelola data penyewa', + isSelected: controller.currentTabIndex.value == 4, + onTap: () => controller.changeTab(4), + ), + _buildMenuItem( + icon: Icons.account_balance_outlined, + activeIcon: Icons.account_balance, + title: 'Kelola Akun Bank', + subtitle: 'Kelola akun bank BUMDes', + isSelected: false, + onTap: () => Get.toNamed(Routes.PETUGAS_AKUN_BANK), + ), + _buildMenuItem( + icon: Icons.bar_chart_outlined, + activeIcon: Icons.bar_chart, + title: 'Laporan Bulanan', + subtitle: 'Cetak laporan bulanan', + isSelected: false, + onTap: () => Get.toNamed(Routes.PETUGAS_LAPORAN), + ), ], ), ); diff --git a/lib/app/modules/warga/controllers/order_sewa_aset_controller.dart b/lib/app/modules/warga/controllers/order_sewa_aset_controller.dart index b72c40b..a04ab3c 100644 --- a/lib/app/modules/warga/controllers/order_sewa_aset_controller.dart +++ b/lib/app/modules/warga/controllers/order_sewa_aset_controller.dart @@ -59,6 +59,10 @@ class OrderSewaAsetController extends GetxController { // Store the asset ID to retrieve it after hot reload final asetId = RxString(''); + // Order type flags + final isAset = true.obs; + final isPaket = false.obs; + // Asset photos data final assetPhotos = [].obs; final currentPhotoIndex = 0.obs; @@ -102,18 +106,27 @@ class OrderSewaAsetController extends GetxController { >{}.obs; final unavailableDatesForHourly = [].obs; + // Package items data + final RxList> paketItems = >[].obs; + final RxBool isPaketItemsLoaded = false.obs; + final RxString paketId = + ''.obs; // Add paketId variable to store the package ID + // Static method for navigation (moved to NavigationService) - static Future navigateToOrderPage(String asetId) async { + static Future navigateToOrderPage( + String asetId, { + bool isAset = true, + }) async { try { // Use ServiceManager to get NavigationService instead of direct Get.find - ServiceManager.navigationService.toOrderSewaAset(asetId); + ServiceManager.navigationService.toOrderSewaAset(asetId, isAset: isAset); debugPrint('✅ Successfully navigated to order page via ServiceManager'); } catch (e) { debugPrint('⚠️ Error in navigateToOrderPage: $e'); // Fallback direct navigation Get.toNamed( '/warga/order-sewa-aset', - arguments: {'asetId': asetId}, + arguments: {'asetId': asetId, 'isAset': isAset}, preventDuplicates: false, ); } @@ -139,7 +152,25 @@ class OrderSewaAsetController extends GetxController { // Initialize unavailable dates collection unavailableDatesForHourly.clear(); + // Initialize controller _initializeController(); + + // Add reaction to ensure jumlahUnit is always 1 when isPaket is true + ever(isPaket, (bool value) { + if (value) { + jumlahUnit.value = 1; + debugPrint('📦 Package detected: Setting jumlahUnit to 1'); + } + }); + + // Add reaction to check for duplicates whenever assetPhotos changes + ever(assetPhotos, (_) { + // Only run if we have more than 1 photo + if (assetPhotos.length > 1) { + debugPrint('🔄 assetPhotos changed, checking for duplicates...'); + ensureNoDuplicatePhotos(); + } + }); } @override @@ -161,8 +192,12 @@ class OrderSewaAsetController extends GetxController { // If we don't have an asset yet but we have an ID stored, load it if (aset.value == null && asetId.value.isNotEmpty) { - debugPrint('🔄 Loading asset data from onReady with ID: ${asetId.value}'); - loadAsetData(asetId.value); + debugPrint('🔄 Loading data from onReady with ID: ${asetId.value}'); + if (isPaket.value) { + loadPaketData(asetId.value); + } else { + loadAsetData(asetId.value); + } } // If we still don't have an asset and no error is set, go back to the previous screen else if (aset.value == null && @@ -175,7 +210,7 @@ class OrderSewaAsetController extends GetxController { navigationService.backFromOrderSewaAset(); Get.snackbar( 'Info', - 'Tidak dapat menampilkan aset - data tidak tersedia', + 'Tidak dapat menampilkan data - data tidak tersedia', snackPosition: SnackPosition.BOTTOM, backgroundColor: Colors.amber, colorText: Colors.black, @@ -211,6 +246,14 @@ class OrderSewaAsetController extends GetxController { if (args != null && args.containsKey('asetId')) { storedId = args['asetId'] as String?; debugPrint('📦 Found asetId in arguments after hot reload: $storedId'); + + // Check if it's a package + isPaket.value = args['isPaket'] == true; + isAset.value = args['isAset'] == true; + + debugPrint( + '📦 Order type after hot reload: ${isPaket.value ? "Package" : "Asset"}', + ); } // Jika tidak ada di arguments, cek di GetStorage @@ -221,14 +264,21 @@ class OrderSewaAsetController extends GetxController { } if (storedId != null && storedId.isNotEmpty) { - debugPrint( - '🔄 Reloading asset data with ID after hot reload: $storedId', - ); + debugPrint('🔄 Reloading data with ID after hot reload: $storedId'); asetId.value = storedId; // Tambahkan delay kecil untuk memastikan controller sudah siap Future.delayed(const Duration(milliseconds: 100), () { - loadAsetData(storedId!); + if (isPaket.value) { + debugPrint('🔄 Loading package data after hot reload'); + // Set paketId.value to ensure it's not empty when creating the order after hot reload + paketId.value = storedId!; + debugPrint('📦 Set paketId.value to: $storedId in handleHotReload'); + loadPaketData(storedId!); + } else { + debugPrint('🔄 Loading asset data after hot reload'); + loadAsetData(storedId!); + } }); } else { debugPrint('⚠️ No asetId found after hot reload'); @@ -243,15 +293,31 @@ class OrderSewaAsetController extends GetxController { final args = Get.arguments; debugPrint('📌 Arguments received in controller: $args'); + // Check for flags + final bool isAsetFlag = args?['isAset'] == true; + final bool isPaketFlag = args?['isPaket'] == true; + + // Set the order type properties + isAset.value = isAsetFlag; + isPaket.value = isPaketFlag; + + debugPrint( + '📌 Order type: ${isAset.value + ? "Single Asset" + : isPaket.value + ? "Package" + : "Unknown"}', + ); + String? newAsetId; if (args != null && args.containsKey('asetId')) { newAsetId = args['asetId'] as String?; - debugPrint('📌 Asset ID from arguments: $newAsetId'); + debugPrint('📌 Asset/Package ID from arguments: $newAsetId'); // Simpan ID ke storage segera setelah menerimanya dari arguments if (newAsetId != null && newAsetId.isNotEmpty) { box.write('current_aset_id', newAsetId); - debugPrint('💾 Immediately saved asetId to GetStorage: $newAsetId'); + debugPrint('💾 Immediately saved ID to GetStorage: $newAsetId'); } } @@ -259,19 +325,31 @@ class OrderSewaAsetController extends GetxController { if ((newAsetId == null || newAsetId.isEmpty) && box.hasData('current_aset_id')) { newAsetId = box.read('current_aset_id'); - debugPrint('📌 Asset ID from GetStorage: $newAsetId'); + debugPrint('📌 Asset/Package ID from GetStorage: $newAsetId'); } if (newAsetId != null && newAsetId.isNotEmpty) { - debugPrint('📌 Using asset ID: $newAsetId'); + debugPrint('📌 Using ID: $newAsetId'); asetId.value = newAsetId; - debugPrint('🔄 Loading asset data with ID: ${asetId.value}'); - loadAsetData(asetId.value); + + // Load data based on type + if (isPaket.value) { + debugPrint('🔄 Loading package data with ID: ${asetId.value}'); + // Set paketId.value to ensure it's not empty when creating the order + paketId.value = newAsetId; + debugPrint( + '📦 Set paketId.value to: $newAsetId in _initializeController', + ); + loadPaketData(asetId.value); + } else { + debugPrint('🔄 Loading asset data with ID: ${asetId.value}'); + loadAsetData(asetId.value); + } } else { - debugPrint('❌ No asset ID available - returning to previous screen'); + debugPrint('❌ No ID available - returning to previous screen'); isLoading.value = false; hasError.value = true; - errorMessage.value = 'ID aset tidak ditemukan'; + errorMessage.value = 'ID tidak ditemukan'; // Don't navigate back here, let onReady handle it } @@ -401,6 +479,9 @@ class OrderSewaAsetController extends GetxController { final photos = await asetProvider.getAsetPhotos(asetId); assetPhotos.value = photos; + // Ensure no duplicates in assetPhotos + ensureNoDuplicatePhotos(); + debugPrint('✅ Loaded ${photos.length} photos for asset'); } catch (e) { debugPrint('❌ Error loading asset photos: $e'); @@ -411,6 +492,37 @@ class OrderSewaAsetController extends GetxController { } } + // Load package items + Future loadPaketItems(String paketId) async { + try { + debugPrint('🔄 Loading package items for paket ID: $paketId'); + isPaketItemsLoaded.value = false; + + final response = await asetProvider.client + .from('paket_item') + .select('*, aset(*)') + .eq('paket_id', paketId); + + if (response != null && response is List && response.isNotEmpty) { + // Convert the response to a list of maps + final items = List>.from(response); + + paketItems.value = items; + isPaketItemsLoaded.value = true; + + debugPrint( + '✅ Loaded ${items.length} package items for paket ID: $paketId', + ); + } else { + debugPrint('⚠️ No package items found for paket ID: $paketId'); + paketItems.value = []; + } + } catch (e) { + debugPrint('❌ Error loading package items: $e'); + isPaketItemsLoaded.value = false; + } + } + // Move to next photo void nextPhoto() { if (assetPhotos.isEmpty) return; @@ -439,9 +551,59 @@ class OrderSewaAsetController extends GetxController { return assetPhotos[currentPhotoIndex.value].fotoAset; } + // Helper method to ensure no duplicate photos in assetPhotos + void ensureNoDuplicatePhotos() { + if (assetPhotos.isEmpty) return; + + debugPrint( + '🔍 Checking for duplicate photos in assetPhotos (before): ${assetPhotos.length}', + ); + + // Use a set to track unique photo URLs + final Set uniqueUrls = {}; + final List uniquePhotos = []; + + // Filter out duplicates + for (var photo in assetPhotos) { + if (photo.fotoAset != null && + photo.fotoAset.isNotEmpty && + !uniqueUrls.contains(photo.fotoAset)) { + uniqueUrls.add(photo.fotoAset); + uniquePhotos.add(photo); + } else { + debugPrint('🗑️ Removing duplicate photo: ${photo.fotoAset}'); + } + } + + // Update assetPhotos with unique photos only if there were duplicates + if (uniquePhotos.length < assetPhotos.length) { + debugPrint( + '🔄 Updating assetPhotos: removed ${assetPhotos.length - uniquePhotos.length} duplicates', + ); + assetPhotos.value = uniquePhotos; + + // Reset currentPhotoIndex if it's out of bounds + if (currentPhotoIndex.value >= assetPhotos.length) { + currentPhotoIndex.value = assetPhotos.length - 1; + if (currentPhotoIndex.value < 0) currentPhotoIndex.value = 0; + } + } + + debugPrint('✅ Unique photos in assetPhotos (after): ${assetPhotos.length}'); + } + // Load bookings for the selected date and calculate availability across dates and times Future loadBookingsForDate(DateTime date) async { try { + // Check if we're dealing with a package or a single asset + if (isPaket.value && paketItems.isNotEmpty) { + debugPrint( + '📦 Loading bookings for package items for date: ${DateFormat('yyyy-MM-dd').format(date)}', + ); + await loadPackageBookingsForDate(date); + return; + } + isLoadingBookings(true); // Clear selections and booked hours @@ -708,6 +870,229 @@ class OrderSewaAsetController extends GetxController { } } + // Load bookings for a specific date for package items + Future loadPackageBookingsForDate(DateTime date) async { + try { + isLoadingBookings(true); + + // Clear selections and booked hours + selectedHours.clear(); + bookedHours.clear(); + bookedHoursList.clear(); + + // Format date for API + final String formattedDate = DateFormat('yyyy-MM-dd').format(date); + debugPrint( + '🔍 Loading package bookings for date: $date (formatted: $formattedDate)', + ); + + // Initialize a combined inventory map for this date + Map> combinedInventory = {}; + + // Initialize inventory for this date with hours 0-23 + // Start with "infinite" availability, we'll find the minimum later + Map dayInventory = {}; + for (int hour = 0; hour < 24; hour++) { + dayInventory[hour] = 999999; // Start with a high number + } + combinedInventory[formattedDate] = dayInventory; + + // Process each package item + for (int i = 0; i < paketItems.length; i++) { + final item = paketItems[i]; + final asetData = item['aset'] as Map?; + + if (asetData == null) { + debugPrint('⚠️ Package item $i has no asset data, skipping'); + continue; + } + + final String asetId = asetData['id'] ?? ''; + final String asetName = asetData['nama'] ?? 'Unknown Asset'; + final int requiredQuantity = + item['kuantitas'] is int ? item['kuantitas'] : 1; + + if (asetId.isEmpty) { + debugPrint('⚠️ Package item $i has empty asset ID, skipping'); + continue; + } + + debugPrint('📦 Processing package item $i: $asetName (ID: $asetId)'); + debugPrint('📦 Required quantity: $requiredQuantity'); + + // Get all bookings for this asset on the specific date + final List> bookings = await asetProvider + .getAsetBookings(asetId, formattedDate); + + debugPrint( + '📊 Found ${bookings.length} bookings for asset $asetName on $formattedDate', + ); + + // Get the total quantity of this asset + final int totalAssetQuantity = + asetData['kuantitas'] is int ? asetData['kuantitas'] : 0; + debugPrint( + '📊 Total quantity for asset $asetName: $totalAssetQuantity', + ); + + // Create an inventory map for this specific asset + Map> assetInventory = {}; + + // Initialize inventory for this date with full quantity + Map assetDayInventory = {}; + for (int hour = 0; hour < 24; hour++) { + assetDayInventory[hour] = totalAssetQuantity; + } + assetInventory[formattedDate] = assetDayInventory; + + // Process all bookings for this asset + if (bookings.isNotEmpty) { + // Process each booking and adjust inventory + for (var booking in bookings) { + final String bookingId = booking['id'] ?? ''; + final String status = booking['status'] ?? ''; + final int bookingQuantity = booking['kuantitas'] ?? 1; + + // Skip rejected bookings + if (status == 'ditolak') { + 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); + + // Calculate all date-hour combinations in the booking range + final List allDateHours = []; + + // Add all hours from start to end + DateTime currentHour = DateTime( + waktuMulai.year, + waktuMulai.month, + waktuMulai.day, + waktuMulai.hour, + ); + + while (!currentHour.isAfter(waktuSelesai)) { + allDateHours.add(currentHour); + currentHour = currentHour.add(const Duration(hours: 1)); + } + + // Process each hour in the booking range to reduce inventory + for (DateTime dateHour in allDateHours) { + final String dateStr = DateFormat( + 'yyyy-MM-dd', + ).format(dateHour); + final int hour = dateHour.hour; + + // Skip if outside our initialized inventory range + if (!assetInventory.containsKey(dateStr)) { + continue; + } + + // Reduce inventory for this hour + if (assetInventory[dateStr]!.containsKey(hour)) { + final int previousQty = assetInventory[dateStr]![hour]!; + assetInventory[dateStr]![hour] = math.max( + 0, + previousQty - bookingQuantity, + ); + } + } + } catch (e) { + debugPrint('❌ Error processing booking $bookingId: $e'); + } + } + } + + // Now update the combined inventory based on this asset's availability + // For each hour, calculate how many complete package items can be booked + for (final hour in assetInventory[formattedDate]!.keys) { + // Calculate how many complete package items can be booked based on this asset + final int availableAssetQty = assetInventory[formattedDate]![hour]!; + final int possiblePackages = availableAssetQty ~/ requiredQuantity; + + // Update combined inventory with the minimum possible packages + combinedInventory[formattedDate]![hour] = math.min( + combinedInventory[formattedDate]![hour]!, + possiblePackages, + ); + } + } + + // Store the combined inventory in our controller + hourlyInventory.clear(); + hourlyInventory.addAll(combinedInventory); + + // Now determine which hours are available for the selected date + if (hourlyInventory.containsKey(formattedDate)) { + final dayInventory = hourlyInventory[formattedDate]!; + + // Debug output of inventory status for this date + debugPrint('📊 PACKAGE INVENTORY STATUS FOR $formattedDate:'); + debugPrint('------------------------------------'); + debugPrint('Requested quantity: ${jumlahUnit.value}'); + + // Business hours (typically 6-21) + List businessHours = List.generate(16, (index) => index + 6); + + // Count available vs unavailable hours + int availableHoursCount = 0; + int unavailableHoursCount = 0; + + for (int hour in businessHours) { + final int availableQty = + dayInventory.containsKey(hour) ? dayInventory[hour]! : 0; + final bool isAvailable = availableQty >= jumlahUnit.value; + final String status = isAvailable ? "✅ AVAILABLE" : "❌ UNAVAILABLE"; + + debugPrint('Hour ${formatHour(hour)}: $availableQty units - $status'); + + if (isAvailable) { + availableHoursCount++; + } else { + unavailableHoursCount++; + bookedHoursList.add(hour); // Mark this hour as unavailable + } + } + + debugPrint('------------------------------------'); + debugPrint( + 'Summary: $availableHoursCount hours available, $unavailableHoursCount hours unavailable', + ); + + // If all business hours are unavailable, add this date to unavailable dates list + if (availableHoursCount == 0) { + final DateTime unavailableDate = DateFormat( + 'yyyy-MM-dd', + ).parse(formattedDate); + if (!unavailableDatesForHourly.contains(unavailableDate)) { + unavailableDatesForHourly.add(unavailableDate); + debugPrint( + '🚫 Date $formattedDate FULLY BOOKED - Adding to unavailable dates', + ); + } + } + } + + debugPrint( + '✅ Completed loading package bookings for date $formattedDate', + ); + } catch (e) { + debugPrint('❌ Error loading package bookings for date: $e'); + } finally { + isLoadingBookings(false); + } + } + // Select a time unit (e.g., hourly or daily) void selectSatuanWaktu(Map satuan) { final bool wasDaily = isDailyRental(); @@ -753,12 +1138,6 @@ class OrderSewaAsetController extends GetxController { // If switching from daily to hourly, reload hourly inventory if (wasDaily) { - // Get current date for hourly rental - final DateTime currentDate = DateTime.now(); - final String formattedDate = DateFormat( - 'yyyy-MM-dd', - ).format(currentDate); - debugPrint( '🔄 Switching to hourly rental mode, loading hourly inventory', ); @@ -767,11 +1146,27 @@ class OrderSewaAsetController extends GetxController { hourlyInventory.clear(); unavailableDatesForHourly.clear(); - // Load current date and next 7 days to have availability data - loadBookingsForDate(currentDate); - for (int i = 1; i <= 7; i++) { - final futureDate = currentDate.add(Duration(days: i)); - loadBookingsForDate(futureDate); + // Check if we're dealing with a package or a regular asset + if (isPaket.value && paketItems.isNotEmpty) { + debugPrint( + '📦 Package detected: Loading comprehensive package booking data', + ); + // For packages, load comprehensive booking data for all items + loadAllPackageBookings(); + } else if (isAset.value) { + debugPrint( + '🏢 Asset detected: Loading comprehensive asset booking data', + ); + // For regular assets, use the same comprehensive loading as initial load + loadAllBookings(); + } else { + // Fallback for any other case + final DateTime currentDate = DateTime.now(); + loadBookingsForDate(currentDate); + for (int i = 1; i <= 7; i++) { + final futureDate = currentDate.add(Duration(days: i)); + loadBookingsForDate(futureDate); + } } } } @@ -1411,6 +1806,13 @@ class OrderSewaAsetController extends GetxController { // Load booked dates for daily rental with inventory-based logic Future loadBookedDates() async { try { + // Check if we're dealing with a package or a single asset + if (isPaket.value && paketItems.isNotEmpty) { + debugPrint('📦 Package detected: Loading package booked dates'); + await loadPackageBookedDates(); + return; + } + isLoadingBookedDates(true); bookedDates.clear(); @@ -1610,9 +2012,231 @@ class OrderSewaAsetController extends GetxController { debugPrint(' - ${DateFormat('yyyy-MM-dd').format(bookedDates[i])}'); } } - } catch (e, stackTrace) { + } catch (e) { debugPrint('❌ Error loading booked dates: $e'); - debugPrint('🔍 Stack trace: $stackTrace'); + } finally { + isLoadingBookedDates(false); + } + } + + // Load booked dates for package items in daily rental mode + Future loadPackageBookedDates() async { + try { + isLoadingBookedDates(true); + bookedDates.clear(); + + // For packages, we always use quantity 1 + final int requestedQuantity = 1; + debugPrint( + '📦 Package booking: Using fixed quantity of $requestedQuantity', + ); + + // Date range to check (next 90 days) + final startDateForQuery = DateTime.now(); + final endDateForQuery = DateTime.now().add(const Duration(days: 90)); + + // Format dates for API + final String formattedStartDate = DateFormat( + 'yyyy-MM-dd', + ).format(startDateForQuery); + final String formattedEndDate = DateFormat( + 'yyyy-MM-dd', + ).format(endDateForQuery); + + debugPrint( + '📦 Checking package availability from $formattedStartDate to $formattedEndDate', + ); + + // Initialize a combined inventory map to track availability across all package items + Map combinedInventory = {}; + + // Initialize inventory for each day in the range with "infinite" availability + for ( + DateTime day = startDateForQuery; + !day.isAfter(endDateForQuery); + day = day.add(const Duration(days: 1)) + ) { + String dateStr = DateFormat('yyyy-MM-dd').format(day); + combinedInventory[dateStr] = 999999; // Start with a high number + } + + // Process each package item + for (int i = 0; i < paketItems.length; i++) { + final item = paketItems[i]; + final asetData = item['aset'] as Map?; + + if (asetData == null) { + debugPrint('⚠️ Package item $i has no asset data, skipping'); + continue; + } + + final String asetId = asetData['id'] ?? ''; + final String asetName = asetData['nama'] ?? 'Unknown Asset'; + final int requiredQuantity = + item['kuantitas'] is int ? item['kuantitas'] : 1; + + if (asetId.isEmpty) { + debugPrint('⚠️ Package item $i has empty asset ID, skipping'); + continue; + } + + debugPrint('📦 Processing package item $i: $asetName (ID: $asetId)'); + debugPrint('📦 Required quantity: $requiredQuantity'); + + // Get all bookings for this asset in the date range + final List> bookings = await asetProvider + .getAsetDailyBookings(asetId, formattedStartDate, formattedEndDate); + + debugPrint('📊 Found ${bookings.length} bookings for asset $asetName'); + + // Get the total quantity of this asset + final int totalAssetQuantity = + asetData['kuantitas'] is int ? asetData['kuantitas'] : 0; + debugPrint( + '📊 Total quantity for asset $asetName: $totalAssetQuantity', + ); + + // Create an inventory map for this specific asset + Map assetInventory = {}; + + // Initialize inventory for each day in the range with full quantity + for ( + DateTime day = startDateForQuery; + !day.isAfter(endDateForQuery); + day = day.add(const Duration(days: 1)) + ) { + String dateStr = DateFormat('yyyy-MM-dd').format(day); + assetInventory[dateStr] = totalAssetQuantity; + } + + // Process all bookings for this asset + if (bookings.isNotEmpty) { + // Process each booking and adjust inventory + for (var booking in bookings) { + final String? startDateStr = booking['waktu_mulai']; + final String? endDateStr = booking['waktu_selesai']; + final int bookingQuantity = booking['kuantitas'] ?? 0; + final String bookingStatus = booking['status'] ?? ''; + + // Skip rejected bookings + if (bookingStatus == 'ditolak') { + continue; + } + + if (startDateStr != null && + endDateStr != null && + bookingQuantity > 0) { + final DateTime bookingStart = DateTime.parse(startDateStr); + final DateTime bookingEnd = DateTime.parse(endDateStr); + + // Get dates without time + final DateTime startDateOnly = DateTime( + bookingStart.year, + bookingStart.month, + bookingStart.day, + ); + final DateTime endDateOnly = DateTime( + bookingEnd.year, + bookingEnd.month, + bookingEnd.day, + ); + + // Reduce available quantity for each day in the booking range + for ( + DateTime day = startDateOnly; + !day.isAfter(endDateOnly); + day = day.add(const Duration(days: 1)) + ) { + String dateStr = DateFormat('yyyy-MM-dd').format(day); + + // Subtract the booking quantity from available inventory + if (assetInventory.containsKey(dateStr)) { + int previousInventory = assetInventory[dateStr]!; + assetInventory[dateStr] = math.max( + 0, + previousInventory - bookingQuantity, + ); + } + } + } + } + } + + // Now update the combined inventory based on this asset's availability + // For each day, calculate how many complete package items can be booked + for (final dateStr in assetInventory.keys) { + // Calculate how many complete package items can be booked based on this asset + final int availableAssetQty = assetInventory[dateStr]!; + final int possiblePackages = + requiredQuantity > 0 ? availableAssetQty ~/ requiredQuantity : 0; + + // Update combined inventory with the minimum possible packages + combinedInventory[dateStr] = math.min( + combinedInventory[dateStr]!, + possiblePackages, + ); + } + } + + // Final inventory status after processing all package items + debugPrint('📊 FINAL PACKAGE INVENTORY STATUS:'); + debugPrint('------------------------------------'); + debugPrint('Requested package quantity: $requestedQuantity'); + + // Show detailed inventory for next 10 days + DateTime currentDate = DateTime.now(); + debugPrint('PACKAGE INVENTORY FOR NEXT 10 DAYS:'); + for (int i = 0; i < 10; i++) { + String dateStr = DateFormat('yyyy-MM-dd').format(currentDate); + int available = combinedInventory[dateStr] ?? 0; + bool isAvailable = available >= requestedQuantity; + String availabilityStatus = + isAvailable ? "✅ AVAILABLE" : "❌ UNAVAILABLE"; + + debugPrint( + '${i + 1}. $dateStr: $available package units available - $availabilityStatus', + ); + currentDate = currentDate.add(const Duration(days: 1)); + } + debugPrint('------------------------------------'); + + // Find days where available quantity is less than requested quantity + for (var entry in combinedInventory.entries) { + if (entry.value < requestedQuantity) { + // Parse the date and add to booked dates + final DateTime bookedDate = DateFormat('yyyy-MM-dd').parse(entry.key); + bookedDates.add(bookedDate); + debugPrint( + '🚫 Disabling date ${entry.key}: available package quantity ${entry.value} < requested $requestedQuantity', + ); + } + } + + // Also add past dates (today and before) to booked dates + final today = DateTime.now(); + final todayDate = DateTime(today.year, today.month, today.day); + + for ( + DateTime day = startDateForQuery; + !day.isAfter(todayDate); + day = day.add(const Duration(days: 1)) + ) { + bookedDates.add(day); + debugPrint( + '🕒 Disabling past date: ${DateFormat('yyyy-MM-dd').format(day)}', + ); + } + + // Debug the total number of disabled dates + debugPrint('📋 Total dates disabled for package: ${bookedDates.length}'); + if (bookedDates.isNotEmpty) { + debugPrint('📅 Sample disabled dates for package:'); + for (int i = 0; i < math.min(5, bookedDates.length); i++) { + debugPrint(' - ${DateFormat('yyyy-MM-dd').format(bookedDates[i])}'); + } + } + } catch (e) { + debugPrint('❌ Error loading package booked dates: $e'); } finally { isLoadingBookedDates(false); } @@ -1896,10 +2520,111 @@ class OrderSewaAsetController extends GetxController { isLoading.value = true; Map sewaAsetData; - Map bookedDetailData; + dynamic + bookedDetailData; // Changed to dynamic to handle both Map and List Map tagihanSewaData; - if (isDailyRental()) { + // Check if this is a package order + if (isPaket.value) { + debugPrint('📦 Creating package order'); + debugPrint('📦 Package ID: ${paketId.value}'); + + // Format date for booking based on rental type + String waktuMulai; + String waktuSelesai; + String satuanWaktu; + int durasiBooking; + + if (isDailyRental()) { + // Create daily rental order for package + final String formattedStartDate = DateFormat( + 'yyyy-MM-dd', + ).format(startDate.value!); + final String formattedEndDate = DateFormat( + 'yyyy-MM-dd', + ).format(endDate.value!); + + // Default time for start is 06:00:00, end is 21:00:00 + waktuMulai = '${formattedStartDate}T06:00:00'; + waktuSelesai = '${formattedEndDate}T21:00:00'; + satuanWaktu = 'hari'; + durasiBooking = + endDate.value!.difference(startDate.value!).inDays + 1; + + debugPrint( + '📅 Creating daily package booking from $waktuMulai to $waktuSelesai', + ); + } else { + // Format date for hourly booking + final DateTime bookingDate = DateFormat( + 'dd MMMM yyyy', + 'id_ID', + ).parse(selectedDate.value); + + // Format start and end times using ISO format + final String formattedDate = DateFormat( + 'yyyy-MM-dd', + ).format(bookingDate); + final String startTime = formatHour(startHour.value); + final String endTime = formatHour(endHour.value); + + // Create ISO timestamp strings + waktuMulai = '${formattedDate}T$startTime:00'; + waktuSelesai = '${formattedDate}T$endTime:00'; + satuanWaktu = 'jam'; + durasiBooking = duration.value; + + debugPrint( + '📅 Creating hourly package booking from $waktuMulai to $waktuSelesai', + ); + } + + // Prepare sewa_aset data for package + sewaAsetData = { + 'id': orderId, // Set UUID as the ID + 'user_id': userId, + 'paket_id': + paketId.value, // Use paket_id instead of aset_id for packages + 'waktu_mulai': waktuMulai, + 'waktu_selesai': waktuSelesai, + 'kuantitas': 1, // Always 1 for packages + 'status': 'MENUNGGU PEMBAYARAN', + 'tipe_pesanan': 'paket', + 'total': totalPrice.value, + 'nama_satuan_waktu': satuanWaktu, + }; + + // Prepare booked_detail data as a List for package items + List> bookedDetailList = []; + + // Create a booked_detail entry for each item in the package + for (var item in paketItems) { + final asetData = item['aset'] as Map?; + final String asetId = asetData?['id'] ?? ''; + final int quantity = item['kuantitas'] is int ? item['kuantitas'] : 1; + + bookedDetailList.add({ + 'id': uuid.v4(), // Generate a new UUID for each booked_detail + 'aset_id': asetId, + 'sewa_aset_id': orderId, + 'paket_id': paketId.value, // Add paket_id for package orders + 'waktu_mulai': waktuMulai, + 'waktu_selesai': waktuSelesai, + 'kuantitas': quantity, + }); + } + + bookedDetailData = bookedDetailList; + + // Prepare tagihan_sewa data + tagihanSewaData = { + 'sewa_aset_id': orderId, + 'durasi': durasiBooking, + 'satuan_waktu': satuanWaktu, + 'harga_sewa': selectedSatuanWaktu.value?['harga'] ?? 0, + 'tagihan_awal': totalPrice.value, + }; + } else if (isDailyRental()) { // Create daily rental order final String formattedStartDate = DateFormat( 'yyyy-MM-dd', @@ -2028,7 +2753,15 @@ class OrderSewaAsetController extends GetxController { debugPrint('✅ Complete order created successfully with ID: $orderId'); // Navigate to payment page - Get.toNamed(Routes.PEMBAYARAN_SEWA, arguments: {'orderId': orderId}); + Get.toNamed( + Routes.PEMBAYARAN_SEWA, + arguments: { + 'orderId': orderId, + 'isPaket': isPaket.value, + 'paketId': isPaket.value ? paketId.value : null, + 'initialTab': 2, // Direct to payment tab + }, + ); } else { // Show error message Get.snackbar( @@ -2055,6 +2788,43 @@ class OrderSewaAsetController extends GetxController { // Validate booking inputs bool _validateBookingInputs() { + // For package orders, we need to validate differently + if (isPaket.value) { + if (selectedSatuanWaktu.value == null) { + _showError('Harap pilih satuan waktu terlebih dahulu'); + return false; + } + + // Check if package items are loaded + if (!isPaketItemsLoaded.value || paketItems.isEmpty) { + _showError('Data paket tidak tersedia. Silakan coba lagi.'); + return false; + } + + // Check if we're using hourly or daily rental + if (isDailyRental()) { + if (startDate.value == null || endDate.value == null) { + _showError('Harap pilih tanggal terlebih dahulu'); + return false; + } + } else { + if (startHour.value == -1 || duration.value <= 0) { + _showError('Harap pilih waktu terlebih dahulu'); + return false; + } + } + + // Check if user is logged in + final userId = authProvider.getCurrentUserId(); + if (userId == null) { + _showError('Anda belum login. Silakan login terlebih dahulu.'); + return false; + } + + return true; + } + + // Regular asset validation if (selectedSatuanWaktu.value == null || aset.value == null) { _showError('Harap pilih satuan waktu terlebih dahulu'); return false; @@ -2098,6 +2868,13 @@ class OrderSewaAsetController extends GetxController { // Load all bookings for the next 30 days to determine available dates Future loadAllBookings() async { try { + // Check if we're dealing with a package or a single asset + if (isPaket.value && paketItems.isNotEmpty) { + debugPrint('📦 Loading bookings for package items'); + await loadAllPackageBookings(); + return; + } + debugPrint( '🔄 Loading all bookings for asset ${aset.value!.id} for the next 30 days', ); @@ -2361,4 +3138,403 @@ class OrderSewaAsetController extends GetxController { date1.month == date2.month && date1.day == date2.day; } + + // Method to load package data + Future loadPaketData(String id) async { + if (id.isEmpty) { + debugPrint('❌ Cannot load package: ID is empty'); + isLoading.value = false; + hasError.value = true; + errorMessage.value = 'ID paket tidak valid'; + return; + } + + try { + isLoading.value = true; + hasError.value = false; + debugPrint('🔄 Loading package data with ID: $id'); + + // For packages, always set jumlahUnit to 1 + jumlahUnit.value = 1; + debugPrint('📦 Package: Setting jumlahUnit to 1'); + + // Save current package ID to storage and explicitly set paketId.value + asetId.value = id; // We're reusing asetId for both assets and packages + paketId.value = + id; // Explicitly set paketId.value to ensure it's not empty + debugPrint('📦 Set paketId.value to: $id'); + + box.write('current_aset_id', id); + debugPrint( + '💾 Saved current paketId to GetStorage during loadPaketData: $id', + ); + + // Get package data from provider + final pakets = await asetProvider.getPakets(); + final paketData = pakets.firstWhereOrNull((p) => p['id'] == id); + + if (paketData != null) { + // Convert package data to AsetModel for compatibility + final convertedAset = AsetModel( + id: paketData['id'], + nama: paketData['nama'], + deskripsi: paketData['deskripsi'], + harga: + paketData['harga'] is num + ? (paketData['harga'] as num).toInt() + : 0, + kuantitas: + paketData['kuantitas'] is num + ? (paketData['kuantitas'] as num).toInt() + : 1, + kategori: 'paket', + status: paketData['status'] ?? 'tersedia', + createdAt: + paketData['created_at'] != null + ? DateTime.parse(paketData['created_at']) + : DateTime.now(), + updatedAt: + paketData['updated_at'] != null + ? DateTime.parse(paketData['updated_at']) + : DateTime.now(), + ); + + // Set satuan waktu sewa after creation + if (paketData['satuanWaktuSewa'] != null) { + final List> satuanList = + List>.from(paketData['satuanWaktuSewa']); + convertedAset.satuanWaktuSewa.clear(); + convertedAset.satuanWaktuSewa.addAll(satuanList); + } + + aset.value = convertedAset; + debugPrint('✅ Package loaded successfully: ${convertedAset.nama}'); + + // Set max unit to total quantity of the package + maxUnit.value = convertedAset.kuantitas ?? 1; + debugPrint('📊 Set max unit to: ${maxUnit.value}'); + + // Load package photos + await loadPaketPhotos(id); + + // Load package items + await loadPaketItems(id); + + // Load all bookings for the next 30 days to initialize availability data + await loadAllBookings(); + + // Find and select hourly option by default if exists + final hourlyOption = convertedAset.satuanWaktuSewa.firstWhereOrNull( + (element) => + element['nama_satuan_waktu']?.toString().toLowerCase().contains( + 'jam', + ) ?? + false, + ); + + if (hourlyOption != null) { + debugPrint( + '✅ Selected hourly option: ${hourlyOption['nama_satuan_waktu']}', + ); + selectSatuanWaktu(hourlyOption); + } else if (convertedAset.satuanWaktuSewa.isNotEmpty) { + // Otherwise select the first option if any exist + debugPrint( + '✅ Selected first available option: ${convertedAset.satuanWaktuSewa[0]['nama_satuan_waktu']}', + ); + selectSatuanWaktu(convertedAset.satuanWaktuSewa[0]); + } + } else { + debugPrint('❌ Package with ID $id not found'); + hasError.value = true; + errorMessage.value = 'Paket tidak ditemukan'; + } + } catch (e) { + debugPrint('❌ Error loading package: $e'); + hasError.value = true; + errorMessage.value = 'Terjadi kesalahan: $e'; + } finally { + isLoading.value = false; + } + } + + // Load package photos + Future loadPaketPhotos(String paketId) async { + try { + // Skip if we're trying to load photos for a different package than the current one + if (isPaket.value && this.paketId.value != paketId) { + debugPrint( + '⚠️ Skipping photo load for package $paketId as current package is ${this.paketId.value}', + ); + return; + } + + isPhotosLoading.value = true; + debugPrint( + '🔄 Starting to load photos for package ID: $paketId, current package ID: ${this.paketId.value}', + ); + + // Clear existing photos + assetPhotos.clear(); + currentPhotoIndex.value = 0; + + // Get photos from provider + final photos = await asetProvider.getFotoPaket(paketId); + + // Filter out empty or null photos and remove duplicates + final Set uniquePhotoUrls = {}; + final List validPhotos = []; + + for (var photo in photos) { + if (photo != null && + photo.isNotEmpty && + !uniquePhotoUrls.contains(photo)) { + uniquePhotoUrls.add(photo); + validPhotos.add(photo); + } + } + + debugPrint( + '📸 Found ${photos.length} total photos, ${validPhotos.length} unique valid photos for package $paketId', + ); + + if (validPhotos.isNotEmpty) { + // Convert to FotoAsetModel for compatibility with existing code + assetPhotos.addAll( + validPhotos.map( + (photo) => FotoAsetModel( + id: '${paketId}_${validPhotos.indexOf(photo)}', + idAset: paketId, + fotoAset: photo, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), + ), + ); + debugPrint( + '✅ Loaded ${assetPhotos.length} unique package photos for package $paketId', + ); + + // Ensure no duplicates in assetPhotos + ensureNoDuplicatePhotos(); + } else { + debugPrint('⚠️ No valid photos found for package $paketId'); + } + } catch (e) { + debugPrint('❌ Error loading package photos for $paketId: $e'); + } finally { + isPhotosLoading.value = false; + debugPrint( + '✅ Completed loading photos for package ID: $paketId, photo count: ${assetPhotos.length}', + ); + } + } + + // Load bookings for all assets in a package and calculate combined availability + Future loadAllPackageBookings() async { + try { + debugPrint('📦 Loading bookings for all package items'); + + // Clear current inventory data + hourlyInventory.clear(); + unavailableDatesForHourly.clear(); + + // Date range to check (today + 30 days) + final DateTime today = DateTime.now(); + final DateTime endDate = today.add(const Duration(days: 30)); + + // Format dates for API + final String formattedStartDate = DateFormat('yyyy-MM-dd').format(today); + final String formattedEndDate = DateFormat('yyyy-MM-dd').format(endDate); + + debugPrint( + '📅 Package: Fetching bookings from $formattedStartDate to $formattedEndDate', + ); + + // Initialize a combined inventory map to track availability across all package items + Map> combinedInventory = {}; + + // Initialize inventory for next 30 days, each with hours 0-23 + // Start with "infinite" availability, we'll find the minimum later + for (int day = 0; day < 30; day++) { + final DateTime currentDate = today.add(Duration(days: day)); + final String currentDateStr = DateFormat( + 'yyyy-MM-dd', + ).format(currentDate); + + // Initialize inventory for each hour of this day + Map dayInventory = {}; + for (int hour = 0; hour < 24; hour++) { + dayInventory[hour] = 999999; // Start with a high number + } + + combinedInventory[currentDateStr] = dayInventory; + } + + // Process each package item + for (int i = 0; i < paketItems.length; i++) { + final item = paketItems[i]; + final asetData = item['aset'] as Map?; + + if (asetData == null) { + debugPrint('⚠️ Package item $i has no asset data, skipping'); + continue; + } + + final String asetId = asetData['id'] ?? ''; + final String asetName = asetData['nama'] ?? 'Unknown Asset'; + final int requiredQuantity = + item['kuantitas'] is int ? item['kuantitas'] : 1; + + if (asetId.isEmpty) { + debugPrint('⚠️ Package item $i has empty asset ID, skipping'); + continue; + } + + debugPrint('📦 Processing package item $i: $asetName (ID: $asetId)'); + debugPrint('📦 Required quantity: $requiredQuantity'); + + // Get all bookings for this asset in the date range + final List> bookings = await asetProvider + .getAsetDailyBookings(asetId, formattedStartDate, formattedEndDate); + + debugPrint('📊 Found ${bookings.length} bookings for asset $asetName'); + + // Get the total quantity of this asset + final int totalAssetQuantity = + asetData['kuantitas'] is int ? asetData['kuantitas'] : 0; + debugPrint( + '📊 Total quantity for asset $asetName: $totalAssetQuantity', + ); + + // Create an inventory map for this specific asset + Map> assetInventory = {}; + + // Initialize inventory for next 30 days for this asset + for (int day = 0; day < 30; day++) { + final DateTime currentDate = today.add(Duration(days: day)); + final String currentDateStr = DateFormat( + 'yyyy-MM-dd', + ).format(currentDate); + + // Initialize inventory for each hour of this day with full quantity + Map dayInventory = {}; + for (int hour = 0; hour < 24; hour++) { + dayInventory[hour] = totalAssetQuantity; + } + + assetInventory[currentDateStr] = dayInventory; + } + + // Process all bookings for this asset + if (bookings.isNotEmpty) { + // Sort bookings by start time + bookings.sort((a, b) { + final String startA = a['waktu_mulai'] ?? ''; + final String startB = b['waktu_mulai'] ?? ''; + return startA.compareTo(startB); + }); + + // Process each booking and adjust inventory + for (var booking in bookings) { + final String bookingId = booking['id'] ?? ''; + final String status = booking['status'] ?? ''; + final int bookingQuantity = booking['kuantitas'] ?? 1; + + // Skip rejected bookings + if (status == 'ditolak') { + 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); + + // Calculate all date-hour combinations in the booking range + final List allDateHours = []; + + // Add all hours from start to end + DateTime currentHour = DateTime( + waktuMulai.year, + waktuMulai.month, + waktuMulai.day, + waktuMulai.hour, + ); + + while (!currentHour.isAfter(waktuSelesai)) { + allDateHours.add(currentHour); + currentHour = currentHour.add(const Duration(hours: 1)); + } + + // Process each hour in the booking range to reduce inventory + for (DateTime dateHour in allDateHours) { + final String dateStr = DateFormat( + 'yyyy-MM-dd', + ).format(dateHour); + final int hour = dateHour.hour; + + // Skip if outside our initialized inventory range + if (!assetInventory.containsKey(dateStr)) { + continue; + } + + // Reduce inventory for this hour + if (assetInventory[dateStr]!.containsKey(hour)) { + final int previousQty = assetInventory[dateStr]![hour]!; + assetInventory[dateStr]![hour] = math.max( + 0, + previousQty - bookingQuantity, + ); + } + } + } catch (e) { + debugPrint('❌ Error processing booking $bookingId: $e'); + } + } + } + + // Now update the combined inventory based on this asset's availability + // For each date and hour, calculate how many complete package items can be booked + for (final dateStr in assetInventory.keys) { + for (final hour in assetInventory[dateStr]!.keys) { + // Calculate how many complete package items can be booked based on this asset + final int availableAssetQty = assetInventory[dateStr]![hour]!; + final int possiblePackages = availableAssetQty ~/ requiredQuantity; + + // Update combined inventory with the minimum possible packages + combinedInventory[dateStr]![hour] = math.min( + combinedInventory[dateStr]![hour]!, + possiblePackages, + ); + } + } + } + + // Store the combined inventory in our controller + hourlyInventory.clear(); + hourlyInventory.addAll(combinedInventory); + + // Now determine which dates are fully booked (no available hours during business hours) + _identifyUnavailableDates(); + + debugPrint( + '✅ Completed loading all package item bookings and calculating availability', + ); + debugPrint( + '🗓️ Total unavailable dates identified: ${unavailableDatesForHourly.length}', + ); + } catch (e) { + debugPrint('❌ Error loading package bookings: $e'); + } finally { + isLoadingBookings(false); + } + } } diff --git a/lib/app/modules/warga/controllers/pembayaran_sewa_controller.dart b/lib/app/modules/warga/controllers/pembayaran_sewa_controller.dart index 70a63c3..028f5d1 100644 --- a/lib/app/modules/warga/controllers/pembayaran_sewa_controller.dart +++ b/lib/app/modules/warga/controllers/pembayaran_sewa_controller.dart @@ -59,6 +59,13 @@ class PembayaranSewaController extends GetxController final RxList imagesToDeleteTagihanAwal = [].obs; final RxList imagesToDeleteDenda = [].obs; + // Package related properties + final isPaket = false.obs; + final paketId = ''.obs; + final paketDetails = Rx>({}); + final paketItems = >[].obs; + final isPaketItemsLoaded = false.obs; + // Flag to track if there are changes that need to be saved final RxBool hasUnsavedChangesTagihanAwal = false.obs; final RxBool hasUnsavedChangesDenda = false.obs; @@ -255,6 +262,24 @@ class PembayaranSewaController extends GetxController if (Get.arguments['orderId'] != null) { orderId.value = Get.arguments['orderId']; + // Get isPaket flag and paketId + isPaket.value = Get.arguments['isPaket'] == true; + if (isPaket.value && Get.arguments['paketId'] != null) { + paketId.value = Get.arguments['paketId']; + debugPrint( + '📦 This is a package order with paketId: ${paketId.value}', + ); + } + + // Set initial tab if specified + if (Get.arguments['initialTab'] != null) { + int initialTab = Get.arguments['initialTab']; + if (initialTab >= 0 && initialTab < tabController.length) { + debugPrint('Setting initial tab to: $initialTab'); + tabController.animateTo(initialTab); + } + } + // If rental data is passed, use it directly if (Get.arguments['rentalData'] != null) { Map rentalData = Get.arguments['rentalData']; @@ -367,6 +392,11 @@ class PembayaranSewaController extends GetxController '✅ Sewa aset details loaded: ${sewaAsetDetails.value['id']}', ); + // If this is a package order, load package details + if (isPaket.value && paketId.value.isNotEmpty) { + loadPaketDetails(); + } + // Debug all fields in the sewaAsetDetails debugPrint('📋 SEWA ASET DETAILS (COMPLETE DATA):'); data.forEach((key, value) { @@ -1250,4 +1280,56 @@ class PembayaranSewaController extends GetxController return Future.value(); } + + // Load package details and items + Future loadPaketDetails() async { + if (!isPaket.value || paketId.value.isEmpty) return; + + try { + debugPrint('🔄 Loading package details for ID: ${paketId.value}'); + + // Get package details + final paketResponse = + await client + .from('paket') + .select('*') + .eq('id', paketId.value) + .maybeSingle(); + + if (paketResponse != null) { + paketDetails.value = paketResponse; + debugPrint('✅ Package details loaded: ${paketDetails.value['nama']}'); + } + + // Load package items + debugPrint('🔄 Loading package items for package ID: ${paketId.value}'); + final itemsResponse = await client + .from('paket_item') + .select('*, aset(*)') + .eq('paket_id', paketId.value); + + if (itemsResponse != null && + itemsResponse is List && + itemsResponse.isNotEmpty) { + paketItems.value = List>.from(itemsResponse); + isPaketItemsLoaded.value = true; + debugPrint('✅ Loaded ${paketItems.length} package items'); + } else { + paketItems.clear(); + debugPrint('⚠️ No package items found'); + } + } catch (e) { + debugPrint('❌ Error loading package details: $e'); + } + } + + // Check if the sewa_aset table has the necessary columns + + // Handle back button press - navigate to warga sewa page + void onBackPressed() { + debugPrint( + '🔙 Back button pressed in PembayaranSewaView - navigating to WargaSewa', + ); + navigationService.toWargaSewa(); + } } diff --git a/lib/app/modules/warga/controllers/warga_dashboard_controller.dart b/lib/app/modules/warga/controllers/warga_dashboard_controller.dart index af00025..83247b7 100644 --- a/lib/app/modules/warga/controllers/warga_dashboard_controller.dart +++ b/lib/app/modules/warga/controllers/warga_dashboard_controller.dart @@ -1,9 +1,14 @@ import 'package:get/get.dart'; +import 'package:flutter/material.dart'; import '../../../data/providers/auth_provider.dart'; import '../../../routes/app_routes.dart'; import '../../../services/navigation_service.dart'; import '../../../data/providers/aset_provider.dart'; +import '../../../theme/app_colors.dart'; import 'package:intl/intl.dart'; +import 'package:flutter/foundation.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:image_picker/image_picker.dart'; class WargaDashboardController extends GetxController { // Dependency injection @@ -19,6 +24,10 @@ class WargaDashboardController extends GetxController { final userNik = ''.obs; final userPhone = ''.obs; final userAddress = ''.obs; + final userTanggalLahir = ''.obs; + final userRtRw = ''.obs; + final userKelurahanDesa = ''.obs; + final userKecamatan = ''.obs; // Navigation state is now managed by NavigationService @@ -90,6 +99,18 @@ class WargaDashboardController extends GetxController { userNik.value = await _authProvider.getUserNIK() ?? ''; userPhone.value = await _authProvider.getUserPhone() ?? ''; userAddress.value = await _authProvider.getUserAddress() ?? ''; + + // Load additional profile data + final tanggalLahir = await _authProvider.getUserTanggalLahir(); + final rtRw = await _authProvider.getUserRtRw(); + final kelurahanDesa = await _authProvider.getUserKelurahanDesa(); + final kecamatan = await _authProvider.getUserKecamatan(); + + // Set values for additional profile data + userTanggalLahir.value = tanggalLahir ?? 'Tidak tersedia'; + userRtRw.value = rtRw ?? 'Tidak tersedia'; + userKelurahanDesa.value = kelurahanDesa ?? 'Tidak tersedia'; + userKecamatan.value = kecamatan ?? 'Tidak tersedia'; } catch (e) { print('Error loading user data: $e'); } @@ -330,4 +351,369 @@ class WargaDashboardController extends GetxController { print('Error fetching profile from warga_desa: $e'); } } + + // Method to update user profile data in warga_desa table + Future updateUserProfile({ + required String namaLengkap, + required String noHp, + }) async { + try { + final user = _authProvider.currentUser; + if (user == null) { + print('Cannot update profile: No current user'); + return false; + } + + final userId = user.id; + + // Update data in warga_desa table + await _authProvider.client + .from('warga_desa') + .update({'nama_lengkap': namaLengkap, 'no_hp': noHp}) + .eq('user_id', userId); + + // Update local values + userName.value = namaLengkap; + userPhone.value = noHp; + + print('Profile updated successfully for user: $userId'); + return true; + } catch (e) { + print('Error updating user profile: $e'); + return false; + } + } + + // Method to delete user avatar + Future deleteUserAvatar() async { + try { + final user = _authProvider.currentUser; + if (user == null) { + print('Cannot delete avatar: No current user'); + return false; + } + + final userId = user.id; + final currentAvatarUrl = userAvatar.value; + + // If there's an avatar URL, delete it from storage + if (currentAvatarUrl != null && currentAvatarUrl.isNotEmpty) { + try { + print('Attempting to delete avatar from URL: $currentAvatarUrl'); + + // Extract filename from URL + // The URL format is typically: + // https://[project-ref].supabase.co/storage/v1/object/public/warga/[filename] + + final uri = Uri.parse(currentAvatarUrl); + final path = uri.path; + + // Find the filename after the last slash + final filename = path.substring(path.lastIndexOf('/') + 1); + + if (filename.isNotEmpty) { + print('Extracted filename: $filename'); + + // Delete from storage bucket 'warga' + final response = await _authProvider.client.storage + .from('warga') + .remove([filename]); + + print('Storage deletion response: $response'); + } else { + print('Failed to extract filename from avatar URL'); + } + } catch (e) { + print('Error deleting avatar from storage: $e'); + // Continue with database update even if storage delete fails + } + } + + // Update warga_desa table to set avatar to null + await _authProvider.client + .from('warga_desa') + .update({'avatar': null}) + .eq('user_id', userId); + + // Update local value + userAvatar.value = ''; + + print('Avatar deleted successfully for user: $userId'); + return true; + } catch (e) { + print('Error deleting user avatar: $e'); + return false; + } + } + + // Method to update user avatar URL + Future updateUserAvatar(String avatarUrl) async { + try { + final user = _authProvider.currentUser; + if (user == null) { + print('Cannot update avatar: No current user'); + return false; + } + + final userId = user.id; + + // Update data in warga_desa table + await _authProvider.client + .from('warga_desa') + .update({'avatar': avatarUrl}) + .eq('user_id', userId); + + // Update local value + userAvatar.value = avatarUrl; + + print('Avatar updated successfully for user: $userId'); + return true; + } catch (e) { + print('Error updating user avatar: $e'); + return false; + } + } + + // Method to upload avatar image to Supabase storage + Future uploadAvatar(Uint8List fileBytes, String fileName) async { + try { + final user = _authProvider.currentUser; + if (user == null) { + print('Cannot upload avatar: No current user'); + return null; + } + + // Generate a unique filename using timestamp and user ID + final timestamp = DateTime.now().millisecondsSinceEpoch; + final extension = fileName.split('.').last; + final uniqueFileName = 'avatar_${user.id}_$timestamp.$extension'; + + // Upload to 'warga' bucket + final response = await _authProvider.client.storage + .from('warga') + .uploadBinary( + uniqueFileName, + fileBytes, + fileOptions: const FileOptions(cacheControl: '3600', upsert: true), + ); + + // Get the public URL + final publicUrl = _authProvider.client.storage + .from('warga') + .getPublicUrl(uniqueFileName); + + print('Avatar uploaded successfully: $publicUrl'); + return publicUrl; + } catch (e) { + print('Error uploading avatar: $e'); + return null; + } + } + + // Method to handle image picking from camera or gallery + Future pickImage(ImageSource source) async { + try { + // Pick image directly without permission checks + final ImagePicker picker = ImagePicker(); + final XFile? pickedFile = await picker.pickImage( + source: source, + maxWidth: 800, + maxHeight: 800, + imageQuality: 85, + ); + + if (pickedFile != null) { + print('Image picked: ${pickedFile.path}'); + } + return pickedFile; + } catch (e) { + print('Error picking image: $e'); + + // Show error message if there's an issue + Get.snackbar( + 'Gagal', + 'Tidak dapat mengakses ${source == ImageSource.camera ? 'kamera' : 'galeri'}', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red.shade700, + colorText: Colors.white, + duration: const Duration(seconds: 3), + ); + + return null; + } + } + + // Method to show image source selection dialog + Future showImageSourceDialog() async { + await Get.bottomSheet( + Container( + padding: const EdgeInsets.symmetric(vertical: 20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + const Text( + 'Pilih Sumber Gambar', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildImageSourceOption( + icon: Icons.camera_alt_rounded, + label: 'Kamera', + onTap: () async { + Get.back(); + final pickedFile = await pickImage(ImageSource.camera); + if (pickedFile != null) { + await processPickedImage(pickedFile); + } + }, + ), + _buildImageSourceOption( + icon: Icons.photo_library_rounded, + label: 'Galeri', + onTap: () async { + Get.back(); + final pickedFile = await pickImage(ImageSource.gallery); + if (pickedFile != null) { + await processPickedImage(pickedFile); + } + }, + ), + ], + ), + const SizedBox(height: 20), + ], + ), + ), + isDismissible: true, + enableDrag: true, + ); + } + + // Helper method to build image source option + Widget _buildImageSourceOption({ + required IconData icon, + required String label, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.primary.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon(icon, color: AppColors.primary, size: 32), + ), + const SizedBox(height: 8), + Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.grey.shade800, + ), + ), + ], + ), + ); + } + + // Method to process picked image (temporary preview before saving) + Future processPickedImage(XFile pickedFile) async { + try { + // Read file as bytes + final bytes = await pickedFile.readAsBytes(); + + // Store the picked file temporarily for later use when saving + tempPickedFile.value = pickedFile; + + // Update UI with temporary avatar preview + tempAvatarBytes.value = bytes; + + print('Image processed for preview'); + } catch (e) { + print('Error processing picked image: $e'); + } + } + + // Method to save the picked image to Supabase and update profile + Future saveNewAvatar() async { + try { + if (tempPickedFile.value == null || tempAvatarBytes.value == null) { + print('No temporary image to save'); + return false; + } + + final pickedFile = tempPickedFile.value!; + final bytes = tempAvatarBytes.value!; + + // First delete the old avatar if exists + final currentAvatarUrl = userAvatar.value; + if (currentAvatarUrl != null && currentAvatarUrl.isNotEmpty) { + try { + await deleteUserAvatar(); + } catch (e) { + print('Error deleting old avatar: $e'); + // Continue with upload even if delete fails + } + } + + // Upload new avatar + final newAvatarUrl = await uploadAvatar(bytes, pickedFile.name); + if (newAvatarUrl == null) { + print('Failed to upload new avatar'); + return false; + } + + // Update avatar URL in database + final success = await updateUserAvatar(newAvatarUrl); + + if (success) { + // Clear temporary data + tempPickedFile.value = null; + tempAvatarBytes.value = null; + + print('Avatar updated successfully'); + } + + return success; + } catch (e) { + print('Error saving new avatar: $e'); + return false; + } + } + + // Method to cancel avatar change + void cancelAvatarChange() { + tempPickedFile.value = null; + tempAvatarBytes.value = null; + print('Avatar change canceled'); + } + + // Temporary storage for picked image + final Rx tempPickedFile = Rx(null); + final Rx tempAvatarBytes = Rx(null); } diff --git a/lib/app/modules/warga/controllers/warga_sewa_controller.dart b/lib/app/modules/warga/controllers/warga_sewa_controller.dart index b5e912f..9b8dbaf 100644 --- a/lib/app/modules/warga/controllers/warga_sewa_controller.dart +++ b/lib/app/modules/warga/controllers/warga_sewa_controller.dart @@ -133,6 +133,111 @@ class WargaSewaController extends GetxController super.onClose(); } + // Helper method to process rental data + Future> _processRentalData( + Map sewaAset, + ) async { + // Get asset details if aset_id is available + String assetName = 'Aset'; + String? imageUrl; + String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam'; + + // Check if this is a package or single asset rental + bool isPaket = sewaAset['aset_id'] == null && sewaAset['paket_id'] != null; + + if (isPaket) { + // Use package data that was fetched in getSewaAsetByStatus + assetName = sewaAset['nama_paket'] ?? 'Paket'; + imageUrl = sewaAset['foto_paket']; + debugPrint( + 'Using package data: name=${assetName}, imageUrl=${imageUrl ?? "none"}', + ); + } else if (sewaAset['aset_id'] != null) { + // Regular asset rental + final asetData = await asetProvider.getAsetById(sewaAset['aset_id']); + if (asetData != null) { + assetName = asetData.nama; + imageUrl = asetData.imageUrl; + } + } + + // Parse waktu mulai and waktu selesai + DateTime? waktuMulai; + DateTime? waktuSelesai; + String waktuSewa = ''; + String tanggalSewa = ''; + String jamMulai = ''; + String jamSelesai = ''; + String rentangWaktu = ''; + + if (sewaAset['waktu_mulai'] != null && sewaAset['waktu_selesai'] != null) { + waktuMulai = DateTime.parse(sewaAset['waktu_mulai']); + waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']); + + // Format for display + final formatTanggal = DateFormat('dd-MM-yyyy'); + final formatWaktu = DateFormat('HH:mm'); + final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID'); + + tanggalSewa = formatTanggalLengkap.format(waktuMulai); + jamMulai = formatWaktu.format(waktuMulai); + jamSelesai = formatWaktu.format(waktuSelesai); + + // Format based on satuan waktu + if (namaSatuanWaktu.toLowerCase() == 'jam') { + // For hours, show time range on same day + rentangWaktu = '$jamMulai - $jamSelesai'; + } else if (namaSatuanWaktu.toLowerCase() == 'hari') { + // For days, show date range + final tanggalMulai = formatTanggalLengkap.format(waktuMulai); + final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai); + rentangWaktu = '$tanggalMulai - $tanggalSelesai'; + } else { + // Default format + rentangWaktu = '$jamMulai - $jamSelesai'; + } + + // Full time format for waktuSewa + waktuSewa = + '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - ' + '${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}'; + } + + // Format price + String totalPrice = 'Rp 0'; + if (sewaAset['total'] != null) { + final formatter = NumberFormat.currency( + locale: 'id', + symbol: 'Rp ', + decimalDigits: 0, + ); + totalPrice = formatter.format(sewaAset['total']); + } + + // Return processed rental data + return { + 'id': sewaAset['id'] ?? '', + 'name': assetName, + 'imageUrl': imageUrl ?? 'assets/images/gambar_pendukung.jpg', + 'jumlahUnit': sewaAset['kuantitas'] ?? 0, + 'waktuSewa': waktuSewa, + 'duration': '${sewaAset['durasi'] ?? 0} ${namaSatuanWaktu}', + 'status': sewaAset['status'] ?? '', + 'totalPrice': totalPrice, + 'countdown': '00:59:59', // Default countdown + 'tanggalSewa': tanggalSewa, + 'jamMulai': jamMulai, + 'jamSelesai': jamSelesai, + 'rentangWaktu': rentangWaktu, + 'namaSatuanWaktu': namaSatuanWaktu, + 'waktuMulai': sewaAset['waktu_mulai'], + 'waktuSelesai': sewaAset['waktu_selesai'], + 'updated_at': sewaAset['updated_at'], + 'isPaket': isPaket, + 'paketId': isPaket ? sewaAset['paket_id'] : null, + }; + } + // Load real data from sewa_aset table Future loadRentalsData() async { try { @@ -151,93 +256,9 @@ class WargaSewaController extends GetxController // Process each sewa_aset record for (var sewaAset in sewaAsetList) { - // Get asset details if aset_id is available - String assetName = 'Aset'; - String? imageUrl; - String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam'; - - if (sewaAset['aset_id'] != null) { - final asetData = await asetProvider.getAsetById(sewaAset['aset_id']); - if (asetData != null) { - assetName = asetData.nama; - imageUrl = asetData.imageUrl; - } - } - - // Parse waktu mulai and waktu selesai - DateTime? waktuMulai; - DateTime? waktuSelesai; - String waktuSewa = ''; - String tanggalSewa = ''; - String jamMulai = ''; - String jamSelesai = ''; - String rentangWaktu = ''; - - if (sewaAset['waktu_mulai'] != null && - sewaAset['waktu_selesai'] != null) { - waktuMulai = DateTime.parse(sewaAset['waktu_mulai']); - waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']); - - // Format for display - final formatTanggal = DateFormat('dd-MM-yyyy'); - final formatWaktu = DateFormat('HH:mm'); - final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID'); - - tanggalSewa = formatTanggalLengkap.format(waktuMulai); - jamMulai = formatWaktu.format(waktuMulai); - jamSelesai = formatWaktu.format(waktuSelesai); - - // Format based on satuan waktu - if (namaSatuanWaktu.toLowerCase() == 'jam') { - // For hours, show time range on same day - rentangWaktu = '$jamMulai - $jamSelesai'; - } else if (namaSatuanWaktu.toLowerCase() == 'hari') { - // For days, show date range - final tanggalMulai = formatTanggalLengkap.format(waktuMulai); - final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai); - rentangWaktu = '$tanggalMulai - $tanggalSelesai'; - } else { - // Default format - rentangWaktu = '$jamMulai - $jamSelesai'; - } - - // Full time format for waktuSewa - waktuSewa = - '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - ' - '${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}'; - } - - // Format price - String totalPrice = 'Rp 0'; - if (sewaAset['total'] != null) { - final formatter = NumberFormat.currency( - locale: 'id', - symbol: 'Rp ', - decimalDigits: 0, - ); - totalPrice = formatter.format(sewaAset['total']); - } - - // Add to rentals list - rentals.add({ - 'id': sewaAset['id'] ?? '', - 'name': assetName, - 'imageUrl': imageUrl ?? 'assets/images/gambar_pendukung.jpg', - 'jumlahUnit': sewaAset['kuantitas'] ?? 0, - 'waktuSewa': waktuSewa, - 'duration': '${sewaAset['durasi'] ?? 0} ${namaSatuanWaktu}', - 'status': sewaAset['status'] ?? 'MENUNGGU PEMBAYARAN', - 'totalPrice': totalPrice, - 'countdown': '00:59:59', // Default countdown - 'tanggalSewa': tanggalSewa, - 'jamMulai': jamMulai, - 'jamSelesai': jamSelesai, - 'rentangWaktu': rentangWaktu, - 'namaSatuanWaktu': namaSatuanWaktu, - 'waktuMulai': sewaAset['waktu_mulai'], - 'waktuSelesai': sewaAset['waktu_selesai'], - 'updated_at': sewaAset['updated_at'], - }); + final processedData = await _processRentalData(sewaAset); + processedData['status'] = sewaAset['status'] ?? 'MENUNGGU PEMBAYARAN'; + rentals.add(processedData); } debugPrint('Processed ${rentals.length} rental records'); @@ -335,6 +356,23 @@ class WargaSewaController extends GetxController ); } + // Navigate directly to payment tab of payment page with the selected rental data + void viewPaymentTab(Map rental) { + debugPrint('Navigating to payment tab with rental ID: ${rental['id']}'); + + // Navigate to payment page with rental data and initialTab set to 2 (payment tab) + Get.toNamed( + Routes.PEMBAYARAN_SEWA, + arguments: { + 'orderId': rental['id'], + 'rentalData': rental, + 'initialTab': 2, // Index 2 corresponds to the payment tab + 'isPaket': rental['isPaket'] ?? false, + 'paketId': rental['paketId'], + }, + ); + } + void payRental(String id) { Get.snackbar( 'Info', @@ -358,91 +396,9 @@ class WargaSewaController extends GetxController // Process each sewa_aset record for (var sewaAset in sewaAsetList) { - // Get asset details if aset_id is available - String assetName = 'Aset'; - String? imageUrl; - String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam'; - - if (sewaAset['aset_id'] != null) { - final asetData = await asetProvider.getAsetById(sewaAset['aset_id']); - if (asetData != null) { - assetName = asetData.nama; - imageUrl = asetData.imageUrl; - } - } - - // Parse waktu mulai and waktu selesai - DateTime? waktuMulai; - DateTime? waktuSelesai; - String waktuSewa = ''; - String tanggalSewa = ''; - String jamMulai = ''; - String jamSelesai = ''; - String rentangWaktu = ''; - - if (sewaAset['waktu_mulai'] != null && - sewaAset['waktu_selesai'] != null) { - waktuMulai = DateTime.parse(sewaAset['waktu_mulai']); - waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']); - - // Format for display - final formatTanggal = DateFormat('dd-MM-yyyy'); - final formatWaktu = DateFormat('HH:mm'); - final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID'); - - tanggalSewa = formatTanggalLengkap.format(waktuMulai); - jamMulai = formatWaktu.format(waktuMulai); - jamSelesai = formatWaktu.format(waktuSelesai); - - // Format based on satuan waktu - if (namaSatuanWaktu.toLowerCase() == 'jam') { - // For hours, show time range on same day - rentangWaktu = '$jamMulai - $jamSelesai'; - } else if (namaSatuanWaktu.toLowerCase() == 'hari') { - // For days, show date range - final tanggalMulai = formatTanggalLengkap.format(waktuMulai); - final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai); - rentangWaktu = '$tanggalMulai - $tanggalSelesai'; - } else { - // Default format - rentangWaktu = '$jamMulai - $jamSelesai'; - } - - // Full time format for waktuSewa - waktuSewa = - '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - ' - '${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}'; - } - - // Format price - String totalPrice = 'Rp 0'; - if (sewaAset['total'] != null) { - final formatter = NumberFormat.currency( - locale: 'id', - symbol: 'Rp ', - decimalDigits: 0, - ); - totalPrice = formatter.format(sewaAset['total']); - } - - // Add to completed rentals list - completedRentals.add({ - 'id': sewaAset['id'] ?? '', - 'name': assetName, - 'imageUrl': imageUrl ?? 'assets/images/gambar_pendukung.jpg', - 'jumlahUnit': sewaAset['kuantitas'] ?? 0, - 'waktuSewa': waktuSewa, - 'duration': '${sewaAset['durasi'] ?? 0} ${namaSatuanWaktu}', - 'status': sewaAset['status'] ?? 'SELESAI', - 'totalPrice': totalPrice, - 'tanggalSewa': tanggalSewa, - 'jamMulai': jamMulai, - 'jamSelesai': jamSelesai, - 'rentangWaktu': rentangWaktu, - 'namaSatuanWaktu': namaSatuanWaktu, - 'waktuMulai': sewaAset['waktu_mulai'], - 'waktuSelesai': sewaAset['waktu_selesai'], - }); + final processedData = await _processRentalData(sewaAset); + processedData['status'] = sewaAset['status'] ?? 'SELESAI'; + completedRentals.add(processedData); } debugPrint( @@ -472,92 +428,11 @@ class WargaSewaController extends GetxController // Process each sewa_aset record for (var sewaAset in sewaAsetList) { - // Get asset details if aset_id is available - String assetName = 'Aset'; - String? imageUrl; - String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam'; - - if (sewaAset['aset_id'] != null) { - final asetData = await asetProvider.getAsetById(sewaAset['aset_id']); - if (asetData != null) { - assetName = asetData.nama; - imageUrl = asetData.imageUrl; - } - } - - // Parse waktu mulai and waktu selesai - DateTime? waktuMulai; - DateTime? waktuSelesai; - String waktuSewa = ''; - String tanggalSewa = ''; - String jamMulai = ''; - String jamSelesai = ''; - String rentangWaktu = ''; - - if (sewaAset['waktu_mulai'] != null && - sewaAset['waktu_selesai'] != null) { - waktuMulai = DateTime.parse(sewaAset['waktu_mulai']); - waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']); - - // Format for display - final formatTanggal = DateFormat('dd-MM-yyyy'); - final formatWaktu = DateFormat('HH:mm'); - final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID'); - - tanggalSewa = formatTanggalLengkap.format(waktuMulai); - jamMulai = formatWaktu.format(waktuMulai); - jamSelesai = formatWaktu.format(waktuSelesai); - - // Format based on satuan waktu - if (namaSatuanWaktu.toLowerCase() == 'jam') { - // For hours, show time range on same day - rentangWaktu = '$jamMulai - $jamSelesai'; - } else if (namaSatuanWaktu.toLowerCase() == 'hari') { - // For days, show date range - final tanggalMulai = formatTanggalLengkap.format(waktuMulai); - final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai); - rentangWaktu = '$tanggalMulai - $tanggalSelesai'; - } else { - // Default format - rentangWaktu = '$jamMulai - $jamSelesai'; - } - - // Full time format for waktuSewa - waktuSewa = - '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - ' - '${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}'; - } - - // Format price - String totalPrice = 'Rp 0'; - if (sewaAset['total'] != null) { - final formatter = NumberFormat.currency( - locale: 'id', - symbol: 'Rp ', - decimalDigits: 0, - ); - totalPrice = formatter.format(sewaAset['total']); - } - - // Add to cancelled rentals list - cancelledRentals.add({ - 'id': sewaAset['id'] ?? '', - 'name': assetName, - 'imageUrl': imageUrl ?? 'assets/images/gambar_pendukung.jpg', - 'jumlahUnit': sewaAset['kuantitas'] ?? 0, - 'waktuSewa': waktuSewa, - 'duration': '${sewaAset['durasi'] ?? 0} ${namaSatuanWaktu}', - 'status': sewaAset['status'] ?? 'DIBATALKAN', - 'totalPrice': totalPrice, - 'tanggalSewa': tanggalSewa, - 'jamMulai': jamMulai, - 'jamSelesai': jamSelesai, - 'rentangWaktu': rentangWaktu, - 'namaSatuanWaktu': namaSatuanWaktu, - 'waktuMulai': sewaAset['waktu_mulai'], - 'waktuSelesai': sewaAset['waktu_selesai'], - 'alasanPembatalan': sewaAset['alasan_pembatalan'] ?? '-', - }); + final processedData = await _processRentalData(sewaAset); + processedData['status'] = sewaAset['status'] ?? 'DIBATALKAN'; + processedData['alasanPembatalan'] = + sewaAset['alasan_pembatalan'] ?? '-'; + cancelledRentals.add(processedData); } debugPrint( @@ -570,6 +445,64 @@ class WargaSewaController extends GetxController } } + // Load data for the Dikembalikan tab (status: DIKEMBALIKAN) + Future loadReturnedRentals() async { + try { + isLoadingReturned.value = true; + + // Clear existing data + returnedRentals.clear(); + + // Get sewa_aset data with status "DIKEMBALIKAN" + final sewaAsetList = await authProvider.getSewaAsetByStatus([ + 'DIKEMBALIKAN', + ]); + + debugPrint('Fetched ${sewaAsetList.length} returned sewa_aset records'); + + // Process each sewa_aset record + for (var sewaAset in sewaAsetList) { + final processedData = await _processRentalData(sewaAset); + processedData['status'] = sewaAset['status'] ?? 'DIKEMBALIKAN'; + returnedRentals.add(processedData); + } + + debugPrint('Processed ${returnedRentals.length} returned rental records'); + } catch (e) { + debugPrint('Error loading returned rentals data: $e'); + } finally { + isLoadingReturned.value = false; + } + } + + // Load data for the Aktif tab (status: AKTIF) + Future loadActiveRentals() async { + try { + isLoadingActive.value = true; + + // Clear existing data + activeRentals.clear(); + + // Get sewa_aset data with status "AKTIF" + final sewaAsetList = await authProvider.getSewaAsetByStatus(['AKTIF']); + + debugPrint('Fetched ${sewaAsetList.length} active sewa_aset records'); + + // Process each sewa_aset record + for (var sewaAset in sewaAsetList) { + final processedData = await _processRentalData(sewaAset); + processedData['status'] = sewaAset['status'] ?? 'AKTIF'; + activeRentals.add(processedData); + } + + debugPrint('Processed ${activeRentals.length} active rental records'); + } catch (e) { + debugPrint('Error loading active rentals data: $e'); + } finally { + isLoadingActive.value = false; + } + } + // Load data for the Pending tab (status: PERIKSA PEMBAYARAN) Future loadPendingRentals() async { try { @@ -588,91 +521,9 @@ class WargaSewaController extends GetxController // Process each sewa_aset record for (var sewaAset in sewaAsetList) { - // Get asset details if aset_id is available - String assetName = 'Aset'; - String? imageUrl; - String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam'; - - if (sewaAset['aset_id'] != null) { - final asetData = await asetProvider.getAsetById(sewaAset['aset_id']); - if (asetData != null) { - assetName = asetData.nama; - imageUrl = asetData.imageUrl; - } - } - - // Parse waktu mulai and waktu selesai - DateTime? waktuMulai; - DateTime? waktuSelesai; - String waktuSewa = ''; - String tanggalSewa = ''; - String jamMulai = ''; - String jamSelesai = ''; - String rentangWaktu = ''; - - if (sewaAset['waktu_mulai'] != null && - sewaAset['waktu_selesai'] != null) { - waktuMulai = DateTime.parse(sewaAset['waktu_mulai']); - waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']); - - // Format for display - final formatTanggal = DateFormat('dd-MM-yyyy'); - final formatWaktu = DateFormat('HH:mm'); - final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID'); - - tanggalSewa = formatTanggalLengkap.format(waktuMulai); - jamMulai = formatWaktu.format(waktuMulai); - jamSelesai = formatWaktu.format(waktuSelesai); - - // Format based on satuan waktu - if (namaSatuanWaktu.toLowerCase() == 'jam') { - // For hours, show time range on same day - rentangWaktu = '$jamMulai - $jamSelesai'; - } else if (namaSatuanWaktu.toLowerCase() == 'hari') { - // For days, show date range - final tanggalMulai = formatTanggalLengkap.format(waktuMulai); - final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai); - rentangWaktu = '$tanggalMulai - $tanggalSelesai'; - } else { - // Default format - rentangWaktu = '$jamMulai - $jamSelesai'; - } - - // Full time format for waktuSewa - waktuSewa = - '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - ' - '${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}'; - } - - // Format price - String totalPrice = 'Rp 0'; - if (sewaAset['total'] != null) { - final formatter = NumberFormat.currency( - locale: 'id', - symbol: 'Rp ', - decimalDigits: 0, - ); - totalPrice = formatter.format(sewaAset['total']); - } - - // Add to pending rentals list - pendingRentals.add({ - 'id': sewaAset['id'] ?? '', - 'name': assetName, - 'imageUrl': imageUrl ?? 'assets/images/gambar_pendukung.jpg', - 'jumlahUnit': sewaAset['kuantitas'] ?? 0, - 'waktuSewa': waktuSewa, - 'duration': '${sewaAset['durasi'] ?? 0} ${namaSatuanWaktu}', - 'status': sewaAset['status'] ?? 'PERIKSA PEMBAYARAN', - 'totalPrice': totalPrice, - 'tanggalSewa': tanggalSewa, - 'jamMulai': jamMulai, - 'jamSelesai': jamSelesai, - 'rentangWaktu': rentangWaktu, - 'namaSatuanWaktu': namaSatuanWaktu, - 'waktuMulai': sewaAset['waktu_mulai'], - 'waktuSelesai': sewaAset['waktu_selesai'], - }); + final processedData = await _processRentalData(sewaAset); + processedData['status'] = sewaAset['status'] ?? 'PERIKSA PEMBAYARAN'; + pendingRentals.add(processedData); } debugPrint('Processed ${pendingRentals.length} pending rental records'); @@ -698,91 +549,9 @@ class WargaSewaController extends GetxController // Process each sewa_aset record for (var sewaAset in sewaAsetList) { - // Get asset details if aset_id is available - String assetName = 'Aset'; - String? imageUrl; - String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam'; - - if (sewaAset['aset_id'] != null) { - final asetData = await asetProvider.getAsetById(sewaAset['aset_id']); - if (asetData != null) { - assetName = asetData.nama; - imageUrl = asetData.imageUrl; - } - } - - // Parse waktu mulai and waktu selesai - DateTime? waktuMulai; - DateTime? waktuSelesai; - String waktuSewa = ''; - String tanggalSewa = ''; - String jamMulai = ''; - String jamSelesai = ''; - String rentangWaktu = ''; - - if (sewaAset['waktu_mulai'] != null && - sewaAset['waktu_selesai'] != null) { - waktuMulai = DateTime.parse(sewaAset['waktu_mulai']); - waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']); - - // Format for display - final formatTanggal = DateFormat('dd-MM-yyyy'); - final formatWaktu = DateFormat('HH:mm'); - final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID'); - - tanggalSewa = formatTanggalLengkap.format(waktuMulai); - jamMulai = formatWaktu.format(waktuMulai); - jamSelesai = formatWaktu.format(waktuSelesai); - - // Format based on satuan waktu - if (namaSatuanWaktu.toLowerCase() == 'jam') { - // For hours, show time range on same day - rentangWaktu = '$jamMulai - $jamSelesai'; - } else if (namaSatuanWaktu.toLowerCase() == 'hari') { - // For days, show date range - final tanggalMulai = formatTanggalLengkap.format(waktuMulai); - final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai); - rentangWaktu = '$tanggalMulai - $tanggalSelesai'; - } else { - // Default format - rentangWaktu = '$jamMulai - $jamSelesai'; - } - - // Full time format for waktuSewa - waktuSewa = - '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - ' - '${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}'; - } - - // Format price - String totalPrice = 'Rp 0'; - if (sewaAset['total'] != null) { - final formatter = NumberFormat.currency( - locale: 'id', - symbol: 'Rp ', - decimalDigits: 0, - ); - totalPrice = formatter.format(sewaAset['total']); - } - - // Add to accepted rentals list - acceptedRentals.add({ - 'id': sewaAset['id'] ?? '', - 'name': assetName, - 'imageUrl': imageUrl ?? 'assets/images/gambar_pendukung.jpg', - 'jumlahUnit': sewaAset['kuantitas'] ?? 0, - 'waktuSewa': waktuSewa, - 'duration': '${sewaAset['durasi'] ?? 0} ${namaSatuanWaktu}', - 'status': sewaAset['status'] ?? 'DITERIMA', - 'totalPrice': totalPrice, - 'tanggalSewa': tanggalSewa, - 'jamMulai': jamMulai, - 'jamSelesai': jamSelesai, - 'rentangWaktu': rentangWaktu, - 'namaSatuanWaktu': namaSatuanWaktu, - 'waktuMulai': sewaAset['waktu_mulai'], - 'waktuSelesai': sewaAset['waktu_selesai'], - }); + final processedData = await _processRentalData(sewaAset); + processedData['status'] = sewaAset['status'] ?? 'DITERIMA'; + acceptedRentals.add(processedData); } debugPrint('Processed ${acceptedRentals.length} accepted rental records'); @@ -792,166 +561,4 @@ class WargaSewaController extends GetxController isLoadingAccepted.value = false; } } - - Future loadReturnedRentals() async { - try { - isLoadingReturned.value = true; - returnedRentals.clear(); - final sewaAsetList = await authProvider.getSewaAsetByStatus([ - 'DIKEMBALIKAN', - ]); - for (var sewaAset in sewaAsetList) { - String assetName = 'Aset'; - String? imageUrl; - String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam'; - if (sewaAset['aset_id'] != null) { - final asetData = await asetProvider.getAsetById(sewaAset['aset_id']); - if (asetData != null) { - assetName = asetData.nama; - imageUrl = asetData.imageUrl; - } - } - DateTime? waktuMulai; - DateTime? waktuSelesai; - String waktuSewa = ''; - String tanggalSewa = ''; - String jamMulai = ''; - String jamSelesai = ''; - String rentangWaktu = ''; - if (sewaAset['waktu_mulai'] != null && - sewaAset['waktu_selesai'] != null) { - waktuMulai = DateTime.parse(sewaAset['waktu_mulai']); - waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']); - final formatTanggal = DateFormat('dd-MM-yyyy'); - final formatWaktu = DateFormat('HH:mm'); - final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID'); - tanggalSewa = formatTanggalLengkap.format(waktuMulai); - jamMulai = formatWaktu.format(waktuMulai); - jamSelesai = formatWaktu.format(waktuSelesai); - if (namaSatuanWaktu.toLowerCase() == 'jam') { - rentangWaktu = '$jamMulai - $jamSelesai'; - } else if (namaSatuanWaktu.toLowerCase() == 'hari') { - final tanggalMulai = formatTanggalLengkap.format(waktuMulai); - final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai); - rentangWaktu = '$tanggalMulai - $tanggalSelesai'; - } else { - rentangWaktu = '$jamMulai - $jamSelesai'; - } - waktuSewa = - '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - ' - '${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}'; - } - String totalPrice = 'Rp 0'; - if (sewaAset['total'] != null) { - final formatter = NumberFormat.currency( - locale: 'id', - symbol: 'Rp ', - decimalDigits: 0, - ); - totalPrice = formatter.format(sewaAset['total']); - } - returnedRentals.add({ - 'id': sewaAset['id'] ?? '', - 'name': assetName, - 'imageUrl': imageUrl ?? 'assets/images/gambar_pendukung.jpg', - 'jumlahUnit': sewaAset['kuantitas'] ?? 0, - 'waktuSewa': waktuSewa, - 'duration': '${sewaAset['durasi'] ?? 0} ${namaSatuanWaktu}', - 'status': sewaAset['status'] ?? 'DIKEMBALIKAN', - 'totalPrice': totalPrice, - 'tanggalSewa': tanggalSewa, - 'jamMulai': jamMulai, - 'jamSelesai': jamSelesai, - 'rentangWaktu': rentangWaktu, - 'namaSatuanWaktu': namaSatuanWaktu, - 'waktuMulai': sewaAset['waktu_mulai'], - 'waktuSelesai': sewaAset['waktu_selesai'], - }); - } - } catch (e) { - debugPrint('Error loading returned rentals data: $e'); - } finally { - isLoadingReturned.value = false; - } - } - - Future loadActiveRentals() async { - try { - isLoadingActive.value = true; - activeRentals.clear(); - final sewaAsetList = await authProvider.getSewaAsetByStatus(['AKTIF']); - for (var sewaAset in sewaAsetList) { - String assetName = 'Aset'; - String? imageUrl; - String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam'; - if (sewaAset['aset_id'] != null) { - final asetData = await asetProvider.getAsetById(sewaAset['aset_id']); - if (asetData != null) { - assetName = asetData.nama; - imageUrl = asetData.imageUrl; - } - } - DateTime? waktuMulai; - DateTime? waktuSelesai; - String waktuSewa = ''; - String tanggalSewa = ''; - String jamMulai = ''; - String jamSelesai = ''; - String rentangWaktu = ''; - if (sewaAset['waktu_mulai'] != null && - sewaAset['waktu_selesai'] != null) { - waktuMulai = DateTime.parse(sewaAset['waktu_mulai']); - waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']); - final formatTanggal = DateFormat('dd-MM-yyyy'); - final formatWaktu = DateFormat('HH:mm'); - final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID'); - tanggalSewa = formatTanggalLengkap.format(waktuMulai); - jamMulai = formatWaktu.format(waktuMulai); - jamSelesai = formatWaktu.format(waktuSelesai); - if (namaSatuanWaktu.toLowerCase() == 'jam') { - rentangWaktu = '$jamMulai - $jamSelesai'; - } else if (namaSatuanWaktu.toLowerCase() == 'hari') { - final tanggalMulai = formatTanggalLengkap.format(waktuMulai); - final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai); - rentangWaktu = '$tanggalMulai - $tanggalSelesai'; - } else { - rentangWaktu = '$jamMulai - $jamSelesai'; - } - waktuSewa = - '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - ' - '${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}'; - } - String totalPrice = 'Rp 0'; - if (sewaAset['total'] != null) { - final formatter = NumberFormat.currency( - locale: 'id', - symbol: 'Rp ', - decimalDigits: 0, - ); - totalPrice = formatter.format(sewaAset['total']); - } - activeRentals.add({ - 'id': sewaAset['id'] ?? '', - 'name': assetName, - 'imageUrl': imageUrl ?? 'assets/images/gambar_pendukung.jpg', - 'jumlahUnit': sewaAset['kuantitas'] ?? 0, - 'waktuSewa': waktuSewa, - 'duration': '${sewaAset['durasi'] ?? 0} ${namaSatuanWaktu}', - 'status': sewaAset['status'] ?? 'AKTIF', - 'totalPrice': totalPrice, - 'tanggalSewa': tanggalSewa, - 'jamMulai': jamMulai, - 'jamSelesai': jamSelesai, - 'rentangWaktu': rentangWaktu, - 'namaSatuanWaktu': namaSatuanWaktu, - 'waktuMulai': sewaAset['waktu_mulai'], - 'waktuSelesai': sewaAset['waktu_selesai'], - }); - } - } catch (e) { - debugPrint('Error loading active rentals data: $e'); - } finally { - isLoadingActive.value = false; - } - } } diff --git a/lib/app/modules/warga/views/order_sewa_aset_view.dart b/lib/app/modules/warga/views/order_sewa_aset_view.dart index 94a138d..af76f9c 100644 --- a/lib/app/modules/warga/views/order_sewa_aset_view.dart +++ b/lib/app/modules/warga/views/order_sewa_aset_view.dart @@ -698,124 +698,149 @@ class OrderSewaAsetView extends GetView { ), // Navigation arrows - only show if we have more than 1 photo - Obx( - () => - controller.assetPhotos.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: controller.previousPhoto, + Obx(() { + // Only show arrows if we have more than 1 photo AND they belong to the current package + final bool shouldShowArrows = + controller.assetPhotos.length > 1 && + (!controller.isPaket.value || + (controller.isPaket.value && + controller.assetPhotos.isNotEmpty && + controller.assetPhotos.first.idAset == + controller.paketId.value)); + + return shouldShowArrows + ? 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, ), ), - 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: controller.nextPhoto, - ), - ), - ], + onPressed: controller.previousPhoto, + ), ), - ) - : SizedBox.shrink(), - ), + 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: controller.nextPhoto, + ), + ), + ], + ), + ) + : SizedBox.shrink(); + }), // Image indicators - only show if we have more than 1 photo - Obx( - () => - controller.assetPhotos.length > 1 - ? Positioned( - bottom: 20, - left: 0, - right: 0, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: List.generate( - controller.assetPhotos.length, - (index) => AnimatedContainer( - duration: Duration(milliseconds: 200), - width: - index == controller.currentPhotoIndex.value - ? 24 - : 10, - height: 10, - margin: EdgeInsets.symmetric(horizontal: 3), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(5), - color: - index == controller.currentPhotoIndex.value - ? AppColors.primary - : AppColors.primaryLight.withOpacity(0.5), - ), - ), + Obx(() { + // Only show indicators if we have more than 1 photo AND they belong to the current package + final bool shouldShowIndicators = + controller.assetPhotos.length > 1 && + (!controller.isPaket.value || + (controller.isPaket.value && + controller.assetPhotos.isNotEmpty && + controller.assetPhotos.first.idAset == + controller.paketId.value)); + + return shouldShowIndicators + ? Positioned( + bottom: 20, + left: 0, + right: 0, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + controller.assetPhotos.length, + (index) => AnimatedContainer( + duration: Duration(milliseconds: 200), + width: + index == controller.currentPhotoIndex.value ? 24 : 10, + height: 10, + margin: EdgeInsets.symmetric(horizontal: 3), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(5), + color: + index == controller.currentPhotoIndex.value + ? AppColors.primary + : AppColors.primaryLight.withOpacity(0.5), ), ), - ) - : SizedBox.shrink(), - ), + ), + ), + ) + : SizedBox.shrink(); + }), // Photo counter - Obx( - () => - controller.assetPhotos.length > 1 - ? Positioned( - top: 20, - right: 20, - 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.assetPhotos.length}', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), + Obx(() { + // Only show counter if we have more than 1 photo AND they belong to the current package + final bool shouldShowCounter = + controller.assetPhotos.length > 1 && + (!controller.isPaket.value || + (controller.isPaket.value && + controller.assetPhotos.isNotEmpty && + controller.assetPhotos.first.idAset == + controller.paketId.value)); + + return shouldShowCounter + ? Positioned( + top: 20, + right: 20, + 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.assetPhotos.length}', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, ), - ) - : SizedBox.shrink(), - ), + ), + ), + ) + : SizedBox.shrink(); + }), ], ); } Widget _buildAssetDetails() { final aset = controller.aset.value!; + + // Set jumlahUnit to 1 when isPaket is true + if (controller.isPaket.value) { + controller.jumlahUnit.value = 1; + } + return Container( padding: EdgeInsets.all(24.0), child: Column( @@ -925,218 +950,491 @@ class OrderSewaAsetView extends GetView { SizedBox(height: 24), - // Jumlah Unit with card styling - Removed icons, added manual input - Container( - padding: EdgeInsets.all(20), - 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.inventory_2_outlined, - color: AppColors.primary, - size: 18, - ), - SizedBox(width: 8), - Text( - 'Jumlah Unit', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: AppColors.primary, - ), - ), - ], - ), - SizedBox(height: 16), - - Row( - children: [ - // Decrease button - Obx( - () => Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: - controller.jumlahUnit.value <= 1 - ? null - : () { - HapticFeedback.lightImpact(); - controller.decreaseUnit(); - }, - child: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: - controller.jumlahUnit.value <= 1 - ? Colors.grey[200] - : Color(0xFF92B4D7).withOpacity(0.3), - borderRadius: BorderRadius.circular(12), - ), - child: Center( - child: Icon( - Icons.remove_rounded, - size: 20, - color: - controller.jumlahUnit.value <= 1 - ? Colors.grey[400] - : Color(0xFF3A6EA5), - ), - ), - ), - ), - ), - ), - - // Text field for manual input - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Obx(() { - final textController = TextEditingController( - text: controller.jumlahUnit.value.toString(), - ); - // Posisi kursor di akhir teks - textController.selection = TextSelection.fromPosition( - TextPosition(offset: textController.text.length), - ); - - return TextField( - textAlign: TextAlign.center, - keyboardType: TextInputType.number, - controller: textController, - onTap: () { - // Pilih semua teks saat di-tap - textController.selection = TextSelection( - baseOffset: 0, - extentOffset: textController.text.length, - ); - }, - decoration: InputDecoration( - isDense: true, - contentPadding: EdgeInsets.symmetric( - vertical: 12, - horizontal: 12, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: Colors.grey[300]!, - ), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: Colors.grey[300]!, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: Color(0xFF3A6EA5), - ), - ), - ), - style: TextStyle( - color: Color(0xFF3A6EA5), - fontWeight: FontWeight.bold, - fontSize: 16, - ), - onSubmitted: (value) { - controller.updateUnitFromInput(value); - }, - ); - }), - ), - ), - - // Increase button - Obx( - () => Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: - controller.jumlahUnit.value >= - controller.maxUnit.value - ? null - : () { - HapticFeedback.lightImpact(); - controller.increaseUnit(); - }, - child: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: - controller.jumlahUnit.value >= - controller.maxUnit.value - ? Colors.grey[200] - : Color(0xFF92B4D7).withOpacity(0.3), - borderRadius: BorderRadius.circular(12), - ), - child: Center( - child: Icon( - Icons.add_rounded, - size: 20, - color: - controller.jumlahUnit.value >= - controller.maxUnit.value - ? Colors.grey[400] - : Color(0xFF3A6EA5), - ), - ), - ), - ), - ), - ), - ], - ), - - SizedBox(height: 8), - - // Maximum unit info - Center( - child: Obx( - () => Row( - mainAxisAlignment: MainAxisAlignment.center, + // Package Details Card - Only shown when isPaket is true + Obx( + () => + controller.isPaket.value + ? Column( children: [ - Text( - 'Maksimal ${controller.maxUnit.value} unit', - style: TextStyle( - fontSize: 13, - color: AppColors.textSecondary, + Container( + padding: EdgeInsets.all(20), + width: double.infinity, + 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.inventory_2_outlined, + color: AppColors.primary, + size: 18, + ), + SizedBox(width: 8), + Text( + 'Detail Paket', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ], + ), + SizedBox(height: 16), + + // Package Items List + Obx(() { + if (controller.isPaketItemsLoaded.value == + false) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: CircularProgressIndicator( + color: AppColors.primary, + ), + ), + ); + } + + if (controller.paketItems.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + 'Tidak ada item dalam paket ini', + style: TextStyle( + color: AppColors.textSecondary, + fontSize: 14, + ), + ), + ), + ); + } + + return Column( + children: + controller.paketItems.map((item) { + final asetData = + item['aset'] + as Map?; + final String asetId = + asetData?['id'] ?? ''; + final String asetName = + asetData?['nama'] ?? + 'Aset tidak diketahui'; + final int quantity = + item['kuantitas'] is int + ? item['kuantitas'] + : 1; + + return Container( + margin: EdgeInsets.only(bottom: 12), + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular( + 12, + ), + border: Border.all( + color: AppColors.borderLight, + ), + ), + child: Row( + children: [ + // Asset Image + FutureBuilder( + future: _getAsetImageUrl( + asetId, + ), + builder: (context, snapshot) { + return Container( + width: 60, + height: 60, + decoration: BoxDecoration( + borderRadius: + BorderRadius.circular( + 8, + ), + color: + AppColors + .surfaceLight, + ), + child: + snapshot.connectionState == + ConnectionState + .waiting + ? Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: + 2, + color: + AppColors + .primary, + ), + ), + ) + : snapshot.data != + null + ? ClipRRect( + borderRadius: + BorderRadius.circular( + 8, + ), + child: CachedNetworkImage( + imageUrl: + snapshot + .data!, + fit: + BoxFit + .cover, + placeholder: + ( + context, + url, + ) => Center( + child: SizedBox( + width: + 20, + height: + 20, + child: CircularProgressIndicator( + strokeWidth: + 2, + color: + AppColors.primary, + ), + ), + ), + errorWidget: + ( + context, + url, + error, + ) => Icon( + Icons + .image_not_supported_outlined, + color: + AppColors + .textLight, + ), + ), + ) + : Icon( + Icons + .image_not_supported_outlined, + color: + AppColors + .textLight, + ), + ); + }, + ), + SizedBox(width: 12), + + // Asset Name and Quantity + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + asetName, + style: TextStyle( + fontSize: 14, + fontWeight: + FontWeight.w600, + color: + AppColors + .textPrimary, + ), + maxLines: 2, + overflow: + TextOverflow.ellipsis, + ), + SizedBox(height: 4), + Text( + '$quantity unit', + style: TextStyle( + fontSize: 12, + color: + AppColors + .textSecondary, + ), + ), + ], + ), + ), + ], + ), + ); + }).toList(), + ); + }), + ], ), ), - SizedBox(width: 4), - Tooltip( - message: 'Jumlah unit yang tersedia untuk disewa', - child: Icon( - Icons.info_outline, - size: 14, - color: AppColors.textLight, + SizedBox(height: 24), + ], + ) + : SizedBox.shrink(), + ), + + // Jumlah Unit card - Only shown when isPaket is false + Obx( + () => + controller.isPaket.value + ? SizedBox.shrink() // Hide the card when isPaket is true + : Column( + children: [ + Container( + padding: EdgeInsets.all(20), + 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.inventory_2_outlined, + color: AppColors.primary, + size: 18, + ), + SizedBox(width: 8), + Text( + 'Jumlah Unit', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ], + ), + SizedBox(height: 16), + + Row( + children: [ + // Decrease button + Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: + controller.jumlahUnit.value <= 1 + ? null + : () { + HapticFeedback.lightImpact(); + controller.decreaseUnit(); + }, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: + controller.jumlahUnit.value <= 1 + ? Colors.grey[200] + : Color( + 0xFF92B4D7, + ).withOpacity(0.3), + borderRadius: BorderRadius.circular( + 12, + ), + ), + child: Center( + child: Icon( + Icons.remove_rounded, + size: 20, + color: + controller.jumlahUnit.value <= 1 + ? Colors.grey[400] + : Color(0xFF3A6EA5), + ), + ), + ), + ), + ), + + // Text field for manual input + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + ), + child: Obx(() { + final textController = + TextEditingController( + text: + controller.jumlahUnit.value + .toString(), + ); + // Posisi kursor di akhir teks + textController.selection = + TextSelection.fromPosition( + TextPosition( + offset: + textController.text.length, + ), + ); + + return TextField( + textAlign: TextAlign.center, + keyboardType: TextInputType.number, + controller: textController, + onTap: () { + // Pilih semua teks saat di-tap + textController + .selection = TextSelection( + baseOffset: 0, + extentOffset: + textController.text.length, + ); + }, + decoration: InputDecoration( + isDense: true, + contentPadding: + EdgeInsets.symmetric( + vertical: 12, + horizontal: 12, + ), + border: OutlineInputBorder( + borderRadius: + BorderRadius.circular(12), + borderSide: BorderSide( + color: Colors.grey[300]!, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: + BorderRadius.circular(12), + borderSide: BorderSide( + color: Colors.grey[300]!, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: + BorderRadius.circular(12), + borderSide: BorderSide( + color: Color(0xFF3A6EA5), + ), + ), + ), + style: TextStyle( + color: Color(0xFF3A6EA5), + fontWeight: FontWeight.bold, + fontSize: 16, + ), + onSubmitted: (value) { + controller.updateUnitFromInput( + value, + ); + }, + ); + }), + ), + ), + + // Increase button + Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: + controller.jumlahUnit.value >= + controller.maxUnit.value + ? null + : () { + HapticFeedback.lightImpact(); + controller.increaseUnit(); + }, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: + controller.jumlahUnit.value >= + controller.maxUnit.value + ? Colors.grey[200] + : Color( + 0xFF92B4D7, + ).withOpacity(0.3), + borderRadius: BorderRadius.circular( + 12, + ), + ), + child: Center( + child: Icon( + Icons.add_rounded, + size: 20, + color: + controller.jumlahUnit.value >= + controller.maxUnit.value + ? Colors.grey[400] + : Color(0xFF3A6EA5), + ), + ), + ), + ), + ), + ], + ), + + SizedBox(height: 8), + + // Maximum unit info + Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Maksimal ${controller.maxUnit.value} unit', + style: TextStyle( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + SizedBox(width: 4), + Tooltip( + message: + 'Jumlah unit yang tersedia untuk disewa', + child: Icon( + Icons.info_outline, + size: 14, + color: AppColors.textLight, + ), + ), + ], + ), + ), + ], ), ), + SizedBox(height: 24), ], ), - ), - ), - ], - ), ), ], ), @@ -1162,9 +1460,10 @@ class OrderSewaAsetView extends GetView { ) ?? false, ); - + // Count available options to handle the case when only one option is available - final availableOptionsCount = (hourlyOption != null ? 1 : 0) + (dailyOption != null ? 1 : 0); + final availableOptionsCount = + (hourlyOption != null ? 1 : 0) + (dailyOption != null ? 1 : 0); return Container( padding: EdgeInsets.symmetric(horizontal: 24.0, vertical: 8.0), @@ -1195,8 +1494,8 @@ class OrderSewaAsetView extends GetView { ], ), // Show number of available options - availableOptionsCount == 1 - ? Container( + availableOptionsCount == 1 + ? Container( padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: AppColors.primarySoft, @@ -1211,7 +1510,7 @@ class OrderSewaAsetView extends GetView { ), ), ) - : SizedBox.shrink(), + : SizedBox.shrink(), ], ), ), @@ -1235,7 +1534,8 @@ class OrderSewaAsetView extends GetView { InkWell( onTap: () { // Only perform action if this is not already selected - if (controller.selectedSatuanWaktu.value?['id'] != hourlyOption['id']) { + if (controller.selectedSatuanWaktu.value?['id'] != + hourlyOption['id']) { HapticFeedback.lightImpact(); controller.selectSatuanWaktu(hourlyOption); } @@ -1243,21 +1543,32 @@ class OrderSewaAsetView extends GetView { borderRadius: BorderRadius.vertical( top: Radius.circular(16), // If this is the only option, also round bottom corners - bottom: dailyOption == null ? Radius.circular(16) : Radius.zero, + bottom: + dailyOption == null + ? Radius.circular(16) + : Radius.zero, ), child: Obx(() { - bool isSelected = controller.selectedSatuanWaktu.value?['id'] == hourlyOption['id']; + bool isSelected = + controller.selectedSatuanWaktu.value?['id'] == + hourlyOption['id']; return AnimatedContainer( duration: Duration(milliseconds: 300), curve: Curves.easeInOut, padding: EdgeInsets.all(16), decoration: BoxDecoration( - color: isSelected ? AppColors.primarySoft : AppColors.surface, + color: + isSelected + ? AppColors.primarySoft + : AppColors.surface, borderRadius: BorderRadius.vertical( top: Radius.circular(16), // If this is the only option, also round bottom corners - bottom: dailyOption == null ? Radius.circular(16) : Radius.zero, + bottom: + dailyOption == null + ? Radius.circular(16) + : Radius.zero, ), ), child: Row( @@ -1267,17 +1578,26 @@ class OrderSewaAsetView extends GetView { width: 48, height: 48, decoration: BoxDecoration( - color: isSelected ? AppColors.primary.withOpacity(0.2) : AppColors.surfaceLight, + color: + isSelected + ? AppColors.primary.withOpacity(0.2) + : AppColors.surfaceLight, borderRadius: BorderRadius.circular(12), border: Border.all( - color: isSelected ? AppColors.primary.withOpacity(0.3) : AppColors.borderLight, + color: + isSelected + ? AppColors.primary.withOpacity(0.3) + : AppColors.borderLight, width: isSelected ? 1.5 : 1, ), ), child: Center( child: Icon( Icons.access_time_rounded, - color: isSelected ? AppColors.primary : AppColors.textSecondary, + color: + isSelected + ? AppColors.primary + : AppColors.textSecondary, size: 24, ), ), @@ -1292,16 +1612,27 @@ class OrderSewaAsetView extends GetView { style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, - color: isSelected ? AppColors.primary : AppColors.textPrimary, + color: + isSelected + ? AppColors.primary + : AppColors.textPrimary, ), ), SizedBox(height: 4), Text( - controller.formatPrice(hourlyOption['harga'] ?? 0), + controller.formatPrice( + hourlyOption['harga'] ?? 0, + ), style: TextStyle( fontSize: 14, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - color: isSelected ? AppColors.primary : AppColors.textSecondary, + fontWeight: + isSelected + ? FontWeight.w600 + : FontWeight.normal, + color: + isSelected + ? AppColors.primary + : AppColors.textSecondary, ), ), ], @@ -1339,28 +1670,40 @@ class OrderSewaAsetView extends GetView { InkWell( onTap: () { // Only perform action if this is not already selected - if (controller.selectedSatuanWaktu.value?['id'] != dailyOption['id']) { + if (controller.selectedSatuanWaktu.value?['id'] != + dailyOption['id']) { HapticFeedback.lightImpact(); controller.selectSatuanWaktu(dailyOption); } }, borderRadius: BorderRadius.vertical( // If this is the only option, also round top corners - top: hourlyOption == null ? Radius.circular(16) : Radius.zero, + top: + hourlyOption == null + ? Radius.circular(16) + : Radius.zero, bottom: Radius.circular(16), ), child: Obx(() { - bool isSelected = controller.selectedSatuanWaktu.value?['id'] == dailyOption['id']; + bool isSelected = + controller.selectedSatuanWaktu.value?['id'] == + dailyOption['id']; return AnimatedContainer( duration: Duration(milliseconds: 300), curve: Curves.easeInOut, padding: EdgeInsets.all(16), decoration: BoxDecoration( - color: isSelected ? AppColors.primarySoft : AppColors.surface, + color: + isSelected + ? AppColors.primarySoft + : AppColors.surface, borderRadius: BorderRadius.vertical( // If this is the only option, also round top corners - top: hourlyOption == null ? Radius.circular(16) : Radius.zero, + top: + hourlyOption == null + ? Radius.circular(16) + : Radius.zero, bottom: Radius.circular(16), ), ), @@ -1371,17 +1714,26 @@ class OrderSewaAsetView extends GetView { width: 48, height: 48, decoration: BoxDecoration( - color: isSelected ? AppColors.primary.withOpacity(0.2) : AppColors.surfaceLight, + color: + isSelected + ? AppColors.primary.withOpacity(0.2) + : AppColors.surfaceLight, borderRadius: BorderRadius.circular(12), border: Border.all( - color: isSelected ? AppColors.primary.withOpacity(0.3) : AppColors.borderLight, + color: + isSelected + ? AppColors.primary.withOpacity(0.3) + : AppColors.borderLight, width: isSelected ? 1.5 : 1, ), ), child: Center( child: Icon( Icons.calendar_today_rounded, - color: isSelected ? AppColors.primary : AppColors.textSecondary, + color: + isSelected + ? AppColors.primary + : AppColors.textSecondary, size: 20, ), ), @@ -1396,16 +1748,27 @@ class OrderSewaAsetView extends GetView { style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, - color: isSelected ? AppColors.primary : AppColors.textPrimary, + color: + isSelected + ? AppColors.primary + : AppColors.textPrimary, ), ), SizedBox(height: 4), Text( - controller.formatPrice(dailyOption['harga'] ?? 0), + controller.formatPrice( + dailyOption['harga'] ?? 0, + ), style: TextStyle( fontSize: 14, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - color: isSelected ? AppColors.primary : AppColors.textSecondary, + fontWeight: + isSelected + ? FontWeight.w600 + : FontWeight.normal, + color: + isSelected + ? AppColors.primary + : AppColors.textSecondary, ), ), ], @@ -1433,7 +1796,7 @@ class OrderSewaAsetView extends GetView { ); }), ), - + // Show message when no options are available (should never happen, but just in case) if (availableOptionsCount == 0) Container( @@ -2044,6 +2407,21 @@ class OrderSewaAsetView extends GetView { ), ); } + + Future _getAsetImageUrl(String asetId) async { + try { + if (asetId.isEmpty) return null; + + final photos = await controller.asetProvider.getAsetPhotos(asetId); + if (photos.isNotEmpty && photos.first.fotoAset != null) { + return photos.first.fotoAset; + } + return null; + } catch (e) { + debugPrint('❌ Error loading asset image: $e'); + return null; + } + } } // Full Screen Image Viewer Widget diff --git a/lib/app/modules/warga/views/pembayaran_sewa_view.dart b/lib/app/modules/warga/views/pembayaran_sewa_view.dart index 3267b9e..9fb0726 100644 --- a/lib/app/modules/warga/views/pembayaran_sewa_view.dart +++ b/lib/app/modules/warga/views/pembayaran_sewa_view.dart @@ -5,122 +5,129 @@ import '../controllers/pembayaran_sewa_controller.dart'; import 'package:intl/intl.dart'; import '../../../theme/app_colors.dart'; import 'dart:async'; +import '../../../routes/app_routes.dart'; class PembayaranSewaView extends GetView { const PembayaranSewaView({super.key}); @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.background, - appBar: AppBar( - title: const Text( - 'Detail Pesanan', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + return WillPopScope( + onWillPop: () async { + controller.onBackPressed(); + return true; + }, + child: Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + title: const Text( + 'Detail Pesanan', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + centerTitle: true, + backgroundColor: AppColors.primary, + foregroundColor: AppColors.textOnPrimary, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => controller.onBackPressed(), + ), ), - centerTitle: true, - backgroundColor: AppColors.primary, - foregroundColor: AppColors.textOnPrimary, - elevation: 0, - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => Get.back(), - ), - ), - body: Column( - children: [ - Container( - decoration: BoxDecoration( - color: AppColors.primary, - boxShadow: [ - BoxShadow( - color: AppColors.shadow, - blurRadius: 4, - offset: const Offset(0, 2), + body: Column( + children: [ + Container( + decoration: BoxDecoration( + color: AppColors.primary, + boxShadow: [ + BoxShadow( + color: AppColors.shadow, + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Container( + margin: const EdgeInsets.only(bottom: 4), + decoration: const BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), ), - ], - ), - child: Container( - margin: const EdgeInsets.only(bottom: 4), - decoration: const BoxDecoration( - color: AppColors.surface, - borderRadius: BorderRadius.only( - topLeft: Radius.circular(20), - topRight: Radius.circular(20), + child: TabBar( + controller: controller.tabController, + labelColor: AppColors.primary, + unselectedLabelColor: AppColors.textSecondary, + indicatorColor: AppColors.primary, + indicatorWeight: 3, + indicatorSize: TabBarIndicatorSize.label, + labelStyle: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + unselectedLabelStyle: const TextStyle( + fontWeight: FontWeight.normal, + fontSize: 14, + ), + tabs: const [ + Tab(text: 'Ringkasan'), + Tab(text: 'Detail Tagihan'), + Tab(text: 'Pembayaran'), + ], ), ), - child: TabBar( + ), + Expanded( + child: TabBarView( controller: controller.tabController, - labelColor: AppColors.primary, - unselectedLabelColor: AppColors.textSecondary, - indicatorColor: AppColors.primary, - indicatorWeight: 3, - indicatorSize: TabBarIndicatorSize.label, - labelStyle: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - ), - unselectedLabelStyle: const TextStyle( - fontWeight: FontWeight.normal, - fontSize: 14, - ), - tabs: const [ - Tab(text: 'Ringkasan'), - Tab(text: 'Detail Tagihan'), - Tab(text: 'Pembayaran'), + children: [ + _buildSummaryTab(), + _buildBillingTab(), + _buildPaymentTab(), ], ), ), - ), - Expanded( - child: TabBarView( - controller: controller.tabController, - children: [ - _buildSummaryTab(), - _buildBillingTab(), - _buildPaymentTab(), - ], - ), - ), - if ((controller.orderDetails.value['status'] ?? '') - .toString() - .toUpperCase() == - 'MENUNGGU PEMBAYARAN' && - controller.orderDetails.value['updated_at'] != null) - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Obx(() { - final status = - (controller.orderDetails.value['status'] ?? '') + if ((controller.orderDetails.value['status'] ?? '') .toString() - .toUpperCase(); - final updatedAtStr = - controller.orderDetails.value['updated_at']; - print('DEBUG status: ' + status); - print( - 'DEBUG updated_at (raw): ' + - (updatedAtStr?.toString() ?? 'NULL'), - ); - if (status == 'MENUNGGU PEMBAYARAN' && updatedAtStr != null) { - try { - final updatedAt = DateTime.parse(updatedAtStr); - print( - 'DEBUG updated_at (parsed): ' + - updatedAt.toIso8601String(), - ); - return CountdownTimerWidget(updatedAt: updatedAt); - } catch (e) { - print('ERROR parsing updated_at: ' + e.toString()); - return Text( - 'Format tanggal salah', - style: TextStyle(color: Colors.red), - ); + .toUpperCase() == + 'MENUNGGU PEMBAYARAN' && + controller.orderDetails.value['updated_at'] != null) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Obx(() { + final status = + (controller.orderDetails.value['status'] ?? '') + .toString() + .toUpperCase(); + final updatedAtStr = + controller.orderDetails.value['updated_at']; + print('DEBUG status: ' + status); + print( + 'DEBUG updated_at (raw): ' + + (updatedAtStr?.toString() ?? 'NULL'), + ); + if (status == 'MENUNGGU PEMBAYARAN' && updatedAtStr != null) { + try { + final updatedAt = DateTime.parse(updatedAtStr); + print( + 'DEBUG updated_at (parsed): ' + + updatedAt.toIso8601String(), + ); + return CountdownTimerWidget(updatedAt: updatedAt); + } catch (e) { + print('ERROR parsing updated_at: ' + e.toString()); + return Text( + 'Format tanggal salah', + style: TextStyle(color: Colors.red), + ); + } } - } - return SizedBox.shrink(); - }), - ), - ], + return SizedBox.shrink(); + }), + ), + ], + ), ), ); } @@ -683,7 +690,11 @@ class PembayaranSewaView extends GetView { // Item name from aset.nama _buildDetailItem( 'Item', - controller.sewaAsetDetails.value['aset_detail'] != null + controller.isPaket.value && + controller.paketDetails.value.isNotEmpty + ? controller.paketDetails.value['nama'] ?? 'Paket' + : controller.sewaAsetDetails.value['aset_detail'] != + null ? controller .sewaAsetDetails .value['aset_detail']['nama'] ?? @@ -692,6 +703,10 @@ class PembayaranSewaView extends GetView { controller.orderDetails.value['item_name'] ?? '-', ), + + // If this is a package, show package items + if (controller.isPaket.value) _buildPackageItemsList(), + // Quantity from sewa_aset.kuantitas _buildDetailItem( 'Jumlah', @@ -2462,6 +2477,94 @@ class PembayaranSewaView extends GetView { return dateTimeStr; } } + + // If this is a package, show package items + Widget _buildPackageItemsList() { + return Obx(() { + if (!controller.isPaketItemsLoaded.value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Center( + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.deepPurple, + ), + ), + ); + } + + if (controller.paketItems.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Text( + 'Tidak ada item dalam paket ini', + style: TextStyle( + fontStyle: FontStyle.italic, + color: Colors.grey[600], + ), + ), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 4.0, bottom: 8.0), + child: Text( + 'Isi Paket:', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: Colors.deepPurple, + ), + ), + ), + Container( + padding: const EdgeInsets.all(8.0), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey[300]!), + ), + child: Column( + children: + controller.paketItems.map((item) { + final asetData = item['aset'] as Map?; + final String asetName = + asetData?['nama'] ?? 'Aset tidak diketahui'; + final int quantity = + item['kuantitas'] is int ? item['kuantitas'] : 1; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + children: [ + Icon( + Icons.circle, + size: 8, + color: Colors.deepPurple, + ), + SizedBox(width: 8), + Expanded( + child: Text( + '$asetName ($quantity unit)', + style: TextStyle(fontSize: 13), + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ], + ), + ); + }); + } } class CountdownTimerWidget extends StatefulWidget { diff --git a/lib/app/modules/warga/views/sewa_aset_view.dart b/lib/app/modules/warga/views/sewa_aset_view.dart index f127dfc..7395605 100644 --- a/lib/app/modules/warga/views/sewa_aset_view.dart +++ b/lib/app/modules/warga/views/sewa_aset_view.dart @@ -68,7 +68,7 @@ class SewaAsetView extends GetView { child: TextField( controller: controller.searchController, decoration: InputDecoration( - hintText: 'Cari aset...', + hintText: 'Cari aset atau paket...', hintStyle: TextStyle(color: Colors.grey[400]), prefixIcon: Icon(Icons.search, color: Colors.grey[600]), border: InputBorder.none, @@ -364,259 +364,271 @@ class SewaAsetView extends GetView { ); } - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: GridView.builder( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - childAspectRatio: 0.50, // Make cards taller to avoid overflow - crossAxisSpacing: 16, - mainAxisSpacing: 16, - ), - itemCount: controller.filteredPakets.length, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemBuilder: (context, index) { - final paket = controller.filteredPakets[index]; - final List satuanWaktuSewa = - paket['satuanWaktuSewa'] ?? []; + return RefreshIndicator( + onRefresh: controller.loadPakets, + color: const Color(0xFF3A6EA5), // Primary blue + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: GridView.builder( + padding: const EdgeInsets.only(top: 16.0, bottom: 16.0), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 0.50, // Make cards taller to avoid overflow + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), + itemCount: controller.filteredPakets.length, + itemBuilder: (context, index) { + final paket = controller.filteredPakets[index]; + final List satuanWaktuSewa = + paket['satuanWaktuSewa'] ?? []; - // Find the lowest price - int lowestPrice = - satuanWaktuSewa.isEmpty - ? 0 - : satuanWaktuSewa - .map((sws) => sws['harga'] ?? 0) - .reduce((a, b) => a < b ? a : b); + // Find the lowest price + int lowestPrice = + satuanWaktuSewa.isEmpty + ? 0 + : satuanWaktuSewa + .map((sws) => sws['harga'] ?? 0) + .reduce((a, b) => a < b ? a : b); - // Get image URL or default - String imageUrl = paket['gambar_url'] ?? ''; + // Get image URL or default + String imageUrl = paket['gambar_url'] ?? ''; - return GestureDetector( - onTap: () { - _showPaketDetailModal(paket); - }, - child: Container( - decoration: BoxDecoration( - color: AppColors.surface, - borderRadius: BorderRadius.circular(12.0), - boxShadow: [ - BoxShadow( - color: AppColors.shadow, - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Image section - ClipRRect( - borderRadius: const BorderRadius.vertical( - top: Radius.circular(12), + return GestureDetector( + onTap: () { + // No action when tapping on the card + }, + child: Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(12.0), + boxShadow: [ + BoxShadow( + color: AppColors.shadow, + blurRadius: 10, + offset: const Offset(0, 2), ), - child: AspectRatio( - aspectRatio: 1.0, - child: CachedNetworkImage( - imageUrl: imageUrl, - fit: BoxFit.cover, - placeholder: - (context, url) => const Center( - child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation( - Colors.purple, + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Image section + ClipRRect( + borderRadius: const BorderRadius.vertical( + top: Radius.circular(12), + ), + child: AspectRatio( + aspectRatio: 1.0, + child: CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + placeholder: + (context, url) => const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + Colors.purple, + ), ), ), - ), - errorWidget: - (context, url, error) => Container( - color: Colors.grey[200], - child: Center( - child: Icon( - Icons.image_not_supported, - size: 32, - color: Colors.grey[400], + errorWidget: + (context, url, error) => Container( + color: Colors.grey[200], + child: Center( + child: Icon( + Icons.image_not_supported, + size: 32, + color: Colors.grey[400], + ), ), ), - ), + ), ), ), - ), - // Content section - Expanded( - child: Padding( - padding: const EdgeInsets.all(10.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Package name - Text( - paket['nama'] ?? 'Paket', - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.bold, + // Content section + Expanded( + child: Padding( + padding: const EdgeInsets.all(10.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Package name + Text( + paket['nama'] ?? 'Paket', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), + const SizedBox(height: 4), - // Status availability - Row( - children: [ + // Status availability + Row( + children: [ + Container( + width: 6, + height: 6, + decoration: const BoxDecoration( + color: AppColors.success, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 4), + Text( + 'Tersedia', + style: TextStyle( + color: AppColors.success, + fontWeight: FontWeight.w500, + fontSize: 11, + ), + ), + ], + ), + const SizedBox(height: 6), + + // Package pricing - show all pricing options with scrolling + if (satuanWaktuSewa.isNotEmpty) + SizedBox( + width: double.infinity, + child: Wrap( + spacing: 4, + runSpacing: 4, + children: [ + ...satuanWaktuSewa.map((sws) { + // Pastikan data yang ditampilkan valid + final harga = sws['harga'] ?? 0; + final namaSatuan = + sws['nama_satuan_waktu'] ?? + 'Satuan'; + return Container( + margin: const EdgeInsets.only( + bottom: 4, + ), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 3, + ), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular( + 4, + ), + border: Border.all( + color: Colors.grey[300]!, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Rp ${_formatNumber(harga)}", + style: const TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.bold, + fontSize: 11, + ), + ), + Text( + "/$namaSatuan", + style: TextStyle( + color: Colors.grey[700], + fontSize: 10, + ), + ), + ], + ), + ); + }), + ], + ), + ) + else Container( - width: 6, - height: 6, - decoration: const BoxDecoration( - color: AppColors.success, - shape: BoxShape.circle, + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: Colors.grey[300]!, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Mulai dari Rp ${NumberFormat('#,###').format(lowestPrice)}', + style: const TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.bold, + fontSize: 11, + ), + ), + ], ), ), - const SizedBox(width: 4), - Text( - 'Tersedia', - style: TextStyle( - color: AppColors.success, - fontWeight: FontWeight.w500, - fontSize: 11, - ), - ), - ], - ), - const SizedBox(height: 6), - // Package pricing - show all pricing options with scrolling - if (satuanWaktuSewa.isNotEmpty) + const Spacer(), + + // Remove the items count badge and replace with direct Order button SizedBox( width: double.infinity, - child: Wrap( - spacing: 4, - runSpacing: 4, - children: [ - ...satuanWaktuSewa.map((sws) { - // Pastikan data yang ditampilkan valid - final harga = sws['harga'] ?? 0; - final namaSatuan = - sws['nama_satuan_waktu'] ?? 'Satuan'; - return Container( - margin: const EdgeInsets.only( - bottom: 4, - ), - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 3, - ), - decoration: BoxDecoration( - color: Colors.grey[100], - borderRadius: BorderRadius.circular( - 4, - ), - border: Border.all( - color: Colors.grey[300]!, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - "Rp ${_formatNumber(harga)}", - style: const TextStyle( - color: AppColors.primary, - fontWeight: FontWeight.bold, - fontSize: 11, - ), - ), - Text( - "/$namaSatuan", - style: TextStyle( - color: Colors.grey[700], - fontSize: 10, - ), - ), - ], - ), - ); - }), - ], - ), - ) - else - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: Colors.grey[100], - borderRadius: BorderRadius.circular(6), - border: Border.all(color: Colors.grey[300]!), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Mulai dari Rp ${NumberFormat('#,###').format(lowestPrice)}', - style: const TextStyle( - color: AppColors.primary, - fontWeight: FontWeight.bold, - fontSize: 11, - ), + child: ElevatedButton( + onPressed: () { + // Navigate to order sewa aset page with package data and isPaket flag + Get.toNamed( + Routes.ORDER_SEWA_ASET, + arguments: { + 'asetId': paket['id'], + 'paketId': paket['id'], + 'paketData': paket, + 'satuanWaktuSewa': satuanWaktuSewa, + 'isPaket': + true, // Add flag to indicate this is a package + }, + preventDuplicates: false, + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), ), - ], - ), - ), - - const Spacer(), - - // Remove the items count badge and replace with direct Order button - SizedBox( - width: double.infinity, - child: ElevatedButton( - 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, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + padding: const EdgeInsets.symmetric( + vertical: 6, + ), + minimumSize: const Size( + double.infinity, + 30, + ), + tapTargetSize: + MaterialTapTargetSize.shrinkWrap, ), - padding: const EdgeInsets.symmetric( - vertical: 6, - ), - minimumSize: const Size(double.infinity, 30), - tapTargetSize: - MaterialTapTargetSize.shrinkWrap, - ), - child: const Text( - 'Pesan', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.bold, + child: const Text( + 'Pesan', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + ), ), ), ), - ), - ], + ], + ), ), ), - ), - ], + ], + ), ), - ), - ); - }, + ); + }, + ), ), ); }); @@ -1796,8 +1808,15 @@ class SewaAsetView extends GetView { return; } - // Use the static navigation method to ensure consistent behavior - OrderSewaAsetController.navigateToOrderPage(aset.id); + // Navigate to order page with asset ID and isAset flag + Get.toNamed( + Routes.ORDER_SEWA_ASET, + arguments: { + 'asetId': aset.id, + 'isAset': true, // Add flag to indicate this is a single asset + }, + preventDuplicates: false, + ); } // Helper to format numbers for display diff --git a/lib/app/modules/warga/views/warga_dashboard_view.dart b/lib/app/modules/warga/views/warga_dashboard_view.dart index 24c7d31..4f17d58 100644 --- a/lib/app/modules/warga/views/warga_dashboard_view.dart +++ b/lib/app/modules/warga/views/warga_dashboard_view.dart @@ -148,7 +148,7 @@ class WargaDashboardView extends GetView { // Define services - removed Langganan and Pengaduan final services = [ { - 'title': 'Sewa', + 'title': 'Aset Tunggal', 'icon': Icons.home_work_outlined, 'color': const Color(0xFF4CAF50), 'route': () => controller.navigateToRentals(), @@ -168,7 +168,7 @@ class WargaDashboardView extends GetView { Padding( padding: const EdgeInsets.fromLTRB(20, 10, 20, 10), child: Text( - 'Layanan', + 'Layanan Sewa', style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, diff --git a/lib/app/modules/warga/views/warga_profile_view.dart b/lib/app/modules/warga/views/warga_profile_view.dart index 40318f8..321e786 100644 --- a/lib/app/modules/warga/views/warga_profile_view.dart +++ b/lib/app/modules/warga/views/warga_profile_view.dart @@ -13,6 +13,24 @@ class WargaProfileView extends GetView { Widget build(BuildContext context) { final navigationService = Get.find(); navigationService.setNavIndex(2); + + // State for editing mode + final isEditing = false.obs; + // State for avatar deletion + final isAvatarDeleted = false.obs; + + // Text editing controllers for editable fields + final nameController = TextEditingController( + text: controller.userName.value, + ); + final phoneController = TextEditingController( + text: controller.userPhone.value, + ); + + // Store original values for cancel functionality + final originalName = controller.userName.value; + final originalPhone = controller.userPhone.value; + return WargaLayout( appBar: AppBar( title: const Text('Profil Saya'), @@ -20,16 +38,128 @@ class WargaProfileView extends GetView { elevation: 0, centerTitle: true, actions: [ - IconButton( - onPressed: () { - Get.snackbar( - 'Info', - 'Fitur edit profil akan segera tersedia', - snackPosition: SnackPosition.BOTTOM, - ); - }, - icon: const Icon(Icons.edit_outlined), - tooltip: 'Edit Profil', + Obx( + () => + isEditing.value + ? Row( + children: [ + // Cancel button + IconButton( + onPressed: () { + // Reset values to original + nameController.text = originalName; + phoneController.text = originalPhone; + isEditing.value = false; + isAvatarDeleted.value = + false; // Reset avatar deletion state + controller + .cancelAvatarChange(); // Reset temporary avatar + }, + icon: const Icon(Icons.close), + tooltip: 'Batal', + ), + // Save button + IconButton( + onPressed: () async { + // Show loading indicator + final loadingDialog = Get.dialog( + const Center(child: CircularProgressIndicator()), + barrierDismissible: false, + ); + + bool success = true; + + // Check if there's a new avatar to save + if (controller.tempAvatarBytes.value != null) { + // Save the new avatar + success = await controller.saveNewAvatar(); + if (!success) { + // Close loading dialog if avatar saving fails + Get.back(); + + Get.snackbar( + 'Gagal', + 'Terjadi kesalahan saat menyimpan foto profil', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + + isEditing.value = false; + return; + } + } + // If avatar was deleted (and no new avatar selected), update it in the database + else if (isAvatarDeleted.value) { + success = await controller.deleteUserAvatar(); + if (!success) { + // Close loading dialog if avatar deletion fails + Get.back(); + + Get.snackbar( + 'Gagal', + 'Terjadi kesalahan saat menghapus foto profil', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + + isAvatarDeleted.value = + false; // Reset avatar deletion state + isEditing.value = false; + return; + } + } + + // Save profile changes to database + success = await controller.updateUserProfile( + namaLengkap: nameController.text, + noHp: phoneController.text, + ); + + // Close loading dialog + Get.back(); + + if (success) { + Get.snackbar( + 'Sukses', + 'Perubahan berhasil disimpan', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.green, + colorText: Colors.white, + ); + } else { + Get.snackbar( + 'Gagal', + 'Terjadi kesalahan saat menyimpan perubahan', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + + // Reset to original values on failure + nameController.text = originalName; + phoneController.text = originalPhone; + isAvatarDeleted.value = + false; // Reset avatar deletion state + controller + .cancelAvatarChange(); // Reset temporary avatar + } + + isEditing.value = false; + }, + icon: const Icon(Icons.check), + tooltip: 'Simpan', + ), + ], + ) + : IconButton( + onPressed: () { + isEditing.value = true; + }, + icon: const Icon(Icons.edit_outlined), + tooltip: 'Edit Profil', + ), ), ], ), @@ -47,15 +177,32 @@ class WargaProfileView extends GetView { onRefresh: () async { await Future.delayed(const Duration(milliseconds: 500)); controller.refreshData(); + // Update text controllers with refreshed data + nameController.text = controller.userName.value; + phoneController.text = controller.userPhone.value; + isAvatarDeleted.value = false; // Reset avatar deletion state return; }, child: SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), child: Column( children: [ - _buildProfileHeader(context), + Obx( + () => _buildProfileHeader( + context, + isEditing.value, + isAvatarDeleted, + ), + ), const SizedBox(height: 16), - _buildInfoCard(context), + Obx( + () => _buildPersonalInfoCard( + context, + isEditing.value, + nameController, + phoneController, + ), + ), const SizedBox(height: 16), _buildSettingsCard(context), const SizedBox(height: 24), @@ -66,7 +213,11 @@ class WargaProfileView extends GetView { ); } - Widget _buildProfileHeader(BuildContext context) { + Widget _buildProfileHeader( + BuildContext context, + bool isEditing, + RxBool isAvatarDeleted, + ) { return Container( width: double.infinity, decoration: BoxDecoration( @@ -97,37 +248,153 @@ class WargaProfileView extends GetView { // Profile picture with shadow effect Obx(() { final avatarUrl = controller.userAvatar.value; - return Container( - height: 110, - width: 110, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 4), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.2), - blurRadius: 10, - offset: const Offset(0, 5), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(55), - child: - avatarUrl != null && avatarUrl.isNotEmpty - ? Image.network( - avatarUrl, - fit: BoxFit.cover, - errorBuilder: - (context, error, stackTrace) => - _buildAvatarFallback(), - loadingBuilder: (context, child, progress) { - if (progress == null) return child; - return _buildAvatarFallback(); + final shouldShowFallback = + isAvatarDeleted.value || + avatarUrl == null || + avatarUrl.isEmpty; + + // Check if there's a temporary avatar preview + final hasTemporaryAvatar = + controller.tempAvatarBytes.value != null; + + return Column( + children: [ + Stack( + alignment: Alignment.bottomRight, + children: [ + Container( + height: 110, + width: 110, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 4), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 10, + offset: const Offset(0, 5), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(55), + child: + hasTemporaryAvatar + // Show temporary avatar preview + ? Image.memory( + controller.tempAvatarBytes.value!, + fit: BoxFit.cover, + ) + : shouldShowFallback + ? _buildAvatarFallback() + : Image.network( + avatarUrl!, + fit: BoxFit.cover, + errorBuilder: + (context, error, stackTrace) => + _buildAvatarFallback(), + loadingBuilder: (context, child, progress) { + if (progress == null) return child; + return _buildAvatarFallback(); + }, + ), + ), + ), + if (isEditing) + GestureDetector( + onTap: () { + // Show image source dialog when camera icon is tapped + controller.showImageSourceDialog(); + }, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + border: Border.all( + color: AppColors.primary, + width: 2, + ), + ), + child: Icon( + Icons.camera_alt, + size: 18, + color: AppColors.primary, + ), + ), + ), + ], + ), + + // Image selection buttons when in edit mode + if (isEditing) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Show "Batal" button if temporary avatar is selected + if (hasTemporaryAvatar) + ElevatedButton.icon( + onPressed: () { + controller.cancelAvatarChange(); + }, + icon: const Icon(Icons.close, size: 16), + label: const Text('Batal'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey.shade200, + foregroundColor: Colors.grey.shade800, + elevation: 0, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + textStyle: const TextStyle(fontSize: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: () { + if (hasTemporaryAvatar) { + // If temporary avatar exists, don't show the snackbar + // The actual saving will happen when the user presses the save button + isAvatarDeleted.value = false; + } else { + // Set avatar deleted flag + isAvatarDeleted.value = true; + + Get.snackbar( + 'Info', + 'Foto profil akan dihapus setelah menekan Simpan', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.orange.shade700, + colorText: Colors.white, + ); + } }, - ) - : _buildAvatarFallback(), - ), + icon: const Icon(Icons.delete_outline, size: 16), + label: const Text('Hapus'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red.shade50, + foregroundColor: Colors.red.shade700, + elevation: 0, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + textStyle: const TextStyle(fontSize: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + ), + ], + ), + ), + ], ); }), const SizedBox(height: 16), @@ -193,84 +460,192 @@ class WargaProfileView extends GetView { ); } - Widget _buildInfoCard(BuildContext context) { - return Card( - margin: const EdgeInsets.symmetric(horizontal: 16), - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide(color: Colors.grey.shade200), - ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Row( + Widget _buildPersonalInfoCard( + BuildContext context, + bool isEditing, + TextEditingController nameController, + TextEditingController phoneController, + ) { + return Column( + children: [ + // Section 1: Data Diri + Card( + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.person_outline, color: AppColors.primary, size: 18), - const SizedBox(width: 8), - Text( - 'INFORMASI PERSONAL', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Colors.grey.shade700, - letterSpacing: 0.5, - ), + Row( + children: [ + Icon( + Icons.person_rounded, + color: AppColors.primary, + size: 22, + ), + const SizedBox(width: 10), + Text( + 'Data Diri', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + if (isEditing) ...[ + const Spacer(), + Text( + 'Mode Edit', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.orange.shade700, + ), + ), + const SizedBox(width: 6), + Icon( + Icons.edit_note, + color: Colors.orange.shade700, + size: 18, + ), + ], + ], + ), + const SizedBox(height: 20), + // Email - always read-only + _buildInfoItemModern( + context, + icon: Icons.email_rounded, + title: 'Email', + value: controller.userEmail.value, + ), + const SizedBox(height: 16), + // Nama Lengkap - editable + _buildEditableInfoItem( + context, + icon: Icons.person_rounded, + title: 'Nama Lengkap', + value: controller.userName.value, + isEditing: isEditing, + controller: nameController, + ), + const SizedBox(height: 16), + // Nomor Telepon - editable + _buildEditableInfoItem( + context, + icon: Icons.phone_rounded, + title: 'Nomor Telepon', + value: controller.userPhone.value, + isEditing: isEditing, + controller: phoneController, + keyboardType: TextInputType.phone, ), ], ), ), - const Divider(height: 1), - _buildInfoItem( - icon: Icons.email_outlined, - title: 'Email', - value: - controller.userEmail.value.isEmpty - ? 'emailpengguna@example.com' - : controller.userEmail.value, + ), + + const SizedBox(height: 16), + + // Section 2: Informasi Warga + Card( + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), ), - Divider(height: 1, color: Colors.grey.shade200), - _buildInfoItem( - icon: Icons.credit_card_outlined, - title: 'NIK', - value: - controller.userNik.value.isEmpty - ? '123456789012345' - : controller.userNik.value, + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.badge_rounded, + color: AppColors.primary, + size: 22, + ), + const SizedBox(width: 10), + Text( + 'Informasi Warga', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ], + ), + const SizedBox(height: 20), + _buildInfoItemModern( + context, + icon: Icons.credit_card_rounded, + title: 'NIK', + value: controller.userNik.value, + ), + const SizedBox(height: 16), + _buildInfoItemModern( + context, + icon: Icons.calendar_today_rounded, + title: 'Tanggal Lahir', + value: controller.userTanggalLahir.value, + ), + const SizedBox(height: 16), + _buildInfoItemModern( + context, + icon: Icons.home_rounded, + title: 'Alamat', + value: controller.userAddress.value, + isMultiLine: true, + ), + const SizedBox(height: 16), + _buildInfoItemModern( + context, + icon: Icons.location_on_rounded, + title: 'RT/RW', + value: controller.userRtRw.value, + ), + const SizedBox(height: 16), + _buildInfoItemModern( + context, + icon: Icons.location_city_rounded, + title: 'Kelurahan/Desa', + value: controller.userKelurahanDesa.value, + ), + const SizedBox(height: 16), + _buildInfoItemModern( + context, + icon: Icons.map_rounded, + title: 'Kecamatan', + value: controller.userKecamatan.value, + ), + ], + ), ), - Divider(height: 1, color: Colors.grey.shade200), - _buildInfoItem( - icon: Icons.phone_outlined, - title: 'Nomor Telepon', - value: - controller.userPhone.value.isEmpty - ? '081234567890' - : controller.userPhone.value, - ), - Divider(height: 1, color: Colors.grey.shade200), - _buildInfoItem( - icon: Icons.home_outlined, - title: 'Alamat Lengkap', - value: - controller.userAddress.value.isEmpty - ? 'Jl. Contoh No. 123, Desa Sejahtera, Kec. Makmur, Kab. Berkah, Prov. Damai' - : controller.userAddress.value, - isMultiLine: true, - ), - ], - ), + ), + ], ); } - Widget _buildInfoItem({ + Widget _buildInfoItemModern( + BuildContext context, { required IconData icon, required String title, required String value, bool isMultiLine = false, }) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + return Container( + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade200), + ), + padding: const EdgeInsets.all(16), child: Row( crossAxisAlignment: isMultiLine ? CrossAxisAlignment.start : CrossAxisAlignment.center, @@ -290,14 +665,18 @@ class WargaProfileView extends GetView { children: [ Text( title, - style: TextStyle(fontSize: 13, color: Colors.grey.shade600), + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Colors.grey.shade600, + ), ), - const SizedBox(height: 3), + const SizedBox(height: 4), Text( - value, + value.isEmpty ? 'Tidak tersedia' : value, style: TextStyle( fontSize: 15, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w600, color: Colors.grey.shade800, ), maxLines: isMultiLine ? 3 : 1, @@ -311,33 +690,123 @@ class WargaProfileView extends GetView { ); } + Widget _buildEditableInfoItem( + BuildContext context, { + required IconData icon, + required String title, + required String value, + required bool isEditing, + required TextEditingController controller, + TextInputType keyboardType = TextInputType.text, + }) { + return Container( + decoration: BoxDecoration( + color: isEditing ? Colors.white : Colors.grey.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: + isEditing + ? AppColors.primary.withOpacity(0.5) + : Colors.grey.shade200, + ), + boxShadow: + isEditing + ? [ + BoxShadow( + color: AppColors.primary.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : null, + ), + padding: EdgeInsets.all(isEditing ? 12 : 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: AppColors.primary, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 4), + if (isEditing) + TextField( + controller: controller, + keyboardType: keyboardType, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: Colors.grey.shade800, + ), + decoration: InputDecoration( + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 8), + border: InputBorder.none, + hintText: 'Masukkan $title', + hintStyle: TextStyle( + color: Colors.grey.shade400, + fontSize: 15, + ), + ), + ) + else + Text( + value.isEmpty ? 'Tidak tersedia' : value, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Colors.grey.shade800, + ), + ), + ], + ), + ), + if (isEditing) Icon(Icons.edit, size: 16, color: AppColors.primary), + ], + ), + ); + } + Widget _buildSettingsCard(BuildContext context) { return Card( - margin: const EdgeInsets.symmetric(horizontal: 16), - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide(color: Colors.grey.shade200), - ), + elevation: 4, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Column( children: [ Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + padding: const EdgeInsets.fromLTRB(20, 20, 20, 12), child: Row( children: [ Icon( - Icons.settings_outlined, + Icons.settings_rounded, color: AppColors.primary, - size: 18, + size: 22, ), - const SizedBox(width: 8), + const SizedBox(width: 10), Text( - 'PENGATURAN', + 'Pengaturan', style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Colors.grey.shade700, - letterSpacing: 0.5, + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.primary, ), ), ], @@ -345,7 +814,7 @@ class WargaProfileView extends GetView { ), const Divider(height: 1), _buildActionItem( - icon: Icons.lock_outline, + icon: Icons.lock_outline_rounded, title: 'Ubah Password', iconColor: AppColors.primary, onTap: () { @@ -358,7 +827,7 @@ class WargaProfileView extends GetView { ), Divider(height: 1, color: Colors.grey.shade200), _buildActionItem( - icon: Icons.logout, + icon: Icons.logout_rounded, title: 'Keluar', iconColor: Colors.red.shade400, isDestructive: true, @@ -384,7 +853,7 @@ class WargaProfileView extends GetView { return InkWell( onTap: onTap, child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20), child: Row( children: [ Container( @@ -395,7 +864,7 @@ class WargaProfileView extends GetView { ), child: Icon(icon, color: color, size: 20), ), - const SizedBox(width: 12), + const SizedBox(width: 16), Text( title, style: TextStyle( diff --git a/lib/app/modules/warga/views/warga_sewa_view.dart b/lib/app/modules/warga/views/warga_sewa_view.dart index 9b808e5..cf7e374 100644 --- a/lib/app/modules/warga/views/warga_sewa_view.dart +++ b/lib/app/modules/warga/views/warga_sewa_view.dart @@ -39,23 +39,38 @@ class WargaSewaView extends GetView { child: _buildTabBar(), ), ), - body: Column( + body: TabBarView( + controller: controller.tabController, + physics: const AlwaysScrollableScrollPhysics(), + dragStartBehavior: DragStartBehavior.start, children: [ - Expanded( - child: TabBarView( - controller: controller.tabController, - physics: const PageScrollPhysics(), - dragStartBehavior: DragStartBehavior.start, - children: [ - _buildBelumBayarTab(), - _buildPendingTab(), - _buildDiterimaTab(), - _buildAktifTab(), - _buildDikembalikanTab(), - _buildSelesaiTab(), - _buildDibatalkanTab(), - ], - ), + RefreshIndicator( + onRefresh: controller.loadRentalsData, + child: _buildBelumBayarTab(), + ), + RefreshIndicator( + onRefresh: controller.loadRentalsData, + child: _buildPendingTab(), + ), + RefreshIndicator( + onRefresh: controller.loadRentalsData, + child: _buildDiterimaTab(), + ), + RefreshIndicator( + onRefresh: controller.loadRentalsData, + child: _buildAktifTab(), + ), + RefreshIndicator( + onRefresh: controller.loadRentalsData, + child: _buildDikembalikanTab(), + ), + RefreshIndicator( + onRefresh: controller.loadRentalsData, + child: _buildSelesaiTab(), + ), + RefreshIndicator( + onRefresh: controller.loadRentalsData, + child: _buildDibatalkanTab(), ), ], ), @@ -147,74 +162,78 @@ class WargaSewaView extends GetView { } Widget _buildPendingTab() { - return Obx(() { - // Show loading indicator while fetching data - if (controller.isLoadingPending.value) { - return const Center(child: CircularProgressIndicator()); - } + return SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Obx(() { + // Show loading indicator while fetching data + if (controller.isLoadingPending.value) { + return const Center(child: CircularProgressIndicator()); + } - // Check if there is any data to display - if (controller.pendingRentals.isNotEmpty) { - return SingleChildScrollView( - physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.all(20), - child: Column( - children: - controller.pendingRentals - .map( - (rental) => Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: _buildUnpaidRentalCard(rental), - ), - ) - .toList(), - ), - ); - } + // Check if there is any data to display + if (controller.pendingRentals.isNotEmpty) { + return Column( + children: + controller.pendingRentals + .map( + (rental) => Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: _buildUnpaidRentalCard(rental), + ), + ) + .toList(), + ); + } - // Return empty state if no data - return _buildTabContent( - icon: Icons.pending_actions, - title: 'Tidak ada pembayaran yang sedang diperiksa', - subtitle: 'Tidak ada sewa yang sedang dalam verifikasi pembayaran', - buttonText: 'Sewa Sekarang', - onButtonPressed: () => controller.navigateToRentals(), - color: AppColors.warning, - ); - }); + // Return empty state if no data + return _buildTabContent( + icon: Icons.pending_actions, + title: 'Tidak ada pembayaran yang sedang diperiksa', + subtitle: 'Tidak ada sewa yang sedang dalam verifikasi pembayaran', + buttonText: 'Sewa Sekarang', + onButtonPressed: () => controller.navigateToRentals(), + color: AppColors.warning, + ); + }), + ), + ); } Widget _buildAktifTab() { - return Obx(() { - if (controller.isLoadingActive.value) { - return const Center(child: CircularProgressIndicator()); - } - if (controller.activeRentals.isEmpty) { - return _buildTabContent( - icon: Icons.play_circle_outline, - title: 'Tidak ada sewa aktif', - subtitle: 'Sewa yang sedang berlangsung akan muncul di sini', - buttonText: 'Sewa Sekarang', - onButtonPressed: () => controller.navigateToRentals(), - color: Colors.blue, - ); - } - return SingleChildScrollView( - physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.all(20), - child: Column( - children: - controller.activeRentals - .map( - (rental) => Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: _buildAktifRentalCard(rental), - ), - ) - .toList(), - ), - ); - }); + return SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Obx(() { + if (controller.isLoadingActive.value) { + return const Center(child: CircularProgressIndicator()); + } + if (controller.activeRentals.isEmpty) { + return _buildTabContent( + icon: Icons.play_circle_outline, + title: 'Tidak ada sewa aktif', + subtitle: 'Sewa yang sedang berlangsung akan muncul di sini', + buttonText: 'Sewa Sekarang', + onButtonPressed: () => controller.navigateToRentals(), + color: Colors.blue, + ); + } + return Column( + children: + controller.activeRentals + .map( + (rental) => Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: _buildAktifRentalCard(rental), + ), + ) + .toList(), + ); + }), + ), + ); } Widget _buildAktifRentalCard(Map rental) { @@ -365,46 +384,48 @@ class WargaSewaView extends GetView { } Widget _buildBelumBayarTab() { - return Obx(() { - // Show loading indicator while fetching data - if (controller.isLoading.value) { - return const Center(child: CircularProgressIndicator()); - } + return SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Obx(() { + // Show loading indicator while fetching data + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } - // Check if there is any data to display - if (controller.rentals.isNotEmpty) { - return SingleChildScrollView( - physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.all(20), - child: Column( - children: [ - // Build a card for each rental item - ...controller.rentals - .map( - (rental) => Column( - children: [ - _buildUnpaidRentalCard(rental), - const SizedBox(height: 20), - ], - ), - ) - .toList(), - _buildTipsSection(), - ], - ), - ); - } + // Check if there is any data to display + if (controller.rentals.isNotEmpty) { + return Column( + children: [ + // Build a card for each rental item + ...controller.rentals + .map( + (rental) => Column( + children: [ + _buildUnpaidRentalCard(rental), + const SizedBox(height: 20), + ], + ), + ) + .toList(), + _buildTipsSection(), + ], + ); + } - // Return empty state if no data - return _buildTabContent( - icon: Icons.payment_outlined, - title: 'Belum ada pembayaran', - subtitle: 'Tidak ada sewa yang menunggu pembayaran', - buttonText: 'Sewa Sekarang', - onButtonPressed: () => controller.navigateToRentals(), - color: AppColors.primary, - ); - }); + // Return empty state if no data + return _buildTabContent( + icon: Icons.payment_outlined, + title: 'Belum ada pembayaran', + subtitle: 'Tidak ada sewa yang menunggu pembayaran', + buttonText: 'Sewa Sekarang', + onButtonPressed: () => controller.navigateToRentals(), + color: AppColors.primary, + ); + }), + ), + ); } Widget _buildUnpaidRentalCard(Map rental) { @@ -592,7 +613,7 @@ class WargaSewaView extends GetView { ), // Pay button ElevatedButton( - onPressed: () {}, + onPressed: () => controller.viewPaymentTab(rental), style: ElevatedButton.styleFrom( backgroundColor: rental['status'] == 'PEMBAYARAN DENDA' @@ -698,46 +719,48 @@ class WargaSewaView extends GetView { } Widget _buildDiterimaTab() { - return Obx(() { - // Show loading indicator while fetching data - if (controller.isLoadingAccepted.value) { - return const Center(child: CircularProgressIndicator()); - } + return SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Obx(() { + // Show loading indicator while fetching data + if (controller.isLoadingAccepted.value) { + return const Center(child: CircularProgressIndicator()); + } - // Check if there is any data to display - if (controller.acceptedRentals.isNotEmpty) { - return SingleChildScrollView( - physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.all(20), - child: Column( - children: [ - // Build a card for each accepted rental item - ...controller.acceptedRentals - .map( - (rental) => Column( - children: [ - _buildDiterimaRentalCard(rental), - const SizedBox(height: 20), - ], - ), - ) - .toList(), - _buildTipsSectionDiterima(), - ], - ), - ); - } + // Check if there is any data to display + if (controller.acceptedRentals.isNotEmpty) { + return Column( + children: [ + // Build a card for each accepted rental item + ...controller.acceptedRentals + .map( + (rental) => Column( + children: [ + _buildDiterimaRentalCard(rental), + const SizedBox(height: 20), + ], + ), + ) + .toList(), + _buildTipsSectionDiterima(), + ], + ); + } - // Return empty state if no data - return _buildTabContent( - icon: Icons.check_circle_outline, - title: 'Belum ada sewa diterima', - subtitle: 'Sewa yang sudah diterima akan muncul di sini', - buttonText: 'Sewa Sekarang', - onButtonPressed: () => controller.navigateToRentals(), - color: AppColors.success, - ); - }); + // Return empty state if no data + return _buildTabContent( + icon: Icons.check_circle_outline, + title: 'Belum ada sewa diterima', + subtitle: 'Sewa yang sudah diterima akan muncul di sini', + buttonText: 'Sewa Sekarang', + onButtonPressed: () => controller.navigateToRentals(), + color: AppColors.success, + ); + }), + ), + ); } Widget _buildDiterimaRentalCard(Map rental) { @@ -947,43 +970,45 @@ class WargaSewaView extends GetView { } Widget _buildSelesaiTab() { - return Obx(() { - if (controller.isLoadingCompleted.value) { - return const Center( - child: Padding( - padding: EdgeInsets.all(20.0), - child: CircularProgressIndicator(), - ), - ); - } + return SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Obx(() { + if (controller.isLoadingCompleted.value) { + return const Center( + child: Padding( + padding: EdgeInsets.all(20.0), + child: CircularProgressIndicator(), + ), + ); + } - if (controller.completedRentals.isEmpty) { - return _buildTabContent( - icon: Icons.check_circle_outline, - title: 'Belum Ada Sewa Selesai', - subtitle: 'Anda belum memiliki riwayat sewa yang telah selesai', - buttonText: 'Lihat Aset', - onButtonPressed: () => Get.toNamed('/warga-aset'), - color: AppColors.info, - ); - } + if (controller.completedRentals.isEmpty) { + return _buildTabContent( + icon: Icons.check_circle_outline, + title: 'Belum Ada Sewa Selesai', + subtitle: 'Anda belum memiliki riwayat sewa yang telah selesai', + buttonText: 'Lihat Aset', + onButtonPressed: () => Get.toNamed('/warga-aset'), + color: AppColors.info, + ); + } - return SingleChildScrollView( - physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.all(20), - child: Column( - children: - controller.completedRentals - .map( - (rental) => Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: _buildSelesaiRentalCard(rental), - ), - ) - .toList(), - ), - ); - }); + return Column( + children: + controller.completedRentals + .map( + (rental) => Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: _buildSelesaiRentalCard(rental), + ), + ) + .toList(), + ); + }), + ), + ); } Widget _buildSelesaiRentalCard(Map rental) { @@ -1170,43 +1195,45 @@ class WargaSewaView extends GetView { } Widget _buildDibatalkanTab() { - return Obx(() { - if (controller.isLoadingCancelled.value) { - return const Center( - child: Padding( - padding: EdgeInsets.all(20.0), - child: CircularProgressIndicator(), - ), - ); - } + return SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Obx(() { + if (controller.isLoadingCancelled.value) { + return const Center( + child: Padding( + padding: EdgeInsets.all(20.0), + child: CircularProgressIndicator(), + ), + ); + } - if (controller.cancelledRentals.isEmpty) { - return _buildTabContent( - icon: Icons.cancel_outlined, - title: 'Belum Ada Sewa Dibatalkan', - subtitle: 'Anda belum memiliki riwayat sewa yang dibatalkan', - buttonText: 'Lihat Aset', - onButtonPressed: () => Get.toNamed('/warga-aset'), - color: AppColors.error, - ); - } + if (controller.cancelledRentals.isEmpty) { + return _buildTabContent( + icon: Icons.cancel_outlined, + title: 'Belum Ada Sewa Dibatalkan', + subtitle: 'Anda belum memiliki riwayat sewa yang dibatalkan', + buttonText: 'Lihat Aset', + onButtonPressed: () => Get.toNamed('/warga-aset'), + color: AppColors.error, + ); + } - return SingleChildScrollView( - physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.all(20), - child: Column( - children: - controller.cancelledRentals - .map( - (rental) => Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: _buildDibatalkanRentalCard(rental), - ), - ) - .toList(), - ), - ); - }); + return Column( + children: + controller.cancelledRentals + .map( + (rental) => Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: _buildDibatalkanRentalCard(rental), + ), + ) + .toList(), + ); + }), + ), + ); } Widget _buildDibatalkanRentalCard(Map rental) { @@ -1413,7 +1440,7 @@ class WargaSewaView extends GetView { required Color color, }) { return SingleChildScrollView( - physics: const BouncingScrollPhysics(), + physics: const AlwaysScrollableScrollPhysics(), child: Padding( padding: const EdgeInsets.all(24.0), child: Column( @@ -1634,41 +1661,43 @@ class WargaSewaView extends GetView { } Widget _buildDikembalikanTab() { - return Obx(() { - if (controller.isLoadingReturned.value) { - return const Center( - child: Padding( - padding: EdgeInsets.all(20.0), - child: CircularProgressIndicator(), - ), - ); - } - if (controller.returnedRentals.isEmpty) { - return _buildTabContent( - icon: Icons.assignment_return, - title: 'Belum Ada Sewa Dikembalikan', - subtitle: 'Sewa yang sudah dikembalikan akan muncul di sini', - buttonText: 'Lihat Aset', - onButtonPressed: () => Get.toNamed('/warga-aset'), - color: Colors.deepPurple, - ); - } - return SingleChildScrollView( - physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.all(20), - child: Column( - children: - controller.returnedRentals - .map( - (rental) => Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: _buildDikembalikanRentalCard(rental), - ), - ) - .toList(), - ), - ); - }); + return SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Obx(() { + if (controller.isLoadingReturned.value) { + return const Center( + child: Padding( + padding: EdgeInsets.all(20.0), + child: CircularProgressIndicator(), + ), + ); + } + if (controller.returnedRentals.isEmpty) { + return _buildTabContent( + icon: Icons.assignment_return, + title: 'Belum Ada Sewa Dikembalikan', + subtitle: 'Sewa yang sudah dikembalikan akan muncul di sini', + buttonText: 'Lihat Aset', + onButtonPressed: () => Get.toNamed('/warga-aset'), + color: Colors.deepPurple, + ); + } + return Column( + children: + controller.returnedRentals + .map( + (rental) => Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: _buildDikembalikanRentalCard(rental), + ), + ) + .toList(), + ); + }), + ), + ); } Widget _buildDikembalikanRentalCard(Map rental) { diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index e2546b0..72877f9 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -14,6 +14,7 @@ import '../modules/warga/bindings/pembayaran_sewa_binding.dart'; import '../modules/petugas_bumdes/bindings/petugas_aset_binding.dart'; import '../modules/petugas_bumdes/bindings/petugas_paket_binding.dart'; import '../modules/petugas_bumdes/bindings/petugas_sewa_binding.dart'; +import '../modules/petugas_bumdes/bindings/petugas_penyewa_binding.dart'; import '../modules/petugas_bumdes/bindings/petugas_manajemen_bumdes_binding.dart'; import '../modules/petugas_bumdes/bindings/petugas_tambah_aset_binding.dart'; import '../modules/petugas_bumdes/bindings/petugas_tambah_paket_binding.dart'; @@ -21,6 +22,9 @@ import '../modules/petugas_bumdes/bindings/petugas_bumdes_cbp_binding.dart'; import '../modules/petugas_bumdes/bindings/list_petugas_mitra_binding.dart'; import '../modules/petugas_bumdes/bindings/list_pelanggan_aktif_binding.dart'; import '../modules/petugas_bumdes/bindings/list_tagihan_periode_binding.dart'; +import '../modules/petugas_bumdes/bindings/petugas_akun_bank_binding.dart'; +import '../modules/petugas_bumdes/bindings/petugas_laporan_binding.dart'; +import '../modules/petugas_bumdes/bindings/petugas_detail_penyewa_binding.dart'; // Import views import '../modules/auth/views/login_view.dart'; @@ -35,6 +39,7 @@ import '../modules/petugas_bumdes/views/petugas_bumdes_dashboard_view.dart'; import '../modules/petugas_bumdes/views/petugas_aset_view.dart'; import '../modules/petugas_bumdes/views/petugas_paket_view.dart'; import '../modules/petugas_bumdes/views/petugas_sewa_view.dart'; +import '../modules/petugas_bumdes/views/petugas_penyewa_view.dart'; import '../modules/petugas_bumdes/views/petugas_manajemen_bumdes_view.dart'; import '../modules/splash/views/splash_view.dart'; import '../modules/warga/views/order_sewa_aset_view.dart'; @@ -46,6 +51,9 @@ import '../modules/petugas_bumdes/views/petugas_bumdes_cbp_view.dart'; import '../modules/petugas_bumdes/views/list_petugas_mitra_view.dart'; import '../modules/petugas_bumdes/views/list_pelanggan_aktif_view.dart'; import '../modules/petugas_bumdes/views/list_tagihan_periode_view.dart'; +import '../modules/petugas_bumdes/views/petugas_akun_bank_view.dart'; +import '../modules/petugas_bumdes/views/petugas_laporan_view.dart'; +import '../modules/petugas_bumdes/views/petugas_detail_penyewa_view.dart'; // Import fixed routes (standalone file) import 'app_routes.dart'; @@ -164,6 +172,17 @@ class AppPages { binding: PetugasSewaBinding(), transition: Transition.fadeIn, ), + GetPage( + name: Routes.PETUGAS_PENYEWA, + page: () => const PetugasPenyewaView(), + binding: PetugasPenyewaBinding(), + transition: Transition.fadeIn, + ), + GetPage( + name: Routes.PETUGAS_DETAIL_PENYEWA, + page: () => const PetugasDetailPenyewaView(), + binding: PetugasDetailPenyewaBinding(), + ), GetPage( name: Routes.PETUGAS_MANAJEMEN_BUMDES, page: () => const PetugasManajemenBumdesView(), @@ -206,5 +225,16 @@ class AppPages { binding: ListTagihanPeriodeBinding(), transition: Transition.fadeIn, ), + GetPage( + name: Routes.PETUGAS_AKUN_BANK, + page: () => const PetugasAkunBankView(), + binding: PetugasAkunBankBinding(), + transition: Transition.fadeIn, + ), + GetPage( + name: Routes.PETUGAS_LAPORAN, + page: () => const PetugasLaporanView(), + binding: PetugasLaporanBinding(), + ), ]; } diff --git a/lib/app/routes/app_routes.dart b/lib/app/routes/app_routes.dart index 9ea3f2d..848daa0 100644 --- a/lib/app/routes/app_routes.dart +++ b/lib/app/routes/app_routes.dart @@ -27,9 +27,12 @@ abstract class Routes { static const LANGGANAN_ASET = '/langganan-aset'; // Petugas BUMDes Features + static const PETUGAS_BUMDES = '/petugas-bumdes'; static const PETUGAS_ASET = '/petugas-aset'; static const PETUGAS_PAKET = '/petugas-paket'; static const PETUGAS_SEWA = '/petugas-sewa'; + static const PETUGAS_PENYEWA = '/petugas-penyewa'; + static const PETUGAS_DETAIL_PENYEWA = '/petugas-detail-penyewa'; static const PETUGAS_MANAJEMEN_BUMDES = '/petugas-manajemen-bumdes'; static const PETUGAS_TAMBAH_ASET = '/petugas-tambah-aset'; static const PETUGAS_TAMBAH_PAKET = '/petugas-tambah-paket'; @@ -37,8 +40,8 @@ abstract class Routes { static const LIST_PETUGAS_MITRA = '/list-petugas-mitra'; static const LIST_PELANGGAN_AKTIF = '/list-pelanggan-aktif'; static const LIST_TAGIHAN_PERIODE = '/list-tagihan-periode'; - static const PETUGAS_LANGGANAN = '/petugas-langganan'; - static const PETUGAS_TAGIHAN_LANGGANAN = '/petugas-tagihan-langganan'; + static const PETUGAS_AKUN_BANK = '/petugas-akun-bank'; + static const PETUGAS_LAPORAN = '/petugas-laporan'; // Petugas Mitra Features static const PETUGAS_MITRA_DASHBOARD = '/petugas-mitra-dashboard'; diff --git a/lib/app/services/navigation_service.dart b/lib/app/services/navigation_service.dart index 56eccb4..e1113e6 100644 --- a/lib/app/services/navigation_service.dart +++ b/lib/app/services/navigation_service.dart @@ -29,8 +29,14 @@ class NavigationService extends GetxService { } /// Navigasi ke halaman Order Sewa Aset dengan ID - Future toOrderSewaAset(String asetId) async { - debugPrint('🧭 Navigating to OrderSewaAset with ID: $asetId'); + Future toOrderSewaAset( + String asetId, { + bool isAset = false, + bool isPaket = false, + }) async { + debugPrint( + '🧭 Navigating to OrderSewaAset with ID: $asetId, isAset: $isAset, isPaket: $isPaket', + ); if (asetId.isEmpty) { Get.snackbar( 'Error', @@ -45,7 +51,7 @@ class NavigationService extends GetxService { // Navigasi dengan arguments Get.toNamed( Routes.ORDER_SEWA_ASET, - arguments: {'asetId': asetId}, + arguments: {'asetId': asetId, 'isAset': isAset, 'isPaket': isPaket}, preventDuplicates: false, ); } @@ -65,10 +71,7 @@ class NavigationService extends GetxService { } // Navigasi dengan arguments - Get.offAndToNamed( - Routes.PEMBAYARAN_SEWA, - arguments: {'sewaId': sewaId}, - ); + Get.offAndToNamed(Routes.PEMBAYARAN_SEWA, arguments: {'sewaId': sewaId}); } /// Kembali ke halaman Sewa Aset diff --git a/lib/app/services/sewa_service.dart b/lib/app/services/sewa_service.dart index 23eca91..3f8b0dc 100644 --- a/lib/app/services/sewa_service.dart +++ b/lib/app/services/sewa_service.dart @@ -52,7 +52,9 @@ class SewaService { final tagihanData = await _supabase .from('tagihan_sewa') - .select('sewa_aset_id, total_tagihan, denda, tagihan_dibayar') + .select( + 'sewa_aset_id, total_tagihan, denda, tagihan_dibayar, satuan_waktu', + ) .filter('sewa_aset_id', 'in', '(${sewaIds.join(",")})') as List; final Map> mapTagihan = { @@ -210,6 +212,7 @@ class SewaService { wargaNama: warga['nama'] ?? '-', wargaNoHp: warga['noHp'] ?? '-', wargaAvatar: warga['avatar'] ?? '-', + namaSatuanWaktu: tagihan['satuan_waktu'] as String?, ), ); } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index ea3bde6..a1d4325 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -21,6 +22,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) gtk_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); gtk_plugin_register_with_registrar(gtk_registrar); + g_autoptr(FlPluginRegistrar) printing_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin"); + printing_plugin_register_with_registrar(printing_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 0420466..be79232 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_linux flutter_secure_storage_linux gtk + printing url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 5a0330e..ba03c13 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,6 +10,7 @@ import file_selector_macos import flutter_image_compress_macos import flutter_secure_storage_macos import path_provider_foundation +import printing import shared_preferences_foundation import sqflite_darwin import url_launcher_macos @@ -20,6 +21,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/pubspec.lock b/pubspec.lock index ce0c194..0270988 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,18 +5,10 @@ packages: dependency: transitive description: name: app_links - sha256: "85ed8fc1d25a76475914fff28cc994653bd900bc2c26e4b57a49e097febb54ba" + sha256: "3ced568a5d9e309e99af71285666f1f3117bddd0bd5b3317979dccc1a40cada4" url: "https://pub.dev" source: hosted - version: "6.4.0" - app_links_linux: - dependency: transitive - description: - name: app_links_linux - sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 - url: "https://pub.dev" - source: hosted - version: "1.0.3" + version: "3.5.1" app_links_platform_interface: dependency: transitive description: @@ -26,13 +18,21 @@ packages: source: hosted version: "2.0.2" app_links_web: - dependency: transitive + dependency: "direct main" description: name: app_links_web sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 url: "https://pub.dev" source: hosted version: "1.0.4" + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" async: dependency: transitive description: @@ -41,6 +41,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.12.0" + barcode: + dependency: transitive + description: + name: barcode + sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" + url: "https://pub.dev" + source: hosted + version: "2.2.9" + bidi: + dependency: transitive + description: + name: bidi + sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d" + url: "https://pub.dev" + source: hosted + version: "2.0.13" boolean_selector: dependency: transitive description: @@ -53,26 +69,26 @@ packages: dependency: "direct main" description: name: cached_network_image - sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + sha256: "28ea9690a8207179c319965c13cd8df184d5ee721ae2ce60f398ced1219cea1f" url: "https://pub.dev" source: hosted - version: "3.4.1" + version: "3.3.1" cached_network_image_platform_interface: dependency: transitive description: name: cached_network_image_platform_interface - sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + sha256: "9e90e78ae72caa874a323d78fa6301b3fb8fa7ea76a8f96dc5b5bf79f283bf2f" url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.0.0" cached_network_image_web: dependency: transitive description: name: cached_network_image_web - sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + sha256: "205d6a9f1862de34b93184f22b9d2d94586b2f05c581d546695e3d8f6a805cd7" url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.2.0" characters: dependency: transitive description: @@ -194,10 +210,10 @@ packages: dependency: transitive description: name: flutter_cache_manager - sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + sha256: "8207f27539deb83732fdda03e259349046a39a4c767269285f449ade355d54ba" url: "https://pub.dev" source: hosted - version: "3.4.1" + version: "3.3.1" flutter_dotenv: dependency: "direct main" description: @@ -345,10 +361,10 @@ packages: dependency: transitive description: name: functions_client - sha256: a49876ebae32a50eb62483c5c5ac80ed0d8da34f98ccc23986b03a8d28cee07c + sha256: "91bd57c5ee843957bfee68fdcd7a2e8b3c1081d448e945d33ff695fb9c2a686c" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.3" get: dependency: "direct main" description: @@ -377,10 +393,10 @@ packages: dependency: transitive description: name: gotrue - sha256: d6362dff9a54f8c1c372bb137c858b4024c16407324d34e6473e59623c9b9f50 + sha256: "941694654ab659990547798569771d8d092f2ade84a72e75bb9bbca249f3d3b1" url: "https://pub.dev" source: hosted - version: "2.11.1" + version: "2.13.0" gtk: dependency: transitive description: @@ -393,10 +409,10 @@ packages: dependency: transitive description: name: http - sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f + sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" http_parser: dependency: transitive description: @@ -405,6 +421,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + url: "https://pub.dev" + source: hosted + version: "4.5.4" image_picker: dependency: "direct main" description: @@ -589,6 +613,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: "direct main" description: @@ -637,6 +669,78 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + pdf: + dependency: "direct main" + description: + name: pdf + sha256: "28eacad99bffcce2e05bba24e50153890ad0255294f4dd78a17075a2ba5c8416" + url: "https://pub.dev" + source: hosted + version: "3.11.3" + pdf_widget_wrapper: + dependency: transitive + description: + name: pdf_widget_wrapper + sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5 + url: "https://pub.dev" + source: hosted + version: "1.0.4" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "2d070d8684b68efb580a5997eb62f675e8a885ef0be6e754fb9ef489c177470f" + url: "https://pub.dev" + source: hosted + version: "12.0.0+1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" + url: "https://pub.dev" + source: hosted + version: "6.1.0" photo_view: dependency: "direct main" description: @@ -661,22 +765,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: f0d7856b6ca1887cfa6d1d394056a296ae33489db914e365e2044fdada449e62 + url: "https://pub.dev" + source: hosted + version: "6.0.2" postgrest: dependency: transitive description: name: postgrest - sha256: b74dc0f57b5dca5ce9f57a54b08110bf41d6fc8a0483c0fec10c79e9aa0fb2bb + sha256: "10b81a23b1c829ccadf68c626b4d66666453a1474d24c563f313f5ca7851d575" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" + printing: + dependency: "direct main" + description: + name: printing + sha256: "482cd5a5196008f984bb43ed0e47cbfdca7373490b62f3b27b3299275bf22a93" + url: "https://pub.dev" + source: hosted + version: "5.14.2" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" realtime_client: dependency: transitive description: name: realtime_client - sha256: e3089dac2121917cc0c72d42ab056fea0abbaf3c2229048fc50e64bafc731adf + sha256: b6a825a4c80f2281ebfbbcf436a8979ae9993d4a30dbcf011b7d2b82ddde9edd url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.5.1" retry: dependency: transitive description: @@ -689,10 +817,10 @@ packages: dependency: transitive description: name: rxdart - sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" url: "https://pub.dev" source: hosted - version: "0.28.0" + version: "0.27.7" shared_preferences: dependency: transitive description: @@ -822,10 +950,10 @@ packages: dependency: transitive description: name: storage_client - sha256: "9f9ed283943313b23a1b27139bb18986e9b152a6d34530232c702c468d98e91a" + sha256: "09bac4d75eea58e8113ca928e6655a09cc8059e6d1b472ee801f01fde815bcfc" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.4.0" stream_channel: dependency: transitive description: @@ -846,18 +974,18 @@ packages: dependency: transitive description: name: supabase - sha256: c3ebddba69ddcf16d8b78e8c44c4538b0193d1cf944fde3b72eb5b279892a370 + sha256: "56c3493114caac8ef0dc3cac5fa24a9edefeb8c22d45794814c0fe3d2feb1a98" url: "https://pub.dev" source: hosted - version: "2.6.3" + version: "2.8.0" supabase_flutter: dependency: "direct main" description: name: supabase_flutter - sha256: "3b5b5b492e342f63f301605d0c66f6528add285b5744f53c9fd9abd5ffdbce5b" + sha256: "66b8d0a7a31f45955b11ad7b65347abc61b31e10f8bdfa4428501b81f5b30fa5" url: "https://pub.dev" source: hosted - version: "2.8.4" + version: "2.9.1" synchronized: dependency: transitive description: @@ -986,22 +1114,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - web_socket: - dependency: transitive - description: - name: web_socket - sha256: bfe6f435f6ec49cb6c01da1e275ae4228719e59a6b067048c51e72d9d63bcc4b - url: "https://pub.dev" - source: hosted - version: "1.0.0" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "2.4.0" win32: dependency: transitive description: @@ -1018,14 +1138,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" yet_another_json_isolate: dependency: transitive description: name: yet_another_json_isolate - sha256: "56155e9e0002cc51ea7112857bbcdc714d4c35e176d43e4d3ee233009ff410c9" + sha256: fe45897501fa156ccefbfb9359c9462ce5dec092f05e8a56109db30be864f01e url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "2.1.0" sdks: dart: ">=3.7.2 <4.0.0" flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml index 5d6a7cd..8f5d835 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -46,7 +46,7 @@ dependencies: cached_network_image: ^3.3.1 google_fonts: ^6.1.0 flutter_dotenv: ^5.1.0 - image_picker: ^1.0.7 + image_picker: ^1.1.2 intl: 0.19.0 logger: ^2.1.0 flutter_localizations: @@ -56,6 +56,10 @@ dependencies: flutter_logs: ^2.2.1 flutter_image_compress: ^2.4.0 path_provider: ^2.1.5 + pdf: ^3.11.3 + printing: ^5.14.2 + permission_handler: ^12.0.0+1 + app_links_web: ^1.0.4 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index bf6dc58..a215292 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,8 @@ #include #include #include +#include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -18,6 +20,10 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + PrintingPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PrintingPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b4be188..661a63e 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -6,6 +6,8 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links file_selector_windows flutter_secure_storage_windows + permission_handler_windows + printing url_launcher_windows )