fitur petugas

This commit is contained in:
Andreas Malvino
2025-06-22 09:25:58 +07:00
parent c4dd4fdfa2
commit 8284c93aa5
48 changed files with 8688 additions and 3436 deletions

View File

@ -5,6 +5,7 @@ class AsetModel {
final String nama;
final String deskripsi;
final String kategori;
final String jenis; // Add this line
final int harga;
final int? denda;
final String status;
@ -14,17 +15,21 @@ class AsetModel {
final int? kuantitasTerpakai;
final String? satuanUkur;
// Untuk menampung URL gambar pertama dari tabel foto_aset
// URL gambar utama (untuk backward compatibility)
String? imageUrl;
// List untuk menyimpan semua URL gambar aset
final RxList<String> imageUrls = <String>[].obs;
// Menggunakan RxList untuk membuatnya mutable dan reaktif
RxList<Map<String, dynamic>> satuanWaktuSewa = <Map<String, dynamic>>[].obs;
final RxList<Map<String, dynamic>> satuanWaktuSewa = <Map<String, dynamic>>[].obs;
AsetModel({
required this.id,
required this.nama,
required this.deskripsi,
required this.kategori,
this.jenis = 'Sewa', // Add this line with default value
required this.harga,
this.denda,
required this.status,
@ -42,31 +47,69 @@ class AsetModel {
}
}
// Menambahkan URL gambar dari JSON
void addImageUrl(String? url) {
if (url != null && url.isNotEmpty && !imageUrls.contains(url)) {
imageUrls.add(url);
// Update imageUrl untuk backward compatibility
if (imageUrl == null) {
imageUrl = url;
}
}
}
// Menghapus URL gambar
bool removeImageUrl(String url) {
final removed = imageUrls.remove(url);
if (removed && imageUrl == url) {
imageUrl = imageUrls.isNotEmpty ? imageUrls.first : null;
}
return removed;
}
factory AsetModel.fromJson(Map<String, dynamic> json) {
return AsetModel(
final model = AsetModel(
id: json['id'] ?? '',
nama: json['nama'] ?? '',
deskripsi: json['deskripsi'] ?? '',
kategori: json['kategori'] ?? '',
jenis: json['jenis'] ?? 'Sewa',
harga: json['harga'] ?? 0,
denda: json['denda'],
status: json['status'] ?? '',
createdAt:
json['created_at'] != null
createdAt: json['created_at'] != null
? DateTime.parse(json['created_at'])
: null,
updatedAt:
json['updated_at'] != null
updatedAt: json['updated_at'] != null
? DateTime.parse(json['updated_at'])
: null,
kuantitas: json['kuantitas'],
kuantitasTerpakai: json['kuantitas_terpakai'],
satuanUkur: json['satuan_ukur'],
imageUrl: json['foto_aset'],
initialSatuanWaktuSewa: json['satuan_waktu_sewa'] != null
? List<Map<String, dynamic>>.from(json['satuan_waktu_sewa'])
: null,
);
// Add the main image URL to the list if it exists
if (json['foto_aset'] != null) {
model.addImageUrl(json['foto_aset']);
}
// Add any additional image URLs if they exist in the JSON
if (json['foto_aset_tambahan'] != null) {
final additionalImages = List<String>.from(json['foto_aset_tambahan']);
for (final url in additionalImages) {
model.addImageUrl(url);
}
}
return model;
}
Map<String, dynamic> toJson() {
return {
final data = <String, dynamic>{
'id': id,
'nama': nama,
'deskripsi': deskripsi,
@ -80,5 +123,23 @@ class AsetModel {
'kuantitas_terpakai': kuantitasTerpakai,
'satuan_ukur': satuanUkur,
};
// Add image URLs if they exist
if (imageUrls.isNotEmpty) {
data['foto_aset'] = imageUrl;
// Add additional images (excluding the main image)
final additionalImages = imageUrls.where((url) => url != imageUrl).toList();
if (additionalImages.isNotEmpty) {
data['foto_aset_tambahan'] = additionalImages;
}
}
// Add rental time units if they exist
if (satuanWaktuSewa.isNotEmpty) {
data['satuan_waktu_sewa'] = satuanWaktuSewa.toList();
}
return data;
}
}

View File

@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:developer' as developer;
class PaketModel {
final String id;
@ -6,12 +7,13 @@ class PaketModel {
final String deskripsi;
final double harga;
final int kuantitas;
final List<String> foto;
final List<Map<String, dynamic>> satuanWaktuSewa;
final String status;
List<String> foto;
List<Map<String, dynamic>> satuanWaktuSewa;
final DateTime createdAt;
final DateTime updatedAt;
final String? foto_paket; // Main photo URL
final List<String>? images; // List of photo URLs
String? foto_paket; // Main photo URL
List<String>? images; // List of photo URLs
PaketModel({
required this.id,
@ -19,13 +21,47 @@ class PaketModel {
required this.deskripsi,
required this.harga,
required this.kuantitas,
required this.foto,
required this.satuanWaktuSewa,
this.status = 'aktif',
required List<String> foto,
required List<Map<String, dynamic>> satuanWaktuSewa,
this.foto_paket,
this.images,
List<String>? images,
required this.createdAt,
required this.updatedAt,
});
}) : foto = List.from(foto),
satuanWaktuSewa = List.from(satuanWaktuSewa),
images = images != null ? List.from(images) : [];
// Add copyWith method for immutability patterns
PaketModel copyWith({
String? id,
String? nama,
String? deskripsi,
double? harga,
int? kuantitas,
String? status,
List<String>? foto,
List<Map<String, dynamic>>? satuanWaktuSewa,
String? foto_paket,
List<String>? images,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return PaketModel(
id: id ?? this.id,
nama: nama ?? this.nama,
deskripsi: deskripsi ?? this.deskripsi,
harga: harga ?? this.harga,
kuantitas: kuantitas ?? this.kuantitas,
status: status ?? this.status,
foto: foto ?? List.from(this.foto),
satuanWaktuSewa: satuanWaktuSewa ?? List.from(this.satuanWaktuSewa),
foto_paket: foto_paket ?? this.foto_paket,
images: images ?? (this.images != null ? List.from(this.images!) : null),
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
// Alias for fromJson to maintain compatibility
factory PaketModel.fromMap(Map<String, dynamic> json) => PaketModel.fromJson(json);
@ -63,10 +99,15 @@ class PaketModel {
}
}
developer.log('📦 [PaketModel.fromJson] Raw status: ${json['status']} (type: ${json['status']?.runtimeType})');
final status = json['status']?.toString().toLowerCase() ?? 'aktif';
developer.log(' 🏷️ Processed status: $status');
return PaketModel(
id: json['id']?.toString() ?? '',
nama: json['nama']?.toString() ?? '',
deskripsi: json['deskripsi']?.toString() ?? '',
status: status,
harga: (json['harga'] is num) ? (json['harga'] as num).toDouble() : 0.0,
kuantitas: (json['kuantitas'] is num) ? (json['kuantitas'] as num).toInt() : 1,
foto: fotoList,
@ -97,34 +138,6 @@ class PaketModel {
'updated_at': updatedAt.toIso8601String(),
};
// Create a copy of the model with some fields updated
PaketModel copyWith({
String? id,
String? nama,
String? deskripsi,
double? harga,
int? kuantitas,
List<String>? foto,
List<Map<String, dynamic>>? satuanWaktuSewa,
String? foto_paket,
List<String>? images,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return PaketModel(
id: id ?? this.id,
nama: nama ?? this.nama,
deskripsi: deskripsi ?? this.deskripsi,
harga: harga ?? this.harga,
kuantitas: kuantitas ?? this.kuantitas,
foto: foto ?? List.from(this.foto),
satuanWaktuSewa: satuanWaktuSewa ?? List.from(this.satuanWaktuSewa),
foto_paket: foto_paket ?? this.foto_paket,
images: images ?? (this.images != null ? List.from(this.images!) : null),
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
// Get the first photo URL or a placeholder
String get firstPhotoUrl => foto.isNotEmpty ? foto.first : '';

View File

@ -0,0 +1,22 @@
class PembayaranModel {
final String id;
final int totalPembayaran;
final String metodePembayaran;
final DateTime waktuPembayaran;
PembayaranModel({
required this.id,
required this.totalPembayaran,
required this.metodePembayaran,
required this.waktuPembayaran,
});
factory PembayaranModel.fromJson(Map<String, dynamic> json) {
return PembayaranModel(
id: json['id'] as String,
totalPembayaran: json['total_pembayaran'] as int,
metodePembayaran: json['metode_pembayaran'] as String,
waktuPembayaran: DateTime.parse(json['waktu_pembayaran'] as String),
);
}
}

View File

@ -1 +1,95 @@
class SewaModel {
final String id;
final String userId;
final String status;
final DateTime waktuMulai;
final DateTime waktuSelesai;
final DateTime tanggalPemesanan;
final String tipePesanan;
final int kuantitas;
// Untuk tunggal
final String? asetId;
final String? asetNama;
final String? asetFoto;
// Untuk paket
final String? paketId;
final String? paketNama;
final String? paketFoto;
// Tagihan
final double totalTagihan;
// Data warga
final String wargaNama;
final String wargaNoHp;
final String wargaAvatar;
final double? denda;
final double? dibayar;
final double? paidAmount;
SewaModel({
required this.id,
required this.userId,
required this.status,
required this.waktuMulai,
required this.waktuSelesai,
required this.tanggalPemesanan,
required this.tipePesanan,
required this.kuantitas,
this.asetId,
this.asetNama,
this.asetFoto,
this.paketId,
this.paketNama,
this.paketFoto,
required this.totalTagihan,
required this.wargaNama,
required this.wargaNoHp,
required this.wargaAvatar,
this.denda,
this.dibayar,
this.paidAmount,
});
factory SewaModel.fromJson(Map<String, dynamic> json) {
return SewaModel(
id: json['id'] ?? '',
userId: json['user_id'] ?? '',
status: json['status'] ?? '',
waktuMulai: DateTime.parse(
json['waktu_mulai'] ?? DateTime.now().toIso8601String(),
),
waktuSelesai: DateTime.parse(
json['waktu_selesai'] ?? DateTime.now().toIso8601String(),
),
tanggalPemesanan: DateTime.parse(
json['tanggal_pemesanan'] ?? DateTime.now().toIso8601String(),
),
tipePesanan: json['tipe_pesanan'] ?? '',
kuantitas: json['kuantitas'] ?? 1,
asetId: json['aset_id'],
asetNama: json['aset_nama'],
asetFoto: json['aset_foto'],
paketId: json['paket_id'],
paketNama: json['paket_nama'],
paketFoto: json['paket_foto'],
totalTagihan:
(json['total_tagihan'] is num)
? json['total_tagihan'].toDouble()
: double.tryParse(json['total_tagihan']?.toString() ?? '0') ?? 0,
wargaNama: json['warga_nama'] ?? '',
wargaNoHp: json['warga_no_hp'] ?? '',
wargaAvatar: json['warga_avatar'] ?? '',
denda:
(json['denda'] is num)
? json['denda'].toDouble()
: double.tryParse(json['denda']?.toString() ?? '0'),
dibayar:
(json['dibayar'] is num)
? json['dibayar'].toDouble()
: double.tryParse(json['dibayar']?.toString() ?? '0'),
paidAmount:
(json['paid_amount'] is num)
? json['paid_amount'].toDouble()
: double.tryParse(json['paid_amount']?.toString() ?? '0'),
);
}
}

View File

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
@ -6,6 +8,8 @@ import '../models/foto_aset_model.dart';
import '../models/satuan_waktu_model.dart';
import '../models/satuan_waktu_sewa_model.dart';
import 'package:intl/intl.dart';
import '../models/paket_model.dart';
import '../providers/auth_provider.dart';
class AsetProvider extends GetxService {
late final SupabaseClient client;
@ -24,8 +28,16 @@ class AsetProvider extends GetxService {
.from('aset')
.select('*')
.eq('kategori', 'sewa')
.eq('status', 'tersedia') // Hanya yang tersedia
.order('nama', ascending: true); // Urutan berdasarkan nama
.ilike('status', 'tersedia') // Hanya yang tersedia
.order('nama', ascending: true) // Urutan berdasarkan nama
.withConverter<List<Map<String, dynamic>>>(
(data) =>
data.map<Map<String, dynamic>>((item) {
// Ensure 'jenis' is set to 'Sewa' for sewa assets
item['jenis'] = 'Sewa';
return item;
}).toList(),
);
debugPrint('Fetched ${response.length} aset');
@ -56,8 +68,16 @@ class AsetProvider extends GetxService {
.from('aset')
.select('*')
.eq('kategori', 'langganan')
.eq('status', 'tersedia') // Hanya yang tersedia
.order('nama', ascending: true); // Urutan berdasarkan nama
.ilike('status', 'tersedia') // Hanya yang tersedia
.order('nama', ascending: true) // Urutan berdasarkan nama
.withConverter<List<Map<String, dynamic>>>(
(data) =>
data.map<Map<String, dynamic>>((item) {
// Ensure 'jenis' is set to 'Langganan' for langganan assets
item['jenis'] = 'Langganan';
return item;
}).toList(),
);
debugPrint('Fetched ${response.length} langganan aset');
@ -120,9 +140,26 @@ class AsetProvider extends GetxService {
Future<void> loadAssetPhotos(AsetModel aset) async {
try {
final photos = await getAsetPhotos(aset.id);
if (photos.isNotEmpty &&
(aset.imageUrl == null || aset.imageUrl!.isEmpty)) {
aset.imageUrl = photos.first.fotoAset;
if (photos.isNotEmpty) {
// Clear existing images
aset.imageUrls.clear();
// Add all photos to the imageUrls list
for (final photo in photos) {
if (photo.fotoAset != null && photo.fotoAset!.isNotEmpty) {
aset.addImageUrl(photo.fotoAset);
}
}
// Set the main image URL if it's not already set
if ((aset.imageUrl == null || aset.imageUrl!.isEmpty) &&
aset.imageUrls.isNotEmpty) {
aset.imageUrl = aset.imageUrls.first;
}
debugPrint(
'✅ Loaded ${aset.imageUrls.length} photos for asset ${aset.id}',
);
}
} catch (e) {
debugPrint('Error loading asset photos for ID ${aset.id}: $e');
@ -172,6 +209,376 @@ class AsetProvider extends GetxService {
}
}
// Create a new asset
Future<Map<String, dynamic>?> createAset(
Map<String, dynamic> asetData,
) async {
try {
debugPrint('🔄 Creating new aset with data:');
asetData.forEach((key, value) {
debugPrint(' $key: $value');
});
final response =
await client.from('aset').insert(asetData).select().single();
debugPrint('✅ Aset created successfully with ID: ${response['id']}');
return response;
} catch (e) {
debugPrint('❌ Error creating aset: $e');
debugPrint('❌ Stack trace: ${StackTrace.current}');
return null;
}
}
// Update an existing asset
Future<bool> updateAset(String asetId, Map<String, dynamic> asetData) async {
try {
debugPrint('🔄 Updating aset with ID: $asetId');
asetData.forEach((key, value) {
debugPrint(' $key: $value');
});
final response = await client
.from('aset')
.update(asetData)
.eq('id', asetId);
debugPrint('✅ Aset updated successfully');
return true;
} catch (e) {
debugPrint('❌ Error updating aset: $e');
debugPrint('❌ Stack trace: ${StackTrace.current}');
return false;
}
}
/// Adds a photo URL to the foto_aset table for a specific asset
Future<bool> addFotoAset({
required String asetId,
required String fotoUrl,
}) async {
try {
debugPrint('💾 Attempting to save foto to database:');
debugPrint(' - asetId: $asetId');
debugPrint(' - fotoUrl: $fotoUrl');
final data = {
'id_aset': asetId,
'foto_aset': fotoUrl,
'created_at': DateTime.now().toIso8601String(),
};
debugPrint('📤 Inserting into foto_aset table...');
final response = await client.from('foto_aset').insert(data).select();
debugPrint('📝 Database insert response: $response');
if (response == null) {
debugPrint('❌ Failed to insert into foto_aset: Response is null');
return false;
}
if (response is List && response.isNotEmpty) {
debugPrint('✅ Successfully added foto for aset ID: $asetId');
return true;
} else {
debugPrint('❌ Failed to add foto: Empty or invalid response');
return false;
}
} catch (e, stackTrace) {
debugPrint('❌ Error adding foto aset: $e');
debugPrint('Stack trace: $stackTrace');
return false;
}
}
/// Add satuan waktu sewa for an asset
Future<bool> addSatuanWaktuSewa({
required String asetId,
required String satuanWaktu,
required int harga,
required int maksimalWaktu,
}) async {
try {
// First, get the satuan_waktu_id from the satuan_waktu table
final response =
await client
.from('satuan_waktu')
.select('id')
.ilike('nama_satuan_waktu', satuanWaktu)
.maybeSingle();
if (response == null) {
debugPrint('❌ Satuan waktu "$satuanWaktu" not found in the database');
return false;
}
final satuanWaktuId = response['id'] as String;
final data = {
'aset_id': asetId,
'satuan_waktu_id': satuanWaktuId,
'harga': harga,
'maksimal_waktu': maksimalWaktu,
};
debugPrint('🔄 Adding satuan waktu sewa:');
data.forEach((key, value) {
debugPrint(' $key: $value');
});
await client.from('satuan_waktu_sewa').insert(data);
debugPrint('✅ Satuan waktu sewa added successfully');
return true;
} catch (e) {
debugPrint('❌ Error adding satuan waktu sewa: $e');
debugPrint('❌ Stack trace: ${StackTrace.current}');
return false;
}
}
// Delete all satuan waktu sewa for an asset
Future<bool> deleteSatuanWaktuSewaByAsetId(String asetId) async {
try {
await client
.from('satuan_waktu_sewa')
.delete()
.eq('aset_id', asetId); // Changed from 'id_aset' to 'aset_id'
debugPrint('✅ Deleted satuan waktu sewa for aset ID: $asetId');
return true;
} catch (e) {
debugPrint('❌ Error deleting satuan waktu sewa: $e');
return false;
}
}
/// Uploads a file to Supabase Storage root
/// Returns the public URL of the uploaded file, or null if upload fails
Future<String?> uploadFileToStorage(File file) async {
try {
if (!await file.exists()) {
debugPrint('❌ File does not exist: ${file.path}');
return null;
}
final fileName =
'${DateTime.now().millisecondsSinceEpoch}_${file.path.split(Platform.pathSeparator).last}';
debugPrint('🔄 Preparing to upload file: $fileName');
final uploadResponse = await client.storage
.from('foto.aset')
.upload(fileName, file, fileOptions: FileOptions(upsert: true));
debugPrint('📤 Upload response: $uploadResponse');
final publicUrl = client.storage.from('foto.aset').getPublicUrl(fileName);
debugPrint('✅ File uploaded successfully. Public URL: $publicUrl');
return publicUrl;
} catch (e, stackTrace) {
debugPrint('❌ Error uploading file to storage: $e');
debugPrint('Stack trace: $stackTrace');
return null;
}
}
/// Helper method to delete a file from Supabase Storage
Future<bool> deleteFileFromStorage(String fileUrl) async {
try {
debugPrint('🔄 Preparing to delete file from storage');
// Extract the file path from the full URL
final uri = Uri.parse(fileUrl);
final pathSegments = uri.pathSegments;
// Find the index of 'foto.aset' in the path
final fotoAsetIndex = pathSegments.indexWhere(
(segment) => segment == 'foto.aset',
);
if (fotoAsetIndex == -1 || fotoAsetIndex == pathSegments.length - 1) {
debugPrint(
'⚠️ Invalid file URL format, cannot extract file path: $fileUrl',
);
return false;
}
// Get the file path relative to the bucket
final filePath = pathSegments.sublist(fotoAsetIndex + 1).join('/');
debugPrint('🗑️ Deleting file from storage - Path: $filePath');
// Delete the file from storage
await client.storage.from('foto.aset').remove([filePath]);
debugPrint('✅ Successfully deleted file from storage');
return true;
} catch (e) {
debugPrint('❌ Error deleting file from storage: $e');
return false;
}
}
/// Updates the photos for an asset
/// Handles both local file uploads and existing URLs
/// Returns true if all operations were successful
Future<bool> updateFotoAset({
required String asetId,
required List<String> fotoUrls,
}) async {
if (fotoUrls.isEmpty) {
debugPrint(' No photos to update for asset: $asetId');
return true;
}
try {
debugPrint('🔄 Starting photo update for asset: $asetId');
// 1. Get existing photo URLs before deleting them
debugPrint('📋 Fetching existing photos for asset: $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);
} else {
debugPrint('⚠️ Skipping invalid photo URL: $photo');
}
}
} else {
debugPrint(' No existing photos found in database');
}
// 3. Remove duplicates from new fotoUrls
final uniqueFotoUrls = fotoUrls.toSet().toList();
debugPrint(
'📸 Processing ${uniqueFotoUrls.length} unique photos (was ${fotoUrls.length})',
);
// 4. Delete existing photo records from database
debugPrint('🗑️ Removing existing photo records from database');
try {
final deleteResponse = await client
.from('foto_aset')
.delete()
.eq('id_aset', asetId);
debugPrint('🗑️ Database delete response: $deleteResponse');
// Verify deletion
final remainingPhotos = await client
.from('foto_aset')
.select()
.eq('id_aset', asetId);
if (remainingPhotos is List && remainingPhotos.isNotEmpty) {
debugPrint(
'⚠️ Warning: ${remainingPhotos.length} photos still exist in database after delete',
);
}
} catch (e) {
debugPrint('❌ Error deleting existing photo records: $e');
// Continue with the update even if deletion fails
}
// 5. Process each unique new photo
bool allSuccess = true;
int processedCount = 0;
for (final fotoUrl in uniqueFotoUrls) {
if (fotoUrl.isEmpty) {
debugPrint('⏭️ Skipping empty photo URL');
continue;
}
try {
debugPrint(
'\n🔄 Processing photo ${processedCount + 1}/${uniqueFotoUrls.length}: ${fotoUrl.length > 50 ? '${fotoUrl.substring(0, 50)}...' : fotoUrl}',
);
// Check if it's a local file
if (fotoUrl.startsWith('file://') ||
fotoUrl.startsWith('/') ||
!fotoUrl.startsWith('http')) {
final file = File(fotoUrl.replaceFirst('file://', ''));
if (!await file.exists()) {
debugPrint('❌ File does not exist: ${file.path}');
allSuccess = false;
continue;
}
debugPrint('📤 Uploading local file...');
final uploadedUrl = await uploadFileToStorage(file);
if (uploadedUrl == null) {
debugPrint('❌ Failed to upload file');
allSuccess = false;
continue;
}
debugPrint('💾 Saving to database...');
final success = await addFotoAset(
asetId: asetId,
fotoUrl: uploadedUrl,
);
if (success) {
processedCount++;
debugPrint('✅ Successfully saved photo #$processedCount');
} else {
allSuccess = false;
debugPrint('❌ Failed to save photo URL to database');
}
}
// Skip placeholder values
else if (fotoUrl == 'pending_upload') {
debugPrint('⏭️ Skipping placeholder URL');
continue;
}
// Handle existing URLs
else if (fotoUrl.startsWith('http')) {
debugPrint('🌐 Processing existing URL...');
final success = await addFotoAset(asetId: asetId, fotoUrl: fotoUrl);
if (success) {
processedCount++;
debugPrint('✅ Successfully saved URL #$processedCount');
} else {
allSuccess = false;
debugPrint('❌ Failed to save URL to database');
}
} else {
debugPrint('⚠️ Unrecognized URL format, skipping');
}
} catch (e, stackTrace) {
allSuccess = false;
debugPrint('❌ Error processing photo: $e');
debugPrint('Stack trace: $stackTrace');
}
}
debugPrint('\n📊 Photo update complete');
debugPrint('✅ Success: $allSuccess');
debugPrint(
'📸 Processed: $processedCount/${uniqueFotoUrls.length} unique photos',
);
return allSuccess && processedCount > 0;
} catch (e) {
debugPrint('❌ Error updating foto aset: $e');
debugPrint('Stack trace: ${StackTrace.current}');
return false;
}
}
// Retrieve bookings for a specific asset on a specific date
Future<List<Map<String, dynamic>>> getAsetBookings(
String asetId,
@ -1061,7 +1468,9 @@ class AsetProvider extends GetxService {
.order('created_at');
if (response != null && response.isNotEmpty) {
return response.map<String>((item) => item['foto_aset'] as String).toList();
return response
.map<String>((item) => item['foto_aset'] as String)
.toList();
}
return [];
} catch (e) {
@ -1095,16 +1504,22 @@ class AsetProvider extends GetxService {
}
if (response.isEmpty) {
debugPrint(' [INFO] No items found in paket_item for paket ID: $paketId');
debugPrint(
' [INFO] No items found in paket_item for paket ID: $paketId',
);
return [];
}
debugPrint('✅ [SUCCESS] Found ${response.length} items in paket_item');
debugPrint(
'✅ [SUCCESS] Found ${response.length} items in paket_item',
);
final List<Map<String, dynamic>> enrichedItems = [];
// Process each item to fetch additional details
debugPrint('🔄 [3/3] Processing ${response.length} items to fetch asset details');
debugPrint(
'🔄 [3/3] Processing ${response.length} items to fetch asset details',
);
for (var item in response) {
final String? asetId = item['aset_id']?.toString();
@ -1123,13 +1538,16 @@ class AsetProvider extends GetxService {
try {
// 1. Get asset name from aset table
debugPrint(' - Querying aset table for id: $asetId');
final asetResponse = await client
final asetResponse =
await client
.from('aset')
.select('id, nama, deskripsi')
.eq('id', asetId)
.maybeSingle();
debugPrint(' - Aset response: ${asetResponse?.toString() ?? 'null'}');
debugPrint(
' - Aset response: ${asetResponse?.toString() ?? 'null'}',
);
if (asetResponse == null) {
debugPrint('⚠️ [WARNING] No asset found with id: $asetId');
@ -1139,7 +1557,7 @@ class AsetProvider extends GetxService {
'nama_aset': 'Item tidak diketahui',
'foto_aset': '',
'semua_foto': <String>[],
'error': 'Asset not found'
'error': 'Asset not found',
});
continue;
}
@ -1173,15 +1591,18 @@ class AsetProvider extends GetxService {
final enrichedItem = {
'aset_id': asetId,
'kuantitas': kuantitas,
'nama_aset': asetResponse['nama']?.toString() ?? 'Nama tidak tersedia',
'nama_aset':
asetResponse['nama']?.toString() ?? 'Nama tidak tersedia',
'foto_aset': fotoUtama,
'semua_foto': semuaFoto,
'debug': {
'aset_query': asetResponse,
'foto_count': semuaFoto.length
}
'foto_count': semuaFoto.length,
},
};
debugPrint('✅ [ENRICHED ITEM] $enrichedItem');
enrichedItems.add(enrichedItem);
// Debug log
@ -1193,7 +1614,6 @@ class AsetProvider extends GetxService {
if (semuaFoto.isNotEmpty) {
debugPrint(' - Foto Utama: ${semuaFoto.first}');
}
} catch (e) {
debugPrint('❌ Error processing asset $asetId: $e');
// Still add the basic item even if we couldn't fetch additional details
@ -1207,9 +1627,13 @@ class AsetProvider extends GetxService {
}
}
debugPrint('✅ Successfully fetched ${enrichedItems.length} items with details');
debugPrint(
'✅ Successfully fetched ${enrichedItems.length} items with details:',
);
for (var item in enrichedItems) {
debugPrint(' - $item');
}
return enrichedItems;
} catch (e, stackTrace) {
debugPrint('❌ Error getting package items for paket $paketId: $e');
debugPrint('Stack trace: $stackTrace');
@ -1221,10 +1645,9 @@ class AsetProvider extends GetxService {
Future<List<Map<String, dynamic>>> getBankAccounts() async {
try {
final response = await client
.from('bank_accounts')
.from('akun_bank')
.select('*')
.eq('is_active', true)
.order('bank_name');
.order('nama_bank');
if (response != null && response.isNotEmpty) {
return List<Map<String, dynamic>>.from(response);
@ -1235,4 +1658,325 @@ class AsetProvider extends GetxService {
return [];
}
}
/// Fetch all packages with their related data (photos and rental time units)
Future<List<PaketModel>> getAllPaket() async {
final stopwatch = Stopwatch()..start();
final String debugId = DateTime.now().millisecondsSinceEpoch
.toString()
.substring(8);
void log(String message, {bool isError = false, bool isSection = false}) {
final prefix =
isError
? ''
: isSection
? '📌'
: ' ';
debugPrint('[$debugId] $prefix $message');
}
try {
log('🚀 Memulai pengambilan data paket...', isSection: true);
log('📡 Mengambil data paket dari database...');
// 1) Get all packages
final paketResponse = await client
.from('paket')
.select('*')
.order('created_at', ascending: false);
log('📥 Diterima ${paketResponse.length} paket dari database');
if (paketResponse.isEmpty) {
log(' Tidak ada paket yang ditemukan');
return [];
}
// Convert to list of PaketModel (without relations yet)
log('\n🔍 Memproses data paket...');
final List<PaketModel> paketList = [];
int successCount = 0;
for (var p in paketResponse) {
try {
final paket = PaketModel.fromMap(p as Map<String, dynamic>);
paketList.add(paket);
successCount++;
log(' ✅ Berhasil memproses paket: ${paket.id} - ${paket.nama}');
} catch (e) {
log('⚠️ Gagal memproses paket: $e', isError: true);
log(' Data paket: $p');
}
}
log('\n📊 Ringkasan Pemrosesan:');
log(' - Total data: ${paketResponse.length}');
log(' - Berhasil: $successCount');
log(' - Gagal: ${paketResponse.length - successCount}');
if (paketList.isEmpty) {
log(' Tidak ada paket yang valid setelah diproses');
return [];
}
// Kumpulkan semua ID paket
final List<String> paketIds = paketList.map((p) => p.id).toList();
log('\n📦 Mengambil data tambahan untuk ${paketList.length} paket...');
log(' ID Paket: ${paketIds.join(', ')}');
// 2) Ambil semua foto untuk paket-paket ini
log('\n🖼️ Mengambil data foto...');
final fotoResp = await client
.from('foto_aset')
.select('id_paket, foto_aset')
.inFilter('id_paket', paketIds);
log(' Ditemukan ${fotoResp.length} foto');
// Map packageId -> List<String> photos
final Map<String, List<String>> mapFoto = {};
int fotoCount = 0;
for (var row in fotoResp) {
try {
final pid = row['id_paket']?.toString() ?? '';
final url = row['foto_aset']?.toString() ?? '';
if (pid.isNotEmpty && url.isNotEmpty) {
mapFoto.putIfAbsent(pid, () => []).add(url);
fotoCount++;
} else {
log(' ⚠️ Data foto tidak valid: ${row.toString()}');
}
} catch (e) {
log('⚠️ Gagal memproses data foto: $e', isError: true);
}
}
log(' Berhasil memetakan $fotoCount foto ke ${mapFoto.length} paket');
// 3) Get all satuan_waktu_sewa for these packages
log('\n⏱️ Mengambil data satuan waktu sewa...');
final swsResp = await client
.from('satuan_waktu_sewa')
.select('paket_id, satuan_waktu_id, harga, maksimal_waktu')
.inFilter('paket_id', paketIds);
log(' Ditemukan ${swsResp.length} entri satuan waktu sewa');
// Process satuan waktu sewa
final Map<String, List<Map<String, dynamic>>> paketSatuanWaktu = {};
int swsCount = 0;
for (var row in swsResp) {
try {
final pid = row['paket_id']?.toString() ?? '';
if (pid.isNotEmpty) {
final swsData = {
'satuan_waktu_id': row['satuan_waktu_id'],
'harga': row['harga'],
'maksimal_waktu': row['maksimal_waktu'],
};
paketSatuanWaktu.putIfAbsent(pid, () => []).add(swsData);
swsCount++;
}
} catch (e) {
log('⚠️ Gagal memproses satuan waktu sewa: $e', isError: true);
log(' Data: $row');
}
}
log(
' Berhasil memetakan $swsCount satuan waktu ke ${paketSatuanWaktu.length} paket',
);
// 4) Gabungkan semua data
log('\n🔗 Menggabungkan data...');
final List<PaketModel> result = [];
int combinedCount = 0;
for (var paket in paketList) {
final pid = paket.id;
log('\n📦 Memproses paket: ${paket.nama} ($pid)');
try {
final updatedPaket = paket.copyWith();
// Lampirkan foto
if (mapFoto.containsKey(pid)) {
final fotoList = mapFoto[pid]!;
updatedPaket.images = List<String>.from(fotoList);
// Set foto utama jika belum ada
if (updatedPaket.images!.isNotEmpty &&
updatedPaket.foto_paket == null) {
updatedPaket.foto_paket = updatedPaket.images!.first;
log(' 📷 Menambahkan ${fotoList.length} foto');
log(' 🖼️ Foto utama: ${updatedPaket.foto_paket}');
}
} else {
log(' Tidak ada foto untuk paket ini');
}
// Lampirkan satuan waktu sewa
if (paketSatuanWaktu.containsKey(pid)) {
final swsList = List<Map<String, dynamic>>.from(
paketSatuanWaktu[pid] ?? [],
);
updatedPaket.satuanWaktuSewa = swsList;
log(' ⏱️ Menambahkan ${swsList.length} satuan waktu sewa');
// Log detail harga
for (var sws in swsList.take(2)) {
// Tampilkan maksimal 2 harga
log(
' - ${sws['harga']} / satuan waktu (ID: ${sws['satuan_waktu_id']})',
);
}
if (swsList.length > 2) {
log(' - ...dan ${swsList.length - 2} lainnya');
}
} else {
log(' Tidak ada satuan waktu sewa untuk paket ini');
}
result.add(updatedPaket);
combinedCount++;
log(' ✅ Berhasil memproses paket $pid');
} catch (e) {
log('⚠️ Gagal memproses paket $pid: $e', isError: true);
// Tetap tambahkan paket asli jika gagal diproses
result.add(paket);
}
}
// Ringkasan eksekusi
stopwatch.stop();
log('\n🎉 Selesai!', isSection: true);
log('📊 Ringkasan Eksekusi:');
log(' - Total paket: ${paketList.length}');
log(' - Berhasil diproses: $combinedCount/${paketList.length}');
log(' - Total foto: $fotoCount');
log(' - Total satuan waktu: $swsCount');
log(' - Waktu eksekusi: ${stopwatch.elapsedMilliseconds}ms');
log(' - ID Debug: $debugId');
return result;
} catch (e, stackTrace) {
log('\n❌ ERROR KRITIS', isError: true);
log('Pesan error: $e', isError: true);
log('Stack trace: $stackTrace', isError: true);
log('ID Debug: $debugId', isError: true);
rethrow;
debugPrint('❌ [getAllPaket] Error: $e');
debugPrint('Stack trace: $stackTrace');
rethrow;
}
}
// Update tagihan_dibayar and insert pembayaran
Future<bool> processPembayaranTagihan({
required String tagihanSewaId,
required int nominal,
required String metodePembayaran,
}) async {
try {
// 1. Get current tagihan_dibayar
final tagihan =
await client
.from('tagihan_sewa')
.select('tagihan_dibayar')
.eq('id', tagihanSewaId)
.maybeSingle();
int currentDibayar = 0;
if (tagihan != null && tagihan['tagihan_dibayar'] != null) {
currentDibayar =
int.tryParse(tagihan['tagihan_dibayar'].toString()) ?? 0;
}
final newDibayar = currentDibayar + nominal;
// 2. Update tagihan_dibayar
await client
.from('tagihan_sewa')
.update({'tagihan_dibayar': newDibayar})
.eq('id', tagihanSewaId);
// 3. Insert pembayaran
final authProvider = Get.find<AuthProvider>();
final idPetugas = authProvider.getCurrentUserId();
final pembayaranData = {
'tagihan_sewa_id': tagihanSewaId,
'metode_pembayaran': metodePembayaran,
'total_pembayaran': nominal,
'status': 'lunas',
'created_at': DateTime.now().toIso8601String(),
'id_petugas': idPetugas,
};
await client.from('pembayaran').insert(pembayaranData);
return true;
} catch (e) {
debugPrint('❌ Error processing pembayaran tagihan: $e');
return false;
}
}
// Update status of sewa_aset by ID
Future<bool> updateSewaAsetStatus({
required String sewaAsetId,
required String status,
}) async {
try {
debugPrint('🔄 Updating status of sewa_aset ID: $sewaAsetId to $status');
final response = await client
.from('sewa_aset')
.update({'status': status})
.eq('id', sewaAsetId);
debugPrint('✅ Status updated for sewa_aset ID: $sewaAsetId');
return true;
} catch (e) {
debugPrint('❌ Error updating sewa_aset status: $e');
return false;
}
}
// Get all payment proof image URLs for a sewa_aset (by tagihan_sewa)
Future<List<String>> getFotoPembayaranUrlsByTagihanSewaId(
String sewaAsetId,
) async {
try {
// 1. Get tagihan_sewa by sewaAsetId
final tagihan = await getTagihanSewa(sewaAsetId);
if (tagihan == null || tagihan['id'] == null) return [];
final tagihanSewaId = tagihan['id'];
// 2. Fetch all foto_pembayaran for this tagihan_sewa_id
final List<dynamic> response = await client
.from('foto_pembayaran')
.select('foto_pembayaran')
.eq('tagihan_sewa_id', tagihanSewaId)
.order('created_at', ascending: false);
// 3. Extract URLs
return response
.map<String>((row) => row['foto_pembayaran']?.toString() ?? '')
.where((url) => url.isNotEmpty)
.toList();
} catch (e) {
debugPrint('❌ Error fetching foto pembayaran: $e');
return [];
}
}
Future<int> countSewaAsetByStatus(List<String> statuses) async {
// Supabase expects the IN filter as a comma-separated string in parentheses
final statusString = '(${statuses.map((s) => '"$s"').join(',')})';
final response = await client
.from('sewa_aset')
.select('id')
.filter('status', 'in', statusString);
if (response is List) {
return response.length;
}
return 0;
}
}

10
lib/app/main.dart Normal file
View File

@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:bumrent_app/app/data/providers/aset_provider.dart';
import 'package:bumrent_app/main.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
Get.put(AsetProvider());
runApp(const MyApp());
}

View File

@ -8,6 +8,10 @@ class AuthController extends GetxController {
final emailController = TextEditingController();
final passwordController = TextEditingController();
final formKey = GlobalKey<FormState>();
final nameController = TextEditingController();
final confirmPasswordController = TextEditingController();
final RxBool isConfirmPasswordVisible = false.obs;
// Form fields for registration
final RxString email = ''.obs;
@ -15,6 +19,7 @@ class AuthController extends GetxController {
final RxString nik = ''.obs;
final RxString phoneNumber = ''.obs;
final RxString selectedRole = 'WARGA'.obs; // Default role
final RxString alamatLengkap = ''.obs;
// Form status
final RxBool isLoading = false.obs;
@ -28,6 +33,10 @@ class AuthController extends GetxController {
isPasswordVisible.value = !isPasswordVisible.value;
}
void toggleConfirmPasswordVisibility() {
isConfirmPasswordVisible.value = !isConfirmPasswordVisible.value;
}
// Change role selection
void setRole(String? role) {
if (role != null) {
@ -172,6 +181,8 @@ class AuthController extends GetxController {
void onClose() {
emailController.dispose();
passwordController.dispose();
nameController.dispose();
confirmPasswordController.dispose();
super.onClose();
}
@ -181,7 +192,8 @@ class AuthController extends GetxController {
if (email.value.isEmpty ||
password.value.isEmpty ||
nik.value.isEmpty ||
phoneNumber.value.isEmpty) {
phoneNumber.value.isEmpty ||
alamatLengkap.value.isEmpty) {
errorMessage.value = 'Semua field harus diisi';
return;
}
@ -222,6 +234,7 @@ class AuthController extends GetxController {
data: {
'nik': nik.value.trim(),
'phone_number': phoneNumber.value.trim(),
'alamat_lengkap': alamatLengkap.value.trim(),
'role': selectedRole.value,
},
);

View File

@ -26,12 +26,8 @@ class ForgotPasswordView extends GetView<AuthController> {
Opacity(
opacity: 0.03,
child: Container(
decoration: const BoxDecoration(
image: DecorationImage(
image: AssetImage('assets/images/pattern.png'),
repeat: ImageRepeat.repeat,
scale: 4.0,
),
decoration: BoxDecoration(
color: Colors.blue[50], // Temporary solid color
),
),
),

View File

@ -30,12 +30,8 @@ class LoginView extends GetView<AuthController> {
Opacity(
opacity: 0.03,
child: Container(
decoration: const BoxDecoration(
image: DecorationImage(
image: AssetImage('assets/images/pattern.png'),
repeat: ImageRepeat.repeat,
scale: 4.0,
),
decoration: BoxDecoration(
color: Colors.blue[50], // Temporary solid color
),
),
),
@ -89,7 +85,6 @@ class LoginView extends GetView<AuthController> {
_buildHeader(),
const SizedBox(height: 40),
_buildLoginCard(),
const SizedBox(height: 24),
_buildRegisterLink(),
const SizedBox(height: 30),
],
@ -161,7 +156,7 @@ class LoginView extends GetView<AuthController> {
prefixIcon: Icons.email_outlined,
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 24),
const SizedBox(height: 12),
// Password field
_buildInputLabel('Password'),
@ -204,7 +199,6 @@ class LoginView extends GetView<AuthController> {
),
),
),
const SizedBox(height: 32),
// Login button
Obx(

View File

@ -187,7 +187,7 @@ class RegistrationView extends GetView<AuthController> {
),
const SizedBox(height: 4),
Text(
'Pendaftaran hanya dapat dilakukan oleh warga dan mitra yang sudah terverivikasi. Silahkan hubungi petugas atau kunjungi kantor untuk informasi lebih lanjut.',
'Setelah pendaftaran lengkapi data diri untuk dapat melakukan sewa',
style: TextStyle(
fontSize: 13,
color: AppColors.textPrimary,
@ -203,121 +203,32 @@ class RegistrationView extends GetView<AuthController> {
}
Widget _buildRegistrationForm() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
return Form(
key: controller.formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Email Input
_buildInputLabel('Email'),
const SizedBox(height: 8),
_buildEmailField(),
const SizedBox(height: 20),
// Password Input
const SizedBox(height: 16),
_buildInputLabel('Password'),
const SizedBox(height: 8),
_buildPasswordField(),
const SizedBox(height: 20),
// NIK Input
_buildInputLabel('NIK'),
const SizedBox(height: 8),
_buildNikField(),
const SizedBox(height: 20),
// Phone Number Input
_buildInputLabel('No. Hp'),
const SizedBox(height: 8),
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: 20),
// Role Selection Dropdown
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Daftar Sebagai',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey[300]!, width: 1),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Obx(
() => DropdownButtonHideUnderline(
child: DropdownButton<String>(
isExpanded: true,
value: controller.selectedRole.value,
hint: const Text('Pilih Peran'),
items: [
DropdownMenuItem(
value: 'WARGA',
child: const Text('Warga'),
),
DropdownMenuItem(
value: 'PETUGAS_MITRA',
child: const Text('Mitra'),
),
],
onChanged: (value) {
controller.setRole(value);
},
icon: const Icon(Icons.arrow_drop_down),
style: const TextStyle(
color: Colors.black87,
fontSize: 14,
),
),
),
),
),
),
const SizedBox(height: 16),
_buildInputLabel('Alamat Lengkap'),
_buildAlamatField(),
const SizedBox(height: 16),
// Removed: NIK, No HP, and Dropdown Daftar Sebagai
],
),
const SizedBox(height: 20),
// Error message
Obx(
() =>
controller.errorMessage.value.isNotEmpty
? Container(
margin: const EdgeInsets.only(top: 8),
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(),
),
],
);
}
@ -415,78 +326,101 @@ class RegistrationView extends GetView<AuthController> {
);
}
Widget _buildNikField() {
return 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.nik.value = value,
keyboardType: TextInputType.number,
style: TextStyle(fontSize: 16, color: AppColors.textPrimary),
Widget _buildConfirmPasswordField() {
return Obx(
() => TextFormField(
controller: controller.confirmPasswordController,
obscureText: !controller.isConfirmPasswordVisible.value,
decoration: InputDecoration(
hintText: 'Masukkan NIK anda',
hintStyle: TextStyle(color: AppColors.textLight),
prefixIcon: Icon(
Icons.credit_card_outlined,
color: AppColors.primary,
hintText: 'Masukkan ulang password anda',
suffixIcon: IconButton(
icon: Icon(
controller.isConfirmPasswordVisible.value
? Icons.visibility
: Icons.visibility_off,
),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(vertical: 16),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
onPressed: controller.toggleConfirmPasswordVisibility,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: AppColors.primary, width: 1.5),
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 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.phoneNumber.value = value,
return TextFormField(
keyboardType: TextInputType.phone,
style: TextStyle(fontSize: 16, color: AppColors.textPrimary),
decoration: InputDecoration(
hintText: 'Masukkan nomor HP anda',
hintStyle: TextStyle(color: AppColors.textLight),
prefixIcon: Icon(Icons.phone_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),
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;
},
);
}

View File

@ -1,6 +1,7 @@
import 'package:get/get.dart';
import '../controllers/petugas_aset_controller.dart';
import '../controllers/petugas_bumdes_dashboard_controller.dart';
import '../../../data/providers/aset_provider.dart';
class PetugasAsetBinding extends Bindings {
@override
@ -10,6 +11,7 @@ class PetugasAsetBinding extends Bindings {
Get.put(PetugasBumdesDashboardController(), permanent: true);
}
Get.lazyPut<AsetProvider>(() => AsetProvider());
Get.lazyPut<PetugasAsetController>(() => PetugasAsetController());
}
}

View File

@ -1,9 +1,14 @@
import 'package:get/get.dart';
import '../controllers/petugas_sewa_controller.dart';
import '../../../data/providers/aset_provider.dart';
class PetugasDetailSewaBinding extends Bindings {
@override
void dependencies() {
// Ensure AsetProvider is registered
if (!Get.isRegistered<AsetProvider>()) {
Get.put(AsetProvider(), permanent: true);
}
// Memastikan controller sudah tersedia
Get.lazyPut<PetugasSewaController>(
() => PetugasSewaController(),

View File

@ -1,15 +1,25 @@
import 'package:get/get.dart';
import 'package:bumrent_app/app/data/providers/aset_provider.dart';
import '../controllers/petugas_paket_controller.dart';
import '../controllers/petugas_bumdes_dashboard_controller.dart';
class PetugasPaketBinding extends Bindings {
@override
void dependencies() {
// Register AsetProvider first
if (!Get.isRegistered<AsetProvider>()) {
Get.put(AsetProvider(), permanent: true);
}
// Ensure dashboard controller is registered
if (!Get.isRegistered<PetugasBumdesDashboardController>()) {
Get.put(PetugasBumdesDashboardController(), permanent: true);
}
Get.lazyPut<PetugasPaketController>(() => PetugasPaketController());
// Register the controller
Get.lazyPut<PetugasPaketController>(
() => PetugasPaketController(),
fenix: true,
);
}
}

View File

@ -1,9 +1,14 @@
import 'package:get/get.dart';
import '../controllers/petugas_sewa_controller.dart';
import '../../../data/providers/aset_provider.dart';
class PetugasSewaBinding extends Bindings {
@override
void dependencies() {
// Ensure AsetProvider is registered
if (!Get.isRegistered<AsetProvider>()) {
Get.put(AsetProvider(), permanent: true);
}
Get.lazyPut<PetugasSewaController>(() => PetugasSewaController());
}
}

View File

@ -1,6 +1,11 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../data/providers/aset_provider.dart';
import '../../../data/models/aset_model.dart';
class PetugasAsetController extends GetxController {
final AsetProvider _asetProvider = Get.find<AsetProvider>();
// Observable lists for asset data
final asetList = <Map<String, dynamic>>[].obs;
final filteredAsetList = <Map<String, dynamic>>[].obs;
@ -27,95 +32,100 @@ class PetugasAsetController extends GetxController {
loadAsetData();
}
// Load sample asset data (would be replaced with API call in production)
// Load asset data from AsetProvider
Future<void> loadAsetData() async {
isLoading.value = true;
try {
// Simulate API call with a delay
await Future.delayed(const Duration(seconds: 1));
isLoading.value = true;
debugPrint('PetugasAsetController: Starting to load asset data...');
// Sample assets data
final sampleData = [
{
'id': '1',
'nama': 'Meja Rapat',
'kategori': 'Furniture',
'jenis': 'Sewa', // Added jenis field
'harga': 50000,
'satuan': 'per hari',
'stok': 10,
'deskripsi':
'Meja rapat kayu jati ukuran besar untuk acara pertemuan',
'gambar': 'https://example.com/meja.jpg',
'tersedia': true,
},
{
'id': '2',
'nama': 'Kursi Taman',
'kategori': 'Furniture',
'jenis': 'Sewa', // Added jenis field
'harga': 10000,
'satuan': 'per hari',
'stok': 50,
'deskripsi': 'Kursi taman plastik yang nyaman untuk acara outdoor',
'gambar': 'https://example.com/kursi.jpg',
'tersedia': true,
},
{
'id': '3',
'nama': 'Proyektor',
'kategori': 'Elektronik',
'jenis': 'Sewa', // Added jenis field
'harga': 100000,
'satuan': 'per hari',
'stok': 5,
'deskripsi': 'Proyektor HD dengan brightness tinggi',
'gambar': 'https://example.com/proyektor.jpg',
'tersedia': true,
},
{
'id': '4',
'nama': 'Sound System',
'kategori': 'Elektronik',
'jenis': 'Langganan', // Added jenis field
'harga': 200000,
'satuan': 'per bulan',
'stok': 3,
'deskripsi': 'Sound system lengkap dengan speaker dan mixer',
'gambar': 'https://example.com/sound.jpg',
'tersedia': false,
},
{
'id': '5',
'nama': 'Mobil Pick Up',
'kategori': 'Kendaraan',
'jenis': 'Langganan', // Added jenis field
'harga': 250000,
'satuan': 'per bulan',
'stok': 2,
'deskripsi': 'Mobil pick up untuk mengangkut barang',
'gambar': 'https://example.com/pickup.jpg',
'tersedia': true,
},
{
'id': '6',
'nama': 'Internet Fiber',
'kategori': 'Elektronik',
'jenis': 'Langganan', // Added jenis field
'harga': 350000,
'satuan': 'per bulan',
'stok': 15,
'deskripsi': 'Paket internet fiber 100Mbps untuk kantor',
'gambar': 'https://example.com/internet.jpg',
'tersedia': true,
},
];
// Fetch data using AsetProvider
final asetData = await _asetProvider.getSewaAsets();
debugPrint(
'PetugasAsetController: Fetched ${asetData.length} assets from Supabase',
);
asetList.assignAll(sampleData);
applyFilters(); // Apply default filters
} catch (e) {
print('Error loading asset data: $e');
if (asetData.isEmpty) {
debugPrint('PetugasAsetController: No assets found in Supabase');
}
final List<Map<String, dynamic>> mappedAsets = [];
int index = 0; // Initialize index counter
for (var aset in asetData) {
String displayKategori = 'Umum'; // Placeholder for descriptive category
// Attempt to derive a more specific category from description if needed, or add to AsetModel
if (aset.deskripsi.toLowerCase().contains('meja') ||
aset.deskripsi.toLowerCase().contains('kursi')) {
displayKategori = 'Furniture';
} else if (aset.deskripsi.toLowerCase().contains('proyektor') ||
aset.deskripsi.toLowerCase().contains('sound') ||
aset.deskripsi.toLowerCase().contains('internet')) {
displayKategori = 'Elektronik';
} else if (aset.deskripsi.toLowerCase().contains('mobil') ||
aset.deskripsi.toLowerCase().contains('kendaraan')) {
displayKategori = 'Kendaraan';
}
final map = {
'id': aset.id,
'nama': aset.nama,
'deskripsi': aset.deskripsi,
'harga':
aset.satuanWaktuSewa.isNotEmpty
? aset.satuanWaktuSewa.first['harga']
: 0,
'status': aset.status,
'kategori': displayKategori,
'jenis': aset.jenis ?? 'Sewa', // Add this line with default value
'imageUrl': aset.imageUrl ?? 'https://via.placeholder.com/150',
'satuan_waktu':
aset.satuanWaktuSewa.isNotEmpty
? aset.satuanWaktuSewa.first['nama_satuan_waktu'] ?? 'Hari'
: 'Hari',
'satuanWaktuSewa': aset.satuanWaktuSewa.toList(),
};
debugPrint('Mapped asset #$index: $map');
mappedAsets.add(map);
index++;
debugPrint('Deskripsi: ${aset.deskripsi}');
debugPrint('Kategori (from AsetModel): ${aset.kategori}');
debugPrint('Status: ${aset.status}');
debugPrint('Mapped Kategori for Petugas View: ${map['kategori']}');
debugPrint('Mapped Jenis for Petugas View: ${map['jenis']}');
debugPrint('--------------------------------');
}
// Populate asetList with fetched data and apply filters
debugPrint(
'PetugasAsetController: Mapped ${mappedAsets.length} assets for display',
);
asetList.assignAll(mappedAsets); // Make data available to UI
debugPrint(
'PetugasAsetController: asetList now has ${asetList.length} items',
);
applyFilters(); // Apply initial filters
debugPrint(
'PetugasAsetController: Applied filters. filteredAsetList has ${filteredAsetList.length} items',
);
debugPrint(
'PetugasAsetController: Data loading complete. Asset list populated and filters applied.',
);
debugPrint(
'PetugasAsetController: First asset name: ${mappedAsets.isNotEmpty ? mappedAsets[0]['nama'] : 'No assets'}',
);
} catch (e, stackTrace) {
debugPrint('PetugasAsetController: Error loading asset data: $e');
debugPrint('PetugasAsetController: StackTrace: $stackTrace');
// Optionally, show a snackbar or error message to the user
Get.snackbar(
'Error Memuat Data',
'Gagal mengambil data aset dari server. Silakan coba lagi nanti.',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
} finally {
isLoading.value = false;
}
@ -170,8 +180,10 @@ class PetugasAsetController extends GetxController {
}
// Change tab (Sewa or Langganan)
void changeTab(int index) {
Future<void> changeTab(int index) async {
selectedTabIndex.value = index;
// Reload data when changing tabs to ensure we have the correct data for the selected tab
await loadAsetData();
applyFilters();
}

View File

@ -1,6 +1,10 @@
import 'package:get/get.dart';
import '../../../data/providers/auth_provider.dart';
import '../../../routes/app_routes.dart';
import '../../../services/sewa_service.dart';
import '../../../services/service_manager.dart';
import '../../../data/models/pembayaran_model.dart';
import '../../../services/pembayaran_service.dart';
class PetugasBumdesDashboardController extends GetxController {
AuthProvider? _authProvider;
@ -8,6 +12,8 @@ class PetugasBumdesDashboardController extends GetxController {
// Reactive variables
final userEmail = ''.obs;
final currentTabIndex = 0.obs;
final avatarUrl = ''.obs;
final userName = ''.obs;
// Revenue Statistics
final totalPendapatanBulanIni = 'Rp 8.500.000'.obs;
@ -20,7 +26,7 @@ class PetugasBumdesDashboardController extends GetxController {
final persentaseSewa = 100.obs;
// Revenue Trends (last 6 months)
final trendPendapatan = [4.2, 5.1, 4.8, 6.2, 7.2, 8.5].obs; // in millions
final trendPendapatan = <double>[].obs; // 6 bulan terakhir
// Status Counters for Sewa Aset
final terlaksanaCount = 5.obs;
@ -43,42 +49,128 @@ class PetugasBumdesDashboardController extends GetxController {
final tagihanAktifCountSewa = 7.obs;
final periksaPembayaranCountSewa = 2.obs;
// Statistik pendapatan
final totalPendapatan = 0.obs;
final pendapatanBulanIni = 0.obs;
final pendapatanBulanLalu = 0.obs;
final pendapatanTunai = 0.obs;
final pendapatanTransfer = 0.obs;
final trenPendapatan = <int>[].obs; // 6 bulan terakhir
// Dashboard statistics
final pembayaranStats = <String, dynamic>{}.obs;
final isStatsLoading = true.obs;
@override
void onInit() {
super.onInit();
try {
_authProvider = Get.find<AuthProvider>();
userEmail.value = _authProvider?.currentUser?.email ?? 'Tidak ada email';
fetchPetugasAvatar();
fetchPetugasName();
} catch (e) {
print('Error finding AuthProvider: $e');
userEmail.value = 'Tidak ada email';
}
// In a real app, these counts would be fetched from backend
// loadStatusCounts();
print('✅ PetugasBumdesDashboardController initialized successfully');
print('\u2705 PetugasBumdesDashboardController initialized successfully');
countSewaByStatus();
fetchPembayaranStats();
}
// Method to load status counts from backend
// Future<void> loadStatusCounts() async {
// try {
// final response = await _asetProvider.getSewaStatusCounts();
// if (response != null) {
// terlaksanaCount.value = response['terlaksana'] ?? 0;
// dijadwalkanCount.value = response['dijadwalkan'] ?? 0;
// aktifCount.value = response['aktif'] ?? 0;
// dibatalkanCount.value = response['dibatalkan'] ?? 0;
// menungguPembayaranCount.value = response['menunggu_pembayaran'] ?? 0;
// periksaPembayaranCount.value = response['periksa_pembayaran'] ?? 0;
// diterimaCount.value = response['diterima'] ?? 0;
// pembayaranDendaCount.value = response['pembayaran_denda'] ?? 0;
// periksaPembayaranDendaCount.value = response['periksa_pembayaran_denda'] ?? 0;
// selesaiCount.value = response['selesai'] ?? 0;
// }
// } catch (e) {
// print('Error loading status counts: $e');
// }
// }
Future<void> countSewaByStatus() async {
try {
final data = await SewaService().fetchAllSewa();
menungguPembayaranCount.value =
data.where((s) => s.status == 'MENUNGGU PEMBAYARAN').length;
periksaPembayaranCount.value =
data.where((s) => s.status == 'PERIKSA PEMBAYARAN').length;
diterimaCount.value = data.where((s) => s.status == 'DITERIMA').length;
pembayaranDendaCount.value =
data.where((s) => s.status == 'PEMBAYARAN DENDA').length;
periksaPembayaranDendaCount.value =
data.where((s) => s.status == 'PERIKSA PEMBAYARAN DENDA').length;
selesaiCount.value = data.where((s) => s.status == 'SELESAI').length;
print(
'Count for MENUNGGU PEMBAYARAN: \\${menungguPembayaranCount.value}',
);
print('Count for PERIKSA PEMBAYARAN: \\${periksaPembayaranCount.value}');
print('Count for DITERIMA: \\${diterimaCount.value}');
print('Count for PEMBAYARAN DENDA: \\${pembayaranDendaCount.value}');
print(
'Count for PERIKSA PEMBAYARAN DENDA: \\${periksaPembayaranDendaCount.value}',
);
print('Count for SELESAI: \\${selesaiCount.value}');
} catch (e) {
print('Error counting sewa by status: $e');
}
}
Future<void> fetchPembayaranStats() async {
isStatsLoading.value = true;
try {
final stats = await PembayaranService().fetchStats();
pembayaranStats.value = stats;
// Set trendPendapatan from stats['trendPerMonth'] if available
if (stats['trendPerMonth'] != null) {
trendPendapatan.value = List<double>.from(stats['trendPerMonth']);
}
print('Pembayaran stats: $stats');
} catch (e, st) {
print('Error fetching pembayaran stats: $e\n$st');
pembayaranStats.value = {};
trendPendapatan.value = [];
}
isStatsLoading.value = false;
}
Future<void> fetchPetugasAvatar() async {
try {
final userId = _authProvider?.getCurrentUserId();
if (userId == null) return;
final client = _authProvider!.client;
final data =
await client
.from('petugas_bumdes')
.select('avatar')
.eq('id', userId)
.maybeSingle();
if (data != null &&
data['avatar'] != null &&
data['avatar'].toString().isNotEmpty) {
avatarUrl.value = data['avatar'].toString();
} else {
avatarUrl.value = '';
}
} catch (e) {
print('Error fetching petugas avatar: $e');
avatarUrl.value = '';
}
}
Future<void> fetchPetugasName() async {
try {
final userId = _authProvider?.getCurrentUserId();
if (userId == null) return;
final client = _authProvider!.client;
final data =
await client
.from('petugas_bumdes')
.select('nama')
.eq('id', userId)
.maybeSingle();
if (data != null &&
data['nama'] != null &&
data['nama'].toString().isNotEmpty) {
userName.value = data['nama'].toString();
} else {
userName.value = '';
}
} catch (e) {
print('Error fetching petugas name: $e');
userName.value = '';
}
}
void changeTab(int index) {
try {

View File

@ -1,24 +1,24 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:intl/intl.dart' show NumberFormat;
import 'package:logger/logger.dart';
import 'package:bumrent_app/app/data/models/paket_model.dart';
import 'package:bumrent_app/app/data/providers/aset_provider.dart';
class PetugasPaketController extends GetxController {
final isLoading = false.obs;
final searchQuery = ''.obs;
final selectedCategory = 'Semua'.obs;
final sortBy = 'Terbaru'.obs;
// Dependencies
final AsetProvider _asetProvider = Get.find<AsetProvider>();
// Kategori untuk filter
final categories = <String>[
'Semua',
'Pesta',
'Rapat',
'Olahraga',
'Pernikahan',
'Lainnya',
];
// State
final RxBool isLoading = false.obs;
final RxString searchQuery = ''.obs;
final RxString selectedCategory = 'Semua'.obs;
final RxString sortBy = 'Terbaru'.obs;
final RxList<PaketModel> packages = <PaketModel>[].obs;
final RxList<PaketModel> filteredPackages = <PaketModel>[].obs;
// Opsi pengurutan
final sortOptions = <String>[
// Sort options for the dropdown
final List<String> sortOptions = [
'Terbaru',
'Terlama',
'Harga Tertinggi',
@ -27,172 +27,218 @@ class PetugasPaketController extends GetxController {
'Nama Z-A',
];
// Data dummy paket
final paketList = <Map<String, dynamic>>[].obs;
final filteredPaketList = <Map<String, dynamic>>[].obs;
// For backward compatibility
final RxList<Map<String, dynamic>> paketList = <Map<String, dynamic>>[].obs;
final RxList<Map<String, dynamic>> filteredPaketList = <Map<String, dynamic>>[].obs;
// Logger
late final Logger _logger;
@override
void onInit() {
super.onInit();
loadPaketData();
}
// Format harga ke Rupiah
String formatPrice(int price) {
final formatter = NumberFormat.currency(
locale: 'id',
symbol: 'Rp ',
decimalDigits: 0,
// Initialize logger
_logger = Logger(
printer: PrettyPrinter(
methodCount: 0,
errorMethodCount: 5,
colors: true,
printEmojis: true,
),
);
return formatter.format(price);
// Load initial data
fetchPackages();
}
// Load data paket dummy
Future<void> loadPaketData() async {
/// Fetch packages from the API
Future<void> fetchPackages() async {
try {
isLoading.value = true;
await Future.delayed(const Duration(milliseconds: 800)); // Simulasi loading
_logger.i('🔄 [fetchPackages] Fetching packages...');
paketList.value = [
{
'id': '1',
'nama': 'Paket Pesta Ulang Tahun',
'kategori': 'Pesta',
'harga': 500000,
'deskripsi':
'Paket lengkap untuk acara ulang tahun. Termasuk 5 meja, 20 kursi, backdrop, dan sound system.',
'tersedia': true,
'created_at': '2023-08-10',
'items': [
{'nama': 'Meja Panjang', 'jumlah': 5},
{'nama': 'Kursi Plastik', 'jumlah': 20},
{'nama': 'Sound System', 'jumlah': 1},
{'nama': 'Backdrop', 'jumlah': 1},
],
'gambar': 'https://example.com/images/paket_ultah.jpg',
},
{
'id': '2',
'nama': 'Paket Rapat Sedang',
'kategori': 'Rapat',
'harga': 300000,
'deskripsi':
'Paket untuk rapat sedang. Termasuk 1 meja rapat besar, 10 kursi, proyektor, dan screen.',
'tersedia': true,
'created_at': '2023-09-05',
'items': [
{'nama': 'Meja Rapat', 'jumlah': 1},
{'nama': 'Kursi Kantor', 'jumlah': 10},
{'nama': 'Proyektor', 'jumlah': 1},
{'nama': 'Screen', 'jumlah': 1},
],
'gambar': 'https://example.com/images/paket_rapat.jpg',
},
{
'id': '3',
'nama': 'Paket Pesta Pernikahan',
'kategori': 'Pernikahan',
'harga': 1500000,
'deskripsi':
'Paket lengkap untuk acara pernikahan. Termasuk 20 meja, 100 kursi, sound system, dekorasi, dan tenda.',
'tersedia': true,
'created_at': '2023-10-12',
'items': [
{'nama': 'Meja Bundar', 'jumlah': 20},
{'nama': 'Kursi Tamu', 'jumlah': 100},
{'nama': 'Sound System Besar', 'jumlah': 1},
{'nama': 'Tenda 10x10', 'jumlah': 2},
{'nama': 'Set Dekorasi Pengantin', 'jumlah': 1},
],
'gambar': 'https://example.com/images/paket_nikah.jpg',
},
{
'id': '4',
'nama': 'Paket Olahraga Voli',
'kategori': 'Olahraga',
'harga': 200000,
'deskripsi':
'Paket perlengkapan untuk turnamen voli. Termasuk net, bola, dan tiang voli.',
'tersedia': false,
'created_at': '2023-07-22',
'items': [
{'nama': 'Net Voli', 'jumlah': 1},
{'nama': 'Bola Voli', 'jumlah': 3},
{'nama': 'Tiang Voli', 'jumlah': 2},
],
'gambar': 'https://example.com/images/paket_voli.jpg',
},
{
'id': '5',
'nama': 'Paket Pesta Anak',
'kategori': 'Pesta',
'harga': 350000,
'deskripsi':
'Paket untuk pesta ulang tahun anak-anak. Termasuk 3 meja, 15 kursi, dekorasi tema, dan sound system kecil.',
'tersedia': true,
'created_at': '2023-11-01',
'items': [
{'nama': 'Meja Anak', 'jumlah': 3},
{'nama': 'Kursi Anak', 'jumlah': 15},
{'nama': 'Set Dekorasi Tema', 'jumlah': 1},
{'nama': 'Sound System Kecil', 'jumlah': 1},
],
'gambar': 'https://example.com/images/paket_anak.jpg',
},
];
final result = await _asetProvider.getAllPaket();
filterPaket();
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');
} catch (e, stackTrace) {
_logger.e('❌ [fetchPackages] Error fetching packages',
error: e,
stackTrace: stackTrace);
Get.snackbar(
'Error',
'Gagal memuat data paket. Silakan coba lagi.',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
} finally {
isLoading.value = false;
}
// Filter paket berdasarkan search query dan kategori
void filterPaket() {
filteredPaketList.value =
paketList.where((paket) {
final matchesQuery =
paket['nama'].toString().toLowerCase().contains(
searchQuery.value.toLowerCase(),
) ||
paket['deskripsi'].toString().toLowerCase().contains(
searchQuery.value.toLowerCase(),
);
final matchesCategory =
selectedCategory.value == 'Semua' ||
paket['kategori'] == selectedCategory.value;
return matchesQuery && matchesCategory;
}).toList();
// Sort the filtered list
sortFilteredList();
}
// Sort the filtered list
/// Update legacy paketList for backward compatibility
void _updateLegacyPaketList() {
try {
_logger.d('🔄 [_updateLegacyPaketList] Updating legacy paketList...');
final List<Map<String, dynamic>> 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');
} catch (e, stackTrace) {
_logger.e('❌ [_updateLegacyPaketList] Error updating legacy list',
error: e,
stackTrace: stackTrace);
}
}
/// For backward compatibility
Future<void> 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();
// 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();
}
sortFilteredList();
_logger.d('✅ [filterPaket] Filtered to ${filteredPackages.length} packages');
} catch (e, 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':
filteredPaketList.sort(
(a, b) => b['created_at'].compareTo(a['created_at']),
);
filteredPackages.sort((a, b) => b.createdAt.compareTo(a.createdAt));
break;
case 'Terlama':
filteredPaketList.sort(
(a, b) => a['created_at'].compareTo(b['created_at']),
);
filteredPackages.sort((a, b) => a.createdAt.compareTo(b.createdAt));
break;
case 'Harga Tertinggi':
filteredPaketList.sort((a, b) => b['harga'].compareTo(a['harga']));
filteredPackages.sort((a, b) => b.harga.compareTo(a.harga));
break;
case 'Harga Terendah':
filteredPaketList.sort((a, b) => a['harga'].compareTo(b['harga']));
filteredPackages.sort((a, b) => a.harga.compareTo(b.harga));
break;
case 'Nama A-Z':
filteredPaketList.sort((a, b) => a['nama'].compareTo(b['nama']));
filteredPackages.sort((a, b) => a.nama.compareTo(b.nama));
break;
case 'Nama Z-A':
filteredPaketList.sort((a, b) => b['nama'].compareTo(a['nama']));
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));
break;
case 'Terlama':
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));
break;
case 'Harga Terendah':
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));
break;
case 'Nama Z-A':
filteredPaketList.sort((a, b) =>
((b['nama'] ?? '') as String).compareTo((a['nama'] ?? '') as String));
break;
}
_logger.d('✅ [sortFilteredList] Sorted ${filteredPackages.length} packages');
} catch (e, stackTrace) {
_logger.e('❌ [sortFilteredList] Error sorting packages',
error: e,
stackTrace: stackTrace);
}
}
// Set search query dan filter paket
@ -214,40 +260,134 @@ class PetugasPaketController extends GetxController {
}
// Tambah paket baru
void addPaket(Map<String, dynamic> paket) {
paketList.add(paket);
Future<void> addPaket(Map<String, dynamic> paketData) async {
try {
isLoading.value = true;
// Convert to PaketModel
final newPaket = PaketModel.fromJson({
...paketData,
'id': DateTime.now().millisecondsSinceEpoch.toString(),
'created_at': DateTime.now().toIso8601String(),
'updated_at': DateTime.now().toIso8601String(),
});
// Add to the list
packages.add(newPaket);
_updateLegacyPaketList();
filterPaket();
Get.back();
Get.snackbar(
'Sukses',
'Paket baru berhasil ditambahkan',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.green,
colorText: Colors.white,
);
} catch (e, stackTrace) {
_logger.e('❌ [addPaket] Error adding package',
error: e,
stackTrace: stackTrace);
Get.snackbar(
'Error',
'Gagal menambahkan paket. Silakan coba lagi.',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
} finally {
isLoading.value = false;
}
}
// Edit paket
void editPaket(String id, Map<String, dynamic> updatedPaket) {
final index = paketList.indexWhere((element) => element['id'] == id);
Future<void> editPaket(String id, Map<String, dynamic> updatedData) async {
try {
isLoading.value = true;
final index = packages.indexWhere((pkg) => pkg.id == id);
if (index >= 0) {
paketList[index] = updatedPaket;
// 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),
updatedAt: DateTime.now(),
);
packages[index] = updatedPaket;
_updateLegacyPaketList();
filterPaket();
Get.back();
Get.snackbar(
'Sukses',
'Paket berhasil diperbarui',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.green,
colorText: Colors.white,
);
}
} catch (e, stackTrace) {
_logger.e('❌ [editPaket] Error updating package',
error: e,
stackTrace: stackTrace);
Get.snackbar(
'Error',
'Gagal memperbarui paket. Silakan coba lagi.',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
} finally {
isLoading.value = false;
}
}
// Hapus paket
void deletePaket(String id) {
paketList.removeWhere((element) => element['id'] == id);
Future<void> 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,
);
} catch (e, stackTrace) {
_logger.e('❌ [deletePaket] Error deleting package',
error: e,
stackTrace: stackTrace);
Get.snackbar(
'Error',
'Gagal menghapus paket. Silakan coba lagi.',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
} finally {
isLoading.value = false;
}
}
/// Format price to Rupiah currency
String formatPrice(num price) {
return 'Rp ${NumberFormat('#,##0', 'id_ID').format(price)}';
}
}

View File

@ -1,5 +1,8 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../services/sewa_service.dart';
import '../../../data/models/rental_booking_model.dart';
import '../../../data/providers/aset_provider.dart';
class PetugasSewaController extends GetxController {
// Reactive variables
@ -7,7 +10,7 @@ class PetugasSewaController extends GetxController {
final searchQuery = ''.obs;
final orderIdQuery = ''.obs;
final selectedStatusFilter = 'Semua'.obs;
final filteredSewaList = <Map<String, dynamic>>[].obs;
final filteredSewaList = <SewaModel>[].obs;
// Filter options
final List<String> statusFilters = [
@ -15,13 +18,19 @@ class PetugasSewaController extends GetxController {
'Menunggu Pembayaran',
'Periksa Pembayaran',
'Diterima',
'Aktif',
'Dikembalikan',
'Selesai',
'Dibatalkan',
];
// Mock data for sewa list
final RxList<Map<String, dynamic>> sewaList = <Map<String, dynamic>>[].obs;
final RxList<SewaModel> sewaList = <SewaModel>[].obs;
// Payment option state (per sewa)
final Map<String, RxBool> isFullPaymentMap = {};
final Map<String, TextEditingController> nominalControllerMap = {};
final Map<String, RxString> paymentMethodMap = {};
@override
void onInit() {
@ -41,25 +50,21 @@ class PetugasSewaController extends GetxController {
void _updateFilteredList() {
filteredSewaList.value =
sewaList.where((sewa) {
// Apply search filter
final matchesSearch = sewa['nama_warga']
.toString()
.toLowerCase()
.contains(searchQuery.value.toLowerCase());
// Apply order ID filter if provided
final matchesOrderId =
orderIdQuery.value.isEmpty ||
sewa['order_id'].toString().toLowerCase().contains(
orderIdQuery.value.toLowerCase(),
);
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'] == selectedStatusFilter.value;
sewa.status.toUpperCase() ==
selectedStatusFilter.value.toUpperCase();
return matchesSearch && matchesOrderId && matchesStatus;
return matchesSearch && matchesStatus;
}).toList();
}
@ -68,100 +73,8 @@ class PetugasSewaController extends GetxController {
isLoading.value = true;
try {
// Simulate API call delay
await Future.delayed(const Duration(milliseconds: 800));
// Populate with mock data
sewaList.assignAll([
{
'id': '1',
'order_id': 'SWA-001',
'nama_warga': 'Sukimin',
'nama_aset': 'Mobil Pickup',
'tanggal_mulai': '2025-02-05',
'tanggal_selesai': '2025-02-10',
'total_biaya': 45000,
'status': 'Diterima',
'photo_url': 'https://example.com/photo1.jpg',
},
{
'id': '2',
'order_id': 'SWA-002',
'nama_warga': 'Sukimin',
'nama_aset': 'Mobil Pickup',
'tanggal_mulai': '2025-02-15',
'tanggal_selesai': '2025-02-20',
'total_biaya': 30000,
'status': 'Selesai',
'photo_url': 'https://example.com/photo2.jpg',
},
{
'id': '3',
'order_id': 'SWA-003',
'nama_warga': 'Sukimin',
'nama_aset': 'Mobil Pickup',
'tanggal_mulai': '2025-02-25',
'tanggal_selesai': '2025-03-01',
'total_biaya': 35000,
'status': 'Menunggu Pembayaran',
'photo_url': 'https://example.com/photo3.jpg',
},
{
'id': '4',
'order_id': 'SWA-004',
'nama_warga': 'Sukimin',
'nama_aset': 'Mobil Pickup',
'tanggal_mulai': '2025-03-05',
'tanggal_selesai': '2025-03-08',
'total_biaya': 20000,
'status': 'Periksa Pembayaran',
'photo_url': 'https://example.com/photo4.jpg',
},
{
'id': '5',
'order_id': 'SWA-005',
'nama_warga': 'Sukimin',
'nama_aset': 'Mobil Pickup',
'tanggal_mulai': '2025-03-12',
'tanggal_selesai': '2025-03-14',
'total_biaya': 15000,
'status': 'Dibatalkan',
'photo_url': 'https://example.com/photo5.jpg',
},
{
'id': '6',
'order_id': 'SWA-006',
'nama_warga': 'Sukimin',
'nama_aset': 'Mobil Pickup',
'tanggal_mulai': '2025-03-18',
'tanggal_selesai': '2025-03-20',
'total_biaya': 25000,
'status': 'Pembayaran Denda',
'photo_url': 'https://example.com/photo6.jpg',
},
{
'id': '7',
'order_id': 'SWA-007',
'nama_warga': 'Sukimin',
'nama_aset': 'Mobil Pickup',
'tanggal_mulai': '2025-03-25',
'tanggal_selesai': '2025-03-28',
'total_biaya': 40000,
'status': 'Periksa Denda',
'photo_url': 'https://example.com/photo7.jpg',
},
{
'id': '8',
'order_id': 'SWA-008',
'nama_warga': 'Sukimin',
'nama_aset': 'Mobil Pickup',
'tanggal_mulai': '2025-04-02',
'tanggal_selesai': '2025-04-05',
'total_biaya': 10000,
'status': 'Dikembalikan',
'photo_url': 'https://example.com/photo8.jpg',
},
]);
final data = await SewaService().fetchAllSewa();
sewaList.assignAll(data);
} catch (e) {
print('Error loading sewa data: $e');
} finally {
@ -196,10 +109,11 @@ class PetugasSewaController extends GetxController {
sewaList.where((sewa) {
bool matchesStatus =
selectedStatusFilter.value == 'Semua' ||
sewa['status'] == selectedStatusFilter.value;
sewa.status.toUpperCase() ==
selectedStatusFilter.value.toUpperCase();
bool matchesSearch =
searchQuery.value.isEmpty ||
sewa['nama_warga'].toLowerCase().contains(
sewa.wargaNama.toLowerCase().contains(
searchQuery.value.toLowerCase(),
);
return matchesStatus && matchesSearch;
@ -213,102 +127,367 @@ class PetugasSewaController extends GetxController {
// Get color based on status
Color getStatusColor(String status) {
switch (status) {
case 'Menunggu Pembayaran':
return Colors.orange;
case 'Periksa Pembayaran':
return Colors.amber.shade700;
case 'Diterima':
return Colors.blue;
case 'Pembayaran Denda':
return Colors.deepOrange;
case 'Periksa Denda':
return Colors.red.shade600;
case 'Dikembalikan':
return Colors.teal;
case 'Sedang Disewa':
switch (status.toUpperCase()) {
case 'MENUNGGU PEMBAYARAN':
return Colors.orangeAccent;
case 'PERIKSA PEMBAYARAN':
return Colors.amber;
case 'DITERIMA':
return Colors.blueAccent;
case 'AKTIF':
return Colors.green;
case 'Selesai':
case 'PEMBAYARAN DENDA':
return Colors.deepOrangeAccent;
case 'PERIKSA PEMBAYARAN DENDA':
return Colors.redAccent;
case 'DIKEMBALIKAN':
return Colors.teal;
case 'SELESAI':
return Colors.purple;
case 'Dibatalkan':
case 'DIBATALKAN':
return Colors.red;
default:
return Colors.grey;
}
}
// Handle sewa approval (from "Periksa Pembayaran" to "Diterima")
void approveSewa(String id) {
final index = sewaList.indexWhere((sewa) => sewa['id'] == id);
if (index != -1) {
final sewa = Map<String, dynamic>.from(sewaList[index]);
final currentStatus = sewa['status'];
if (currentStatus == 'Periksa Pembayaran') {
sewa['status'] = 'Diterima';
} else if (currentStatus == 'Periksa Denda') {
sewa['status'] = 'Selesai';
} else if (currentStatus == 'Menunggu Pembayaran') {
sewa['status'] = 'Periksa Pembayaran';
// Get icon based on status
IconData getStatusIcon(String status) {
switch (status) {
case 'MENUNGGU PEMBAYARAN':
return Icons.payments_outlined;
case 'PERIKSA PEMBAYARAN':
return Icons.fact_check_outlined;
case 'DITERIMA':
return Icons.check_circle_outlined;
case 'AKTIF':
return Icons.play_circle_outline;
case 'PEMBYARAN DENDA':
return Icons.money_off_csred_outlined;
case 'PERIKSA PEMBAYARAN DENDA':
return Icons.assignment_late_outlined;
case 'DIKEMBALIKAN':
return Icons.assignment_return_outlined;
case 'SELESAI':
return Icons.task_alt_outlined;
case 'DIBATALKAN':
return Icons.cancel_outlined;
default:
return Icons.help_outline_rounded;
}
}
sewaList[index] = sewa;
// Handle sewa approval (from "Periksa Pembayaran" to "Diterima")
void approveSewa(String id) {
final index = sewaList.indexWhere((sewa) => sewa.id == id);
if (index != -1) {
final sewa = sewaList[index];
final currentStatus = sewa.status;
String? newStatus;
if (currentStatus == 'PERIKSA PEMBAYARAN') {
newStatus = 'DITERIMA';
} else if (currentStatus == 'PERIKSA PEMBAYARAN DENDA') {
newStatus = 'SELESAI';
} else if (currentStatus == 'MENUNGGU PEMBAYARAN') {
newStatus = 'PERIKSA PEMBAYARAN';
}
if (newStatus != null) {
sewaList[index] = SewaModel(
id: sewa.id,
userId: sewa.userId,
status: newStatus,
waktuMulai: sewa.waktuMulai,
waktuSelesai: sewa.waktuSelesai,
tanggalPemesanan: sewa.tanggalPemesanan,
tipePesanan: sewa.tipePesanan,
kuantitas: sewa.kuantitas,
asetId: sewa.asetId,
asetNama: sewa.asetNama,
asetFoto: sewa.asetFoto,
paketId: sewa.paketId,
paketNama: sewa.paketNama,
paketFoto: sewa.paketFoto,
totalTagihan: sewa.totalTagihan,
wargaNama: sewa.wargaNama,
wargaNoHp: sewa.wargaNoHp,
wargaAvatar: sewa.wargaAvatar,
);
sewaList.refresh();
}
}
}
// Handle sewa rejection or cancellation
void rejectSewa(String id) {
final index = sewaList.indexWhere((sewa) => sewa['id'] == id);
final index = sewaList.indexWhere((sewa) => sewa.id == id);
if (index != -1) {
final sewa = Map<String, dynamic>.from(sewaList[index]);
sewa['status'] = 'Dibatalkan';
sewaList[index] = sewa;
final sewa = sewaList[index];
sewaList[index] = SewaModel(
id: sewa.id,
userId: sewa.userId,
status: 'Dibatalkan',
waktuMulai: sewa.waktuMulai,
waktuSelesai: sewa.waktuSelesai,
tanggalPemesanan: sewa.tanggalPemesanan,
tipePesanan: sewa.tipePesanan,
kuantitas: sewa.kuantitas,
asetId: sewa.asetId,
asetNama: sewa.asetNama,
asetFoto: sewa.asetFoto,
paketId: sewa.paketId,
paketNama: sewa.paketNama,
paketFoto: sewa.paketFoto,
totalTagihan: sewa.totalTagihan,
wargaNama: sewa.wargaNama,
wargaNoHp: sewa.wargaNoHp,
wargaAvatar: sewa.wargaAvatar,
);
sewaList.refresh();
}
}
// Request payment for penalty
void requestPenaltyPayment(String id) {
final index = sewaList.indexWhere((sewa) => sewa['id'] == id);
final index = sewaList.indexWhere((sewa) => sewa.id == id);
if (index != -1) {
final sewa = Map<String, dynamic>.from(sewaList[index]);
sewa['status'] = 'Pembayaran Denda';
sewaList[index] = sewa;
final sewa = sewaList[index];
sewaList[index] = SewaModel(
id: sewa.id,
userId: sewa.userId,
status: 'Pembayaran Denda',
waktuMulai: sewa.waktuMulai,
waktuSelesai: sewa.waktuSelesai,
tanggalPemesanan: sewa.tanggalPemesanan,
tipePesanan: sewa.tipePesanan,
kuantitas: sewa.kuantitas,
asetId: sewa.asetId,
asetNama: sewa.asetNama,
asetFoto: sewa.asetFoto,
paketId: sewa.paketId,
paketNama: sewa.paketNama,
paketFoto: sewa.paketFoto,
totalTagihan: sewa.totalTagihan,
wargaNama: sewa.wargaNama,
wargaNoHp: sewa.wargaNoHp,
wargaAvatar: sewa.wargaAvatar,
);
sewaList.refresh();
}
}
// Mark penalty payment as requiring inspection
void markPenaltyForInspection(String id) {
final index = sewaList.indexWhere((sewa) => sewa['id'] == id);
final index = sewaList.indexWhere((sewa) => sewa.id == id);
if (index != -1) {
final sewa = Map<String, dynamic>.from(sewaList[index]);
sewa['status'] = 'Periksa Denda';
sewaList[index] = sewa;
final sewa = sewaList[index];
sewaList[index] = SewaModel(
id: sewa.id,
userId: sewa.userId,
status: 'Periksa Denda',
waktuMulai: sewa.waktuMulai,
waktuSelesai: sewa.waktuSelesai,
tanggalPemesanan: sewa.tanggalPemesanan,
tipePesanan: sewa.tipePesanan,
kuantitas: sewa.kuantitas,
asetId: sewa.asetId,
asetNama: sewa.asetNama,
asetFoto: sewa.asetFoto,
paketId: sewa.paketId,
paketNama: sewa.paketNama,
paketFoto: sewa.paketFoto,
totalTagihan: sewa.totalTagihan,
wargaNama: sewa.wargaNama,
wargaNoHp: sewa.wargaNoHp,
wargaAvatar: sewa.wargaAvatar,
);
sewaList.refresh();
}
}
// Handle sewa completion
void completeSewa(String id) {
final index = sewaList.indexWhere((sewa) => sewa['id'] == id);
void completeSewa(String id) async {
final index = sewaList.indexWhere((sewa) => sewa.id == id);
if (index != -1) {
final sewa = Map<String, dynamic>.from(sewaList[index]);
sewa['status'] = 'Selesai';
sewaList[index] = sewa;
final sewa = sewaList[index];
sewaList[index] = SewaModel(
id: sewa.id,
userId: sewa.userId,
status: 'Selesai',
waktuMulai: sewa.waktuMulai,
waktuSelesai: sewa.waktuSelesai,
tanggalPemesanan: sewa.tanggalPemesanan,
tipePesanan: sewa.tipePesanan,
kuantitas: sewa.kuantitas,
asetId: sewa.asetId,
asetNama: sewa.asetNama,
asetFoto: sewa.asetFoto,
paketId: sewa.paketId,
paketNama: sewa.paketNama,
paketFoto: sewa.paketFoto,
totalTagihan: sewa.totalTagihan,
wargaNama: sewa.wargaNama,
wargaNoHp: sewa.wargaNoHp,
wargaAvatar: sewa.wargaAvatar,
);
sewaList.refresh();
// Update status in database
final asetProvider = Get.find<AsetProvider>();
await asetProvider.updateSewaAsetStatus(
sewaAsetId: id,
status: 'SELESAI',
);
}
}
// Mark rental as returned
void markAsReturned(String id) {
final index = sewaList.indexWhere((sewa) => sewa['id'] == id);
Future<void> markAsReturned(String id) async {
final index = sewaList.indexWhere((sewa) => sewa.id == id);
if (index != -1) {
final sewa = Map<String, dynamic>.from(sewaList[index]);
sewa['status'] = 'Dikembalikan';
sewaList[index] = sewa;
final sewa = sewaList[index];
sewaList[index] = SewaModel(
id: sewa.id,
userId: sewa.userId,
status: 'Dikembalikan',
waktuMulai: sewa.waktuMulai,
waktuSelesai: sewa.waktuSelesai,
tanggalPemesanan: sewa.tanggalPemesanan,
tipePesanan: sewa.tipePesanan,
kuantitas: sewa.kuantitas,
asetId: sewa.asetId,
asetNama: sewa.asetNama,
asetFoto: sewa.asetFoto,
paketId: sewa.paketId,
paketNama: sewa.paketNama,
paketFoto: sewa.paketFoto,
totalTagihan: sewa.totalTagihan,
wargaNama: sewa.wargaNama,
wargaNoHp: sewa.wargaNoHp,
wargaAvatar: sewa.wargaAvatar,
);
sewaList.refresh();
// Update status in database
final asetProvider = Get.find<AsetProvider>();
final result = await asetProvider.updateSewaAsetStatus(
sewaAsetId: id,
status: 'DIKEMBALIKAN',
);
if (!result) {
Get.snackbar(
'Gagal',
'Gagal mengubah status sewa di database',
backgroundColor: Colors.red,
colorText: Colors.white,
);
}
}
}
// Ambil detail item paket (nama aset & kuantitas)
Future<List<Map<String, dynamic>>> getPaketItems(String paketId) async {
final asetProvider = Get.find<AsetProvider>();
debugPrint('[DEBUG] getPaketItems called with paketId: $paketId');
try {
final items = await asetProvider.getPaketItems(paketId);
debugPrint('[DEBUG] getPaketItems result for paketId $paketId:');
for (var item in items) {
debugPrint(' - item: ${item.toString()}');
}
return items;
} catch (e, stack) {
debugPrint('[ERROR] getPaketItems failed for paketId $paketId: $e');
debugPrint('[ERROR] Stacktrace: $stack');
return [];
}
}
RxBool getIsFullPayment(String sewaId) {
if (!isFullPaymentMap.containsKey(sewaId)) {
isFullPaymentMap[sewaId] = false.obs;
}
return isFullPaymentMap[sewaId]!;
}
TextEditingController getNominalController(String sewaId) {
if (!nominalControllerMap.containsKey(sewaId)) {
final controller = TextEditingController(text: '0');
nominalControllerMap[sewaId] = controller;
}
return nominalControllerMap[sewaId]!;
}
void setFullPayment(String sewaId, bool value, num totalTagihan) {
getIsFullPayment(sewaId).value = value;
if (value) {
getNominalController(sewaId).text = totalTagihan.toString();
}
}
RxString getPaymentMethod(String sewaId) {
if (!paymentMethodMap.containsKey(sewaId)) {
paymentMethodMap[sewaId] = 'Tunai'.obs;
}
return paymentMethodMap[sewaId]!;
}
void setPaymentMethod(String sewaId, String method) {
getPaymentMethod(sewaId).value = method;
}
Future<String?> getTagihanSewaIdBySewaAsetId(String sewaAsetId) async {
final asetProvider = Get.find<AsetProvider>();
final tagihan = await asetProvider.getTagihanSewa(sewaAsetId);
if (tagihan != null && tagihan['id'] != null) {
return tagihan['id'] as String;
}
return null;
}
Future<void> confirmPembayaranTagihan({
required String sewaAsetId,
required int nominal,
required String metodePembayaran,
}) async {
final tagihanSewaId = await getTagihanSewaIdBySewaAsetId(sewaAsetId);
if (tagihanSewaId == null) {
Get.snackbar(
'Gagal',
'Tagihan sewa tidak ditemukan',
backgroundColor: Colors.red,
colorText: Colors.white,
);
return;
}
final asetProvider = Get.find<AsetProvider>();
// Cek status sewa_aset saat ini
final sewaAsetData = await asetProvider.getSewaAsetWithAsetData(sewaAsetId);
if (sewaAsetData != null &&
(sewaAsetData['status']?.toString()?.toUpperCase() ==
'PERIKSA PEMBAYARAN')) {
// Ubah status menjadi MENUNGGU PEMBAYARAN
await asetProvider.updateSewaAsetStatus(
sewaAsetId: sewaAsetId,
status: 'MENUNGGU PEMBAYARAN',
);
}
final result = await asetProvider.processPembayaranTagihan(
tagihanSewaId: tagihanSewaId,
nominal: nominal,
metodePembayaran: metodePembayaran,
);
if (result) {
Get.snackbar(
'Sukses',
'Pembayaran berhasil diproses',
backgroundColor: Colors.green,
colorText: Colors.white,
);
} else {
Get.snackbar(
'Gagal',
'Pembayaran gagal diproses',
backgroundColor: Colors.red,
colorText: Colors.white,
);
}
}
}

View File

@ -1,7 +1,187 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart';
import 'package:bumrent_app/app/data/models/aset_model.dart';
import 'package:bumrent_app/app/data/providers/aset_provider.dart';
class PetugasTambahAsetController extends GetxController {
// Flag to check if in edit mode
final isEditing = false.obs;
String? assetId; // To store the ID of the asset being edited
@override
Future<void> onInit() async {
super.onInit();
try {
// Handle edit mode and load data if needed
final args = Get.arguments;
debugPrint('[DEBUG] PetugasTambahAsetController initialized with args: $args');
if (args != null && args is Map<String, dynamic>) {
isEditing.value = args['isEditing'] ?? false;
debugPrint('[DEBUG] isEditing set to: ${isEditing.value}');
if (isEditing.value) {
// Get asset ID from arguments
final assetId = args['assetId']?.toString() ?? '';
debugPrint('[DEBUG] Edit mode: Loading asset with ID: $assetId');
if (assetId.isNotEmpty) {
// Store the asset ID and load asset data
this.assetId = assetId;
debugPrint('[DEBUG] Asset ID set to: $assetId');
// Load asset data and await completion
await _loadAssetData(assetId);
} else {
debugPrint('[ERROR] Edit mode but no assetId provided in arguments');
Get.snackbar(
'Error',
'ID Aset tidak ditemukan',
snackPosition: SnackPosition.BOTTOM,
);
// Optionally navigate back if in edit mode without an ID
Future.delayed(Duration.zero, () => Get.back());
}
} else {
// Set default values for new asset
debugPrint('[DEBUG] Add new asset mode');
quantityController.text = '1';
unitOfMeasureController.text = 'Unit';
}
} else {
// Default values for new asset when no arguments are passed
debugPrint('[DEBUG] No arguments passed, defaulting to add new asset mode');
quantityController.text = '1';
unitOfMeasureController.text = 'Unit';
}
} catch (e, stackTrace) {
debugPrint('[ERROR] Error in onInit: $e');
debugPrint('Stack trace: $stackTrace');
// Ensure loading is set to false even if there's an error
isLoading.value = false;
Get.snackbar(
'Error',
'Terjadi kesalahan saat memuat data',
snackPosition: SnackPosition.BOTTOM,
);
}
// Listen to field changes for validation
nameController.addListener(validateForm);
descriptionController.addListener(validateForm);
quantityController.addListener(validateForm);
pricePerHourController.addListener(validateForm);
pricePerDayController.addListener(validateForm);
}
final AsetProvider _asetProvider = Get.find<AsetProvider>();
final isLoading = false.obs;
Future<void> _loadAssetData(String assetId) async {
try {
isLoading.value = true;
debugPrint('[DEBUG] Fetching asset data for ID: $assetId');
// Fetch asset data from Supabase
final aset = await _asetProvider.getAsetById(assetId);
if (aset == null) {
throw Exception('Aset tidak ditemukan');
}
debugPrint('[DEBUG] Successfully fetched asset data: ${aset.toJson()}');
// Populate form fields with the fetched data
nameController.text = aset.nama ?? '';
descriptionController.text = aset.deskripsi ?? '';
quantityController.text = (aset.kuantitas ?? 1).toString();
// Ensure the status matches one of the available options exactly
final status = aset.status?.toLowerCase() ?? 'tersedia';
if (status == 'tersedia') {
selectedStatus.value = 'Tersedia';
} else if (status == 'pemeliharaan') {
selectedStatus.value = 'Pemeliharaan';
} else {
// Default to 'Tersedia' if status is not recognized
selectedStatus.value = 'Tersedia';
}
// Handle time options and pricing
if (aset.satuanWaktuSewa != null && aset.satuanWaktuSewa!.isNotEmpty) {
// Reset time options
timeOptions.forEach((key, value) => value.value = false);
// Process each satuan waktu sewa
for (var sws in aset.satuanWaktuSewa) {
final satuan = sws['nama_satuan_waktu']?.toString().toLowerCase() ?? '';
final harga = sws['harga'] as int? ?? 0;
final maksimalWaktu = sws['maksimal_waktu'] as int? ?? 24;
if (satuan.contains('jam')) {
timeOptions['Per Jam']?.value = true;
pricePerHourController.text = harga.toString();
maxHourController.text = maksimalWaktu.toString();
} else if (satuan.contains('hari')) {
timeOptions['Per Hari']?.value = true;
pricePerDayController.text = harga.toString();
maxDayController.text = maksimalWaktu.toString();
}
}
}
// Clear existing images
selectedImages.clear();
networkImageUrls.clear();
// Get all image URLs from the model
final allImageUrls = aset.imageUrls.toList();
// If no imageUrls but has imageUrl, use that as fallback (backward compatibility)
if (allImageUrls.isEmpty && aset.imageUrl != null && aset.imageUrl!.isNotEmpty) {
allImageUrls.add(aset.imageUrl!);
}
// Add all images to the lists
for (final imageUrl in allImageUrls) {
if (imageUrl != null && imageUrl.isNotEmpty) {
try {
// For network images, we'll store the URL in networkImageUrls
// and create a dummy XFile with the URL as path for backward compatibility
final dummyFile = XFile(imageUrl);
selectedImages.add(dummyFile);
networkImageUrls.add(imageUrl);
debugPrint('Added network image: $imageUrl');
} catch (e) {
debugPrint('Error adding network image: $e');
}
}
}
debugPrint('Total ${networkImageUrls.length} images loaded for asset $assetId');
debugPrint('[DEBUG] Successfully loaded asset data for ID: $assetId');
} catch (e, stackTrace) {
debugPrint('[ERROR] Failed to load asset data: $e');
debugPrint('Stack trace: $stackTrace');
Get.snackbar(
'Error',
'Gagal memuat data aset: ${e.toString()}',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
// Optionally navigate back if there's an error
Future.delayed(const Duration(seconds: 2), () => Get.back());
} finally {
isLoading.value = false;
}
}
// Form controllers
final nameController = TextEditingController();
final descriptionController = TextEditingController();
@ -23,27 +203,17 @@ class PetugasTambahAsetController extends GetxController {
final categoryOptions = ['Sewa', 'Langganan'];
final statusOptions = ['Tersedia', 'Pemeliharaan'];
// Images
final selectedImages = <String>[].obs;
// List to store selected images
final RxList<XFile> selectedImages = <XFile>[].obs;
// List to store network image URLs
final RxList<String> networkImageUrls = <String>[].obs;
final _picker = ImagePicker();
// Form validation
final isFormValid = false.obs;
final isSubmitting = false.obs;
@override
void onInit() {
super.onInit();
// Set default values
quantityController.text = '1';
unitOfMeasureController.text = 'Unit';
// Listen to field changes for validation
nameController.addListener(validateForm);
descriptionController.addListener(validateForm);
quantityController.addListener(validateForm);
pricePerHourController.addListener(validateForm);
pricePerDayController.addListener(validateForm);
}
@override
void onClose() {
@ -89,17 +259,140 @@ class PetugasTambahAsetController extends GetxController {
validateForm();
}
// Add image to the list (in a real app, this would handle file upload)
void addImage(String imagePath) {
selectedImages.add(imagePath);
validateForm();
// Create a new asset in Supabase
Future<String?> _createAsset(
Map<String, dynamic> assetData,
List<Map<String, dynamic>> satuanWaktuSewa,
) async {
try {
// Create the asset in the 'aset' table
final response = await _asetProvider.createAset(assetData);
if (response == null || response['id'] == null) {
debugPrint('❌ Failed to create asset: No response or ID from server');
return null;
}
// Remove image from the list
final String assetId = response['id'].toString();
debugPrint('✅ Asset created with ID: $assetId');
// Add satuan waktu sewa
for (var sws in satuanWaktuSewa) {
final success = await _asetProvider.addSatuanWaktuSewa(
asetId: assetId,
satuanWaktu: sws['satuan_waktu'],
harga: sws['harga'],
maksimalWaktu: sws['maksimal_waktu'],
);
if (!success) {
debugPrint('❌ Failed to add satuan waktu sewa: $sws');
}
}
return assetId;
} catch (e, stackTrace) {
debugPrint('❌ Error creating asset: $e');
debugPrint('Stack trace: $stackTrace');
rethrow;
}
}
// Update an existing asset in Supabase
Future<bool> _updateAsset(
String assetId,
Map<String, dynamic> assetData,
List<Map<String, dynamic>> satuanWaktuSewa,
) async {
try {
debugPrint('\n🔄 Starting update for asset ID: $assetId');
// 1. Extract and remove foto_aset from assetData as it's not in the aset table
final fotoAsetUrl = assetData['foto_aset'];
assetData.remove('foto_aset');
debugPrint('📝 Asset data prepared for update (without foto_aset)');
// 2. Update the main asset data (without foto_aset)
debugPrint('🔄 Updating main asset data...');
final success = await _asetProvider.updateAset(assetId, assetData);
if (!success) {
debugPrint('❌ Failed to update asset with ID: $assetId');
return false;
}
debugPrint('✅ Successfully updated main asset data');
// 3. Update satuan waktu sewa
debugPrint('\n🔄 Updating rental time units...');
// First, delete existing satuan waktu sewa
await _asetProvider.deleteSatuanWaktuSewaByAsetId(assetId);
// Then add the new ones
for (var sws in satuanWaktuSewa) {
debugPrint(' - Adding: ${sws['satuan_waktu']} (${sws['harga']} IDR)');
await _asetProvider.addSatuanWaktuSewa(
asetId: assetId,
satuanWaktu: sws['satuan_waktu'],
harga: sws['harga'] as int,
maksimalWaktu: sws['maksimal_waktu'] as int,
);
}
debugPrint('✅ Successfully updated rental time units');
// 4. Update photos in the foto_aset table if any exist
if (selectedImages.isNotEmpty || networkImageUrls.isNotEmpty) {
// Combine network URLs and local file paths
final List<String> allImageUrls = [
...networkImageUrls,
...selectedImages.map((file) => file.path),
];
debugPrint('\n🖼️ Processing photos for asset $assetId');
debugPrint(' - Network URLs: ${networkImageUrls.length}');
debugPrint(' - Local files: ${selectedImages.length}');
debugPrint(' - Total unique photos: ${allImageUrls.toSet().length} (before deduplication)');
try {
// Use updateFotoAset which handles both uploading new photos and updating the database
final photoSuccess = await _asetProvider.updateFotoAset(
asetId: assetId,
fotoUrls: allImageUrls,
);
if (!photoSuccess) {
debugPrint('⚠️ Some photos might not have been updated for asset $assetId');
// We don't fail the whole update if photo update fails
// as the main asset data has been saved successfully
} else {
debugPrint('✅ Successfully updated photos for asset $assetId');
}
} catch (e, stackTrace) {
debugPrint('❌ Error updating photos: $e');
debugPrint('Stack trace: $stackTrace');
// Continue with the update even if photo update fails
}
} else {
debugPrint(' No photos to update');
}
debugPrint('\n✅ Asset update completed successfully for ID: $assetId');
return true;
} catch (e, stackTrace) {
debugPrint('❌ Error updating asset: $e');
debugPrint('Stack trace: $stackTrace');
rethrow;
}
}
// Remove an image from the selected images list
void removeImage(int index) {
if (index >= 0 && index < selectedImages.length) {
// Remove from both lists if they have an entry at this index
if (index < networkImageUrls.length) {
networkImageUrls.removeAt(index);
}
selectedImages.removeAt(index);
validateForm();
}
}
@ -133,62 +426,130 @@ class PetugasTambahAsetController extends GetxController {
basicValid && perHourValid && perDayValid && anyTimeOptionSelected;
}
// Submit form and save asset
// Submit form and save or update asset
Future<void> saveAsset() async {
if (!isFormValid.value) return;
isSubmitting.value = true;
try {
// In a real app, this would make an API call to save the asset
await Future.delayed(const Duration(seconds: 1)); // Mock API call
// Prepare asset data
final assetData = {
// Prepare the basic asset data
final Map<String, dynamic> assetData = {
'nama': nameController.text,
'deskripsi': descriptionController.text,
'kategori': selectedCategory.value,
'kategori': 'sewa', // Default to 'sewa' category
'status': selectedStatus.value,
'kuantitas': int.parse(quantityController.text),
'satuan_ukur': unitOfMeasureController.text,
'opsi_waktu_sewa':
timeOptions.entries
.where((entry) => entry.value.value)
.map((entry) => entry.key)
.toList(),
'harga_per_jam':
timeOptions['Per Jam']!.value
? int.parse(pricePerHourController.text)
: null,
'max_jam':
timeOptions['Per Jam']!.value && maxHourController.text.isNotEmpty
? int.parse(maxHourController.text)
: null,
'harga_per_hari':
timeOptions['Per Hari']!.value
? int.parse(pricePerDayController.text)
: null,
'max_hari':
timeOptions['Per Hari']!.value && maxDayController.text.isNotEmpty
? int.parse(maxDayController.text)
: null,
'gambar': selectedImages,
'satuan_ukur': 'unit', // Default unit of measure
};
// Log the data (in a real app, this would be sent to an API)
print('Asset data: $assetData');
// Handle time options and pricing
final List<Map<String, dynamic>> satuanWaktuSewa = [];
// Return to the asset list page
Get.back();
if (timeOptions['Per Jam']?.value == true) {
final hargaPerJam = int.tryParse(pricePerHourController.text) ?? 0;
final maxJam = int.tryParse(maxHourController.text) ?? 24;
if (hargaPerJam <= 0) {
throw Exception('Harga per jam harus lebih dari 0');
}
satuanWaktuSewa.add({
'satuan_waktu': 'jam',
'harga': hargaPerJam,
'maksimal_waktu': maxJam,
});
}
if (timeOptions['Per Hari']?.value == true) {
final hargaPerHari = int.tryParse(pricePerDayController.text) ?? 0;
final maxHari = int.tryParse(maxDayController.text) ?? 30;
if (hargaPerHari <= 0) {
throw Exception('Harga per hari harus lebih dari 0');
}
satuanWaktuSewa.add({
'satuan_waktu': 'hari',
'harga': hargaPerHari,
'maksimal_waktu': maxHari,
});
}
// Validate that at least one time option is selected
if (satuanWaktuSewa.isEmpty) {
throw Exception('Pilih setidaknya satu opsi waktu sewa (jam/hari)');
}
// Handle image uploads
List<String> imageUrls = [];
if (networkImageUrls.isNotEmpty) {
// Use existing network URLs
imageUrls = List.from(networkImageUrls);
} else if (selectedImages.isNotEmpty) {
// For local files, we'll upload them to Supabase Storage
// Store the file paths for now, they'll be uploaded in the provider
imageUrls = selectedImages.map((file) => file.path).toList();
debugPrint('Found ${imageUrls.length} local images to upload');
} else if (!isEditing.value) {
// For new assets, require at least one image
throw Exception('Harap unggah setidaknya satu gambar');
}
// Ensure at least one image is provided for new assets
if (imageUrls.isEmpty && !isEditing.value) {
throw Exception('Harap unggah setidaknya satu gambar');
}
// Create or update the asset
bool success;
String? createdAssetId;
if (isEditing.value && (assetId?.isNotEmpty ?? false)) {
// Update existing asset
debugPrint('🔄 Updating asset with ID: $assetId');
success = await _updateAsset(assetId!, assetData, satuanWaktuSewa);
// Update all photos if we have any
if (success && imageUrls.isNotEmpty) {
await _asetProvider.updateFotoAset(
asetId: assetId!,
fotoUrls: imageUrls,
);
}
} else {
// Create new asset
debugPrint('🔄 Creating new asset');
createdAssetId = await _createAsset(assetData, satuanWaktuSewa);
success = createdAssetId != null;
// Add all photos for new asset
if (success && createdAssetId != null && imageUrls.isNotEmpty) {
await _asetProvider.updateFotoAset(
asetId: createdAssetId,
fotoUrls: imageUrls,
);
}
}
if (success) {
// Show success message
Get.snackbar(
'Berhasil',
'Aset berhasil ditambahkan',
'Sukses',
isEditing.value ? 'Aset berhasil diperbarui' : 'Aset berhasil ditambahkan',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.green,
colorText: Colors.white,
snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 3),
);
// Navigate back with success after a short delay
await Future.delayed(const Duration(seconds: 1));
Get.back(result: true);
} else {
throw Exception('Gagal menyimpan aset');
}
} catch (e) {
// Show error message
Get.snackbar(
@ -203,8 +564,68 @@ class PetugasTambahAsetController extends GetxController {
}
}
// Example method to upload images (to be implemented with your backend)
// Future<List<String>> _uploadImages(List<XFile> images) async {
// List<String> urls = [];
// for (var image in images) {
// // Upload image to your server and get the URL
// // final url = await yourApiService.uploadImage(File(image.path));
// // urls.add(url);
// urls.add('https://example.com/path/to/uploaded/image.jpg'); // Mock URL
// }
// return urls;
// }
// Pick image from camera
Future<void> pickImageFromCamera() async {
try {
final XFile? image = await _picker.pickImage(
source: ImageSource.camera,
imageQuality: 80,
maxWidth: 1024,
maxHeight: 1024,
);
if (image != null) {
selectedImages.add(image);
}
} catch (e) {
Get.snackbar(
'Error',
'Gagal mengambil gambar dari kamera: $e',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
}
}
// Pick image from gallery
Future<void> pickImageFromGallery() async {
try {
final List<XFile>? images = await _picker.pickMultiImage(
imageQuality: 80,
maxWidth: 1024,
maxHeight: 1024,
);
if (images != null && images.isNotEmpty) {
selectedImages.addAll(images);
}
} catch (e) {
Get.snackbar(
'Error',
'Gagal memilih gambar dari galeri: $e',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
}
}
// For demonstration purposes: add sample image
void addSampleImage() {
addImage('assets/images/sample_asset_${selectedImages.length + 1}.jpg');
// In a real app, this would open the image picker
selectedImages.add(XFile('assets/images/sample_asset_${selectedImages.length + 1}.jpg'));
validateForm();
}
}

View File

@ -1,5 +1,11 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:bumrent_app/app/data/models/paket_model.dart';
import 'package:image_picker/image_picker.dart';
import 'package:bumrent_app/app/data/providers/aset_provider.dart';
import 'dart:io';
import 'package:uuid/uuid.dart';
class PetugasTambahPaketController extends GetxController {
// Form controllers
@ -10,14 +16,14 @@ class PetugasTambahPaketController extends GetxController {
// Dropdown and toggle values
final selectedCategory = 'Bulanan'.obs;
final selectedStatus = 'Aktif'.obs;
final selectedStatus = 'Tersedia'.obs;
// Category options
final categoryOptions = ['Bulanan', 'Tahunan', 'Premium', 'Bisnis'];
final statusOptions = ['Aktif', 'Nonaktif'];
final statusOptions = ['Tersedia', 'Pemeliharaan'];
// Images
final selectedImages = <String>[].obs;
final selectedImages = <dynamic>[].obs;
// For package name and description
final packageNameController = TextEditingController();
@ -31,21 +37,85 @@ class PetugasTambahPaketController extends GetxController {
// For asset selection
final RxList<Map<String, dynamic>> availableAssets =
<Map<String, dynamic>>[].obs;
final Rx<int?> selectedAsset = Rx<int?>(null);
final Rx<String?> selectedAsset = Rx<String?>(null);
final RxBool isLoadingAssets = false.obs;
// Form validation
final isFormValid = false.obs;
final isSubmitting = false.obs;
// New RxBool for editing
final isEditing = false.obs;
final timeOptions = {'Per Jam': true.obs, 'Per Hari': false.obs};
final pricePerHourController = TextEditingController();
final maxHourController = TextEditingController();
final pricePerDayController = TextEditingController();
final maxDayController = TextEditingController();
final _picker = ImagePicker();
final isFormChanged = false.obs;
Map<String, dynamic> initialFormData = {};
final AsetProvider _asetProvider = Get.put(AsetProvider());
@override
void onInit() {
super.onInit();
// Ambil flag isEditing dari arguments
isEditing.value =
Get.arguments != null && Get.arguments['isEditing'] == true;
if (isEditing.value) {
final paketArg = Get.arguments['paket'];
String? paketId;
if (paketArg != null) {
if (paketArg is Map && paketArg['id'] != null) {
paketId = paketArg['id'].toString();
} else if (paketArg is PaketModel && paketArg.id != null) {
paketId = paketArg.id.toString();
}
}
if (paketId != null) {
fetchPaketDetail(paketId);
}
}
// Listen to field changes for validation
nameController.addListener(validateForm);
descriptionController.addListener(validateForm);
priceController.addListener(validateForm);
nameController.addListener(() {
validateForm();
checkFormChanged();
});
descriptionController.addListener(() {
validateForm();
checkFormChanged();
});
priceController.addListener(() {
validateForm();
checkFormChanged();
});
itemQuantityController.addListener(() {
validateForm();
checkFormChanged();
});
pricePerHourController.addListener(() {
validateForm();
checkFormChanged();
});
maxHourController.addListener(() {
validateForm();
checkFormChanged();
});
pricePerDayController.addListener(() {
validateForm();
checkFormChanged();
});
maxDayController.addListener(() {
validateForm();
checkFormChanged();
});
// Load available assets when the controller initializes
fetchAvailableAssets();
@ -61,6 +131,10 @@ class PetugasTambahPaketController extends GetxController {
packageNameController.dispose();
packageDescriptionController.dispose();
packagePriceController.dispose();
pricePerHourController.dispose();
maxHourController.dispose();
pricePerDayController.dispose();
maxDayController.dispose();
super.onClose();
}
@ -68,18 +142,21 @@ class PetugasTambahPaketController extends GetxController {
void setCategory(String category) {
selectedCategory.value = category;
validateForm();
checkFormChanged();
}
// Change selected status
void setStatus(String status) {
selectedStatus.value = status;
validateForm();
checkFormChanged();
}
// Add image to the list (in a real app, this would handle file upload)
void addImage(String imagePath) {
selectedImages.add(imagePath);
validateForm();
checkFormChanged();
}
// Remove image from the list
@ -87,34 +164,43 @@ class PetugasTambahPaketController extends GetxController {
if (index >= 0 && index < selectedImages.length) {
selectedImages.removeAt(index);
validateForm();
checkFormChanged();
}
}
// Fetch available assets from the API or local data
void fetchAvailableAssets() {
// Fetch available assets from Supabase and filter out already selected ones
Future<void> fetchAvailableAssets() async {
isLoadingAssets.value = true;
// This is a mock implementation - replace with actual API call
Future.delayed(const Duration(seconds: 1), () {
availableAssets.value = [
{'id': 1, 'nama': 'Laptop Dell XPS', 'stok': 5},
{'id': 2, 'nama': 'Proyektor Epson', 'stok': 3},
{'id': 3, 'nama': 'Meja Kantor', 'stok': 10},
{'id': 4, 'nama': 'Kursi Ergonomis', 'stok': 15},
{'id': 5, 'nama': 'Printer HP LaserJet', 'stok': 2},
{'id': 6, 'nama': 'AC Panasonic 1PK', 'stok': 8},
];
try {
final allAssets = await _asetProvider.getSewaAsets();
final selectedAsetIds =
packageItems.map((item) => item['asetId'].toString()).toSet();
// Only show assets not yet selected
availableAssets.value =
allAssets
.where((aset) => !selectedAsetIds.contains(aset.id))
.map(
(aset) => {
'id': aset.id,
'nama': aset.nama,
'stok': aset.kuantitas,
},
)
.toList();
} catch (e) {
availableAssets.value = [];
} finally {
isLoadingAssets.value = false;
});
}
}
// Set the selected asset
void setSelectedAsset(int? assetId) {
void setSelectedAsset(String? assetId) {
selectedAsset.value = assetId;
}
// Get remaining stock for an asset (considering current selections)
int getRemainingStock(int assetId) {
int getRemainingStock(String assetId) {
// Find the asset in available assets
final asset = availableAssets.firstWhere(
(item) => item['id'] == assetId,
@ -129,7 +215,7 @@ class PetugasTambahPaketController extends GetxController {
// Calculate how many of this asset are already in the package
int alreadySelected = 0;
for (var item in packageItems) {
if (item['asetId'] == assetId) {
if (item['asetId'].toString() == assetId) {
alreadySelected += item['jumlah'] as int;
}
}
@ -204,6 +290,8 @@ class PetugasTambahPaketController extends GetxController {
backgroundColor: Colors.green,
colorText: Colors.white,
);
checkFormChanged();
}
// Update an existing package item
@ -301,11 +389,16 @@ class PetugasTambahPaketController extends GetxController {
backgroundColor: Colors.green,
colorText: Colors.white,
);
checkFormChanged();
}
// Remove an item from the package
void removeItem(int index) {
if (index >= 0 && index < packageItems.length) {
packageItems.removeAt(index);
checkFormChanged();
}
Get.snackbar(
'Dihapus',
'Item berhasil dihapus dari paket',
@ -319,10 +412,7 @@ class PetugasTambahPaketController extends GetxController {
void validateForm() {
// Basic validation
bool basicValid =
nameController.text.isNotEmpty &&
descriptionController.text.isNotEmpty &&
priceController.text.isNotEmpty &&
int.tryParse(priceController.text) != null;
nameController.text.isNotEmpty && descriptionController.text.isNotEmpty;
// Package should have at least one item
bool hasItems = packageItems.isNotEmpty;
@ -337,27 +427,191 @@ class PetugasTambahPaketController extends GetxController {
isSubmitting.value = true;
try {
// In a real app, this would make an API call to save the package
await Future.delayed(const Duration(seconds: 1)); // Mock API call
final supabase = Supabase.instance.client;
if (isEditing.value) {
// --- UPDATE LOGIC ---
final paketArg = Get.arguments['paket'];
final String paketId =
paketArg is Map && paketArg['id'] != null
? paketArg['id'].toString()
: (paketArg is PaketModel && paketArg.id != null
? paketArg.id.toString()
: '');
if (paketId.isEmpty) throw Exception('ID paket tidak ditemukan');
// Prepare package data
final paketData = {
// 1. Update data utama paket
await supabase
.from('paket')
.update({
'nama': nameController.text,
'deskripsi': descriptionController.text,
'kategori': selectedCategory.value,
'status': selectedStatus.value == 'Aktif',
'harga': int.parse(priceController.text),
'gambar': selectedImages,
'items': packageItems,
};
'status': selectedStatus.value.toLowerCase(),
})
.eq('id', paketId);
// Log the data (in a real app, this would be sent to an API)
print('Package data: $paketData');
// 2. Update paket_item: hapus semua, insert ulang
await supabase.from('paket_item').delete().eq('paket_id', paketId);
for (var item in packageItems) {
await supabase.from('paket_item').insert({
'paket_id': paketId,
'aset_id': item['asetId'],
'kuantitas': item['jumlah'],
});
}
// Return to the package list page
// 3. Update satuan_waktu_sewa: hapus semua, insert ulang
await supabase
.from('satuan_waktu_sewa')
.delete()
.eq('paket_id', paketId);
// Fetch satuan_waktu UUIDs
final satuanWaktuList = await supabase
.from('satuan_waktu')
.select('id, nama_satuan_waktu');
String? jamId;
String? hariId;
for (var sw in satuanWaktuList) {
final nama = (sw['nama_satuan_waktu'] ?? '').toString().toLowerCase();
if (nama.contains('jam')) jamId = sw['id'];
if (nama.contains('hari')) hariId = sw['id'];
}
if (timeOptions['Per Jam']?.value == true && jamId != null) {
await supabase.from('satuan_waktu_sewa').insert({
'paket_id': paketId,
'satuan_waktu_id': jamId,
'harga': int.tryParse(pricePerHourController.text) ?? 0,
'maksimal_waktu': int.tryParse(maxHourController.text) ?? 0,
});
}
if (timeOptions['Per Hari']?.value == true && hariId != null) {
await supabase.from('satuan_waktu_sewa').insert({
'paket_id': paketId,
'satuan_waktu_id': hariId,
'harga': int.tryParse(pricePerDayController.text) ?? 0,
'maksimal_waktu': int.tryParse(maxDayController.text) ?? 0,
});
}
// 4. Update foto_aset
// a. Ambil foto lama dari DB
final oldPhotos = await supabase
.from('foto_aset')
.select('foto_aset')
.eq('id_paket', paketId);
final oldPhotoUrls =
oldPhotos
.map((e) => e['foto_aset']?.toString())
.whereType<String>()
.toSet();
final newPhotoUrls =
selectedImages
.map((img) => img is String ? img : (img.path ?? ''))
.where((e) => e.isNotEmpty)
.toSet();
// b. Hapus foto yang dihapus user (dari DB dan storage)
final removedPhotos = oldPhotoUrls.difference(newPhotoUrls);
for (final url in removedPhotos) {
await supabase
.from('foto_aset')
.delete()
.eq('foto_aset', url)
.eq('id_paket', paketId);
await _asetProvider.deleteFileFromStorage(url);
}
// c. Tambah foto baru (upload jika perlu, insert ke DB)
for (final img in selectedImages) {
String url = '';
if (img is String && img.startsWith('http')) {
url = img;
} else if (img is XFile) {
final uploaded = await _asetProvider.uploadFileToStorage(
File(img.path),
);
if (uploaded != null) url = uploaded;
}
if (url.isNotEmpty && !oldPhotoUrls.contains(url)) {
await supabase.from('foto_aset').insert({
'id_paket': paketId,
'foto_aset': url,
});
}
}
// Sukses
Get.back();
Get.snackbar(
'Berhasil',
'Paket berhasil diperbarui',
backgroundColor: Colors.green,
colorText: Colors.white,
snackPosition: SnackPosition.BOTTOM,
);
} else {
// --- ADD LOGIC ---
final uuid = Uuid();
final String paketId = uuid.v4();
// 1. Insert ke tabel paket
await supabase.from('paket').insert({
'id': paketId,
'nama': nameController.text,
'deskripsi': descriptionController.text,
'status': selectedStatus.value.toLowerCase(),
});
// 2. Insert ke paket_item
for (var item in packageItems) {
await supabase.from('paket_item').insert({
'paket_id': paketId,
'aset_id': item['asetId'],
'kuantitas': item['jumlah'],
});
}
// 3. Insert ke satuan_waktu_sewa (ambil UUID satuan waktu)
final satuanWaktuList = await supabase
.from('satuan_waktu')
.select('id, nama_satuan_waktu');
String? jamId;
String? hariId;
for (var sw in satuanWaktuList) {
final nama = (sw['nama_satuan_waktu'] ?? '').toString().toLowerCase();
if (nama.contains('jam')) jamId = sw['id'];
if (nama.contains('hari')) hariId = sw['id'];
}
if (timeOptions['Per Jam']?.value == true && jamId != null) {
await supabase.from('satuan_waktu_sewa').insert({
'paket_id': paketId,
'satuan_waktu_id': jamId,
'harga': int.tryParse(pricePerHourController.text) ?? 0,
'maksimal_waktu': int.tryParse(maxHourController.text) ?? 0,
});
}
if (timeOptions['Per Hari']?.value == true && hariId != null) {
await supabase.from('satuan_waktu_sewa').insert({
'paket_id': paketId,
'satuan_waktu_id': hariId,
'harga': int.tryParse(pricePerDayController.text) ?? 0,
'maksimal_waktu': int.tryParse(maxDayController.text) ?? 0,
});
}
// 4. Insert ke foto_aset (upload jika perlu)
for (final img in selectedImages) {
String url = '';
if (img is String && img.startsWith('http')) {
url = img;
} else if (img is XFile) {
final uploaded = await _asetProvider.uploadFileToStorage(
File(img.path),
);
if (uploaded != null) url = uploaded;
}
if (url.isNotEmpty) {
await supabase.from('foto_aset').insert({
'id_paket': paketId,
'foto_aset': url,
});
}
}
// Sukses
Get.back();
// Show success message
Get.snackbar(
'Berhasil',
'Paket berhasil ditambahkan',
@ -365,11 +619,12 @@ class PetugasTambahPaketController extends GetxController {
colorText: Colors.white,
snackPosition: SnackPosition.BOTTOM,
);
}
} catch (e) {
// Show error message
Get.snackbar(
'Gagal',
'Terjadi kesalahan: ${e.toString()}',
'Terjadi kesalahan: \\${e.toString()}',
backgroundColor: Colors.red,
colorText: Colors.white,
snackPosition: SnackPosition.BOTTOM,
@ -390,4 +645,215 @@ class PetugasTambahPaketController extends GetxController {
selectedImages.add('https://example.com/sample_image.jpg');
validateForm();
}
void toggleTimeOption(String option) {
timeOptions[option]?.value = !(timeOptions[option]?.value ?? false);
// Ensure at least one option is selected
bool anySelected = false;
timeOptions.forEach((key, value) {
if (value.value) anySelected = true;
});
if (!anySelected) {
timeOptions[option]?.value = true;
}
validateForm();
checkFormChanged();
}
Future<void> fetchPaketDetail(String paketId) async {
try {
debugPrint('[DEBUG] Fetching paket detail for id: $paketId');
final supabase = Supabase.instance.client;
// 1) Ambil data paket utama
final paketData =
await supabase
.from('paket')
.select('id, nama, deskripsi, status')
.eq('id', paketId)
.single();
debugPrint('[DEBUG] Paket data: ' + paketData.toString());
// 2) Ambil paket_item
final paketItemData = await supabase
.from('paket_item')
.select('id, paket_id, aset_id, kuantitas')
.eq('paket_id', paketId);
debugPrint('[DEBUG] Paket item data: ' + paketItemData.toString());
// 3) Ambil satuan_waktu_sewa
final swsData = await supabase
.from('satuan_waktu_sewa')
.select('id, paket_id, satuan_waktu_id, harga, maksimal_waktu')
.eq('paket_id', paketId);
debugPrint('[DEBUG] Satuan waktu sewa data: ' + swsData.toString());
// 4) Ambil semua satuan_waktu_id dari swsData
final swIds = swsData.map((e) => e['satuan_waktu_id']).toSet().toList();
final swData =
swIds.isNotEmpty
? await supabase
.from('satuan_waktu')
.select('id, nama_satuan_waktu')
.inFilter('id', swIds)
: [];
debugPrint('[DEBUG] Satuan waktu data: ' + swData.toString());
final Map satuanWaktuMap = {
for (var sw in swData) sw['id']: sw['nama_satuan_waktu'],
};
// 5) Ambil foto_aset
final fotoData = await supabase
.from('foto_aset')
.select('id_paket, foto_aset')
.eq('id_paket', paketId);
debugPrint('[DEBUG] Foto aset data: ' + fotoData.toString());
// 6) Kumpulkan semua aset_id dari paketItemData
final asetIds = paketItemData.map((e) => e['aset_id']).toSet().toList();
final asetData =
asetIds.isNotEmpty
? await supabase
.from('aset')
.select('id, nama, kuantitas')
.inFilter('id', asetIds)
: [];
debugPrint('[DEBUG] Aset data: ' + asetData.toString());
final Map asetMap = {for (var a in asetData) a['id']: a};
// Prefill field controller
nameController.text = paketData['nama']?.toString() ?? '';
descriptionController.text = paketData['deskripsi']?.toString() ?? '';
// Status mapping
final statusDb =
(paketData['status']?.toString().toLowerCase() ?? 'tersedia');
selectedStatus.value =
statusDb == 'pemeliharaan' ? 'Pemeliharaan' : 'Tersedia';
// Foto
selectedImages.clear();
if (fotoData.isNotEmpty) {
for (var foto in fotoData) {
final url = foto['foto_aset']?.toString();
if (url != null && url.isNotEmpty) {
selectedImages.add(url);
}
}
}
// Item paket
packageItems.clear();
for (var item in paketItemData) {
final aset = asetMap[item['aset_id']];
packageItems.add({
'asetId': item['aset_id'],
'nama': aset != null ? aset['nama'] : '',
'jumlah': item['kuantitas'],
'stok': aset != null ? aset['kuantitas'] : 0,
});
}
// Opsi waktu & harga sewa
// Reset
timeOptions['Per Jam']?.value = false;
timeOptions['Per Hari']?.value = false;
pricePerHourController.clear();
maxHourController.clear();
pricePerDayController.clear();
maxDayController.clear();
for (var sws in swsData) {
final satuanNama =
satuanWaktuMap[sws['satuan_waktu_id']]?.toString().toLowerCase() ??
'';
if (satuanNama.contains('jam')) {
timeOptions['Per Jam']?.value = true;
pricePerHourController.text = (sws['harga'] ?? '').toString();
maxHourController.text = (sws['maksimal_waktu'] ?? '').toString();
} else if (satuanNama.contains('hari')) {
timeOptions['Per Hari']?.value = true;
pricePerDayController.text = (sws['harga'] ?? '').toString();
maxDayController.text = (sws['maksimal_waktu'] ?? '').toString();
}
}
// Simpan snapshot initialFormData setelah prefill
initialFormData = {
'nama': nameController.text,
'deskripsi': descriptionController.text,
'status': selectedStatus.value,
'images': List.from(selectedImages),
'items': List.from(packageItems),
'perJam': timeOptions['Per Jam']?.value ?? false,
'perHari': timeOptions['Per Hari']?.value ?? false,
'hargaJam': pricePerHourController.text,
'maxJam': maxHourController.text,
'hargaHari': pricePerDayController.text,
'maxHari': maxDayController.text,
};
isFormChanged.value = false;
} catch (e, st) {
debugPrint('[ERROR] Gagal fetch paket detail: $e');
}
}
Future<void> pickImageFromCamera() async {
try {
final XFile? image = await _picker.pickImage(
source: ImageSource.camera,
imageQuality: 80,
maxWidth: 1024,
maxHeight: 1024,
);
if (image != null) {
selectedImages.add(image);
}
} catch (e) {
Get.snackbar(
'Error',
'Gagal mengambil gambar dari kamera: $e',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
}
}
Future<void> pickImageFromGallery() async {
try {
final List<XFile>? images = await _picker.pickMultiImage(
imageQuality: 80,
maxWidth: 1024,
maxHeight: 1024,
);
if (images != null && images.isNotEmpty) {
for (final img in images) {
selectedImages.add(img);
}
}
} catch (e) {
Get.snackbar(
'Error',
'Gagal memilih gambar dari galeri: $e',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
}
}
void checkFormChanged() {
final current = {
'nama': nameController.text,
'deskripsi': descriptionController.text,
'status': selectedStatus.value,
'images': List.from(selectedImages),
'items': List.from(packageItems),
'perJam': timeOptions['Per Jam']?.value ?? false,
'perHari': timeOptions['Per Hari']?.value ?? false,
'hargaJam': pricePerHourController.text,
'maxJam': maxHourController.text,
'hargaHari': pricePerDayController.text,
'maxHari': maxDayController.text,
};
isFormChanged.value = current.toString() != initialFormData.toString();
}
}

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/petugas_aset_controller.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../../../theme/app_colors_petugas.dart';
import '../widgets/petugas_bumdes_bottom_navbar.dart';
import '../widgets/petugas_side_navbar.dart';
@ -23,26 +24,12 @@ class _PetugasAsetViewState extends State<PetugasAsetView>
void initState() {
super.initState();
controller = Get.find<PetugasAsetController>();
_tabController = TabController(length: 2, vsync: this);
// Listen to tab changes and update controller
_tabController.addListener(() {
if (!_tabController.indexIsChanging) {
controller.changeTab(_tabController.index);
}
});
// Listen to controller tab changes and update TabController
ever(controller.selectedTabIndex, (index) {
if (_tabController.index != index) {
_tabController.animateTo(index);
}
});
// Initialize with default tab (sewa)
controller.changeTab(0);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@ -82,7 +69,7 @@ class _PetugasAsetViewState extends State<PetugasAsetView>
body: Column(
children: [
_buildSearchBar(),
_buildTabBar(),
const SizedBox(height: 16),
Expanded(child: _buildAssetList()),
],
),
@ -93,7 +80,13 @@ class _PetugasAsetViewState extends State<PetugasAsetView>
),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => Get.toNamed(Routes.PETUGAS_TAMBAH_ASET),
onPressed: () {
// Navigate to PetugasTambahAsetView in add mode
Get.toNamed(
Routes.PETUGAS_TAMBAH_ASET,
arguments: {'isEditing': false, 'assetData': null},
);
},
backgroundColor: AppColorsPetugas.babyBlueBright,
icon: Icon(Icons.add, color: AppColorsPetugas.blueGrotto),
label: Text(
@ -144,60 +137,19 @@ class _PetugasAsetViewState extends State<PetugasAsetView>
);
}
Widget _buildTabBar() {
return Container(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 0),
decoration: BoxDecoration(
color: AppColorsPetugas.babyBlueLight,
borderRadius: BorderRadius.circular(12),
),
child: TabBar(
controller: _tabController,
labelColor: Colors.white,
unselectedLabelColor: AppColorsPetugas.textSecondary,
indicatorSize: TabBarIndicatorSize.tab,
indicator: BoxDecoration(
color: AppColorsPetugas.blueGrotto,
borderRadius: BorderRadius.circular(12),
),
dividerColor: Colors.transparent,
tabs: const [
Tab(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.shopping_cart, size: 18),
SizedBox(width: 8),
Text('Sewa', style: TextStyle(fontWeight: FontWeight.w600)),
],
),
),
),
Tab(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.subscriptions, size: 18),
SizedBox(width: 8),
Text(
'Langganan',
style: TextStyle(fontWeight: FontWeight.w600),
),
],
),
),
),
],
),
);
}
// Tab bar has been removed as per requirements
Widget _buildAssetList() {
return Obx(() {
debugPrint('_buildAssetList: isLoading=${controller.isLoading.value}');
debugPrint(
'_buildAssetList: filteredAsetList length=${controller.filteredAsetList.length}',
);
if (controller.filteredAsetList.isNotEmpty) {
debugPrint(
'_buildAssetList: First item name=${controller.filteredAsetList[0]['nama']}',
);
}
if (controller.isLoading.value) {
return Center(
child: CircularProgressIndicator(
@ -255,10 +207,15 @@ class _PetugasAsetViewState extends State<PetugasAsetView>
color: AppColorsPetugas.blueGrotto,
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: controller.filteredAsetList.length,
itemCount: controller.filteredAsetList.length + 1,
itemBuilder: (context, index) {
if (index < controller.filteredAsetList.length) {
final aset = controller.filteredAsetList[index];
return _buildAssetCard(context, aset);
} else {
// Blank space at the end
return const SizedBox(height: 80);
}
},
),
);
@ -266,7 +223,31 @@ class _PetugasAsetViewState extends State<PetugasAsetView>
}
Widget _buildAssetCard(BuildContext context, Map<String, dynamic> aset) {
final isAvailable = aset['tersedia'] == true;
debugPrint('\n--- Building Asset Card ---');
debugPrint('Asset data: $aset');
// Extract and validate all asset properties with proper null safety
final status =
aset['status']?.toString().toLowerCase() ?? 'tidak_diketahui';
final isAvailable = status == 'tersedia';
final imageUrl = aset['imageUrl']?.toString() ?? '';
final harga =
aset['harga'] is int
? aset['harga'] as int
: (int.tryParse(aset['harga']?.toString() ?? '0') ?? 0);
final satuanWaktu =
aset['satuan_waktu']?.toString().capitalizeFirst ?? 'Hari';
final nama = aset['nama']?.toString().trim() ?? 'Nama tidak tersedia';
final kategori = aset['kategori']?.toString().trim() ?? 'Umum';
final orderId = aset['order_id']?.toString() ?? '';
// Debug prints for development
debugPrint('Image URL: $imageUrl');
debugPrint('Harga: $harga');
debugPrint('Satuan Waktu: $satuanWaktu');
debugPrint('Nama: $nama');
debugPrint('Kategori: $kategori');
debugPrint('Status: $status (Available: $isAvailable)');
return Container(
margin: const EdgeInsets.only(bottom: 12),
@ -290,24 +271,49 @@ class _PetugasAsetViewState extends State<PetugasAsetView>
child: Row(
children: [
// Asset image
Container(
SizedBox(
width: 80,
height: 80,
decoration: BoxDecoration(
color: AppColorsPetugas.babyBlueLight,
child: ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
bottomLeft: Radius.circular(12),
),
),
child: CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
placeholder:
(context, url) => Container(
color: AppColorsPetugas.babyBlueLight,
child: Center(
child: Icon(
_getAssetIcon(aset['kategori']),
color: AppColorsPetugas.navyBlue,
_getAssetIcon(
kategori,
), // Show category icon as placeholder
color: AppColorsPetugas.navyBlue.withOpacity(
0.5,
),
size: 32,
),
),
),
errorWidget:
(context, url, error) => Container(
color: AppColorsPetugas.babyBlueLight,
child: Center(
child: Icon(
Icons
.broken_image, // Or your preferred error icon
color: AppColorsPetugas.navyBlue.withOpacity(
0.5,
),
size: 32,
),
),
),
),
),
),
// Asset info
Expanded(
@ -323,8 +329,8 @@ class _PetugasAsetViewState extends State<PetugasAsetView>
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
aset['nama'],
style: TextStyle(
nama,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: AppColorsPetugas.navyBlue,
@ -333,12 +339,63 @@ class _PetugasAsetViewState extends State<PetugasAsetView>
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'${controller.formatPrice(aset['harga'])} ${aset['satuan']}',
// Harga dan satuan waktu (multi-line, tampilkan semua dari satuanWaktuSewa)
Builder(
builder: (context) {
final satuanWaktuList =
(aset['satuanWaktuSewa'] is List)
? List<Map<String, dynamic>>.from(
aset['satuanWaktuSewa'],
)
: [];
final validSatuanWaktu =
satuanWaktuList
.where(
(sw) =>
(sw['harga'] ?? 0) > 0 &&
(sw['nama_satuan_waktu'] !=
null &&
(sw['nama_satuan_waktu']
as String)
.isNotEmpty),
)
.toList();
if (validSatuanWaktu.isNotEmpty) {
return Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children:
validSatuanWaktu.map((sw) {
final harga = sw['harga'] ?? 0;
final satuan =
sw['nama_satuan_waktu'] ?? '';
return Text(
'${controller.formatPrice(harga)} / $satuan',
style: TextStyle(
fontSize: 12,
color:
AppColorsPetugas
.textSecondary,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
);
}).toList(),
);
} else {
// fallback: harga tunggal
return Text(
'${controller.formatPrice(aset['harga'] ?? 0)} / ${aset['satuan_waktu']?.toString().capitalizeFirst ?? 'Hari'}',
style: TextStyle(
fontSize: 12,
color: AppColorsPetugas.textSecondary,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
);
}
},
),
],
),
@ -383,11 +440,36 @@ class _PetugasAsetViewState extends State<PetugasAsetView>
children: [
// Edit icon
GestureDetector(
onTap:
() => _showAddEditAssetDialog(
context,
aset: aset,
),
onTap: () {
// Navigate to PetugasTambahAsetView in edit mode with only the asset ID
final assetId =
aset['id']?.toString() ??
''; // Changed from 'id_aset' to 'id'
debugPrint(
'[DEBUG] Navigating to edit asset with ID: $assetId',
);
debugPrint(
'[DEBUG] Full asset data: $aset',
); // Log full asset data for debugging
if (assetId.isEmpty) {
debugPrint('[ERROR] Asset ID is empty!');
Get.snackbar(
'Error',
'ID Aset tidak valid',
snackPosition: SnackPosition.BOTTOM,
);
return;
}
Get.toNamed(
Routes.PETUGAS_TAMBAH_ASET,
arguments: {
'isEditing': true,
'assetId': assetId,
},
);
},
child: Container(
padding: const EdgeInsets.all(5),
decoration: BoxDecoration(

View File

@ -5,6 +5,7 @@ import '../controllers/petugas_bumdes_dashboard_controller.dart';
import '../widgets/petugas_bumdes_bottom_navbar.dart';
import '../widgets/petugas_side_navbar.dart';
import '../../../theme/app_colors_petugas.dart';
import '../../../utils/format_utils.dart';
class PetugasBumdesDashboardView
extends GetView<PetugasBumdesDashboardController> {
@ -23,12 +24,7 @@ class PetugasBumdesDashboardView
backgroundColor: AppColorsPetugas.navyBlue,
foregroundColor: Colors.white,
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () => _showLogoutConfirmation(context),
),
],
// actions: [],
),
drawer: PetugasSideNavbar(controller: controller),
drawerEdgeDragWidth: 60,
@ -118,8 +114,6 @@ class PetugasBumdesDashboardView
),
_buildRevenueStatistics(),
const SizedBox(height: 16),
_buildRevenueSources(),
const SizedBox(height: 16),
_buildRevenueTrend(),
// Add some padding at the bottom for better scrolling
@ -156,7 +150,31 @@ class PetugasBumdesDashboardView
children: [
Row(
children: [
Container(
Obx(() {
final avatar = controller.avatarUrl.value;
if (avatar.isNotEmpty) {
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
avatar,
width: 48,
height: 48,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) => Container(
width: 48,
height: 48,
color: Colors.white.withOpacity(0.2),
child: const Icon(
Icons.person,
color: Colors.white,
size: 30,
),
),
),
);
} else {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
@ -174,7 +192,9 @@ class PetugasBumdesDashboardView
color: Colors.white,
size: 30,
),
),
);
}
}),
const SizedBox(width: 16),
Expanded(
child: Column(
@ -208,15 +228,17 @@ class PetugasBumdesDashboardView
),
),
const SizedBox(height: 4),
Obx(
() => Text(
controller.userEmail.value,
Obx(() {
final name = controller.userName.value;
final email = controller.userEmail.value;
return Text(
name.isNotEmpty ? name : email,
style: const TextStyle(
fontSize: 14,
color: Colors.white70,
),
),
),
);
}),
],
),
),
@ -642,19 +664,24 @@ class PetugasBumdesDashboardView
),
),
const SizedBox(height: 10),
Obx(
() => Text(
controller.totalPendapatanBulanIni.value,
Obx(() {
final stats = controller.pembayaranStats;
final total = stats['totalThisMonth'] ?? 0.0;
return Text(
formatRupiah(total),
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.success,
),
),
),
);
}),
const SizedBox(height: 6),
Obx(
() => Row(
Obx(() {
final stats = controller.pembayaranStats;
final percent = stats['percentComparedLast'] ?? 0.0;
final isPositive = percent >= 0;
return Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
@ -663,7 +690,7 @@ class PetugasBumdesDashboardView
),
decoration: BoxDecoration(
color:
controller.isKenaikanPositif.value
isPositive
? AppColorsPetugas.success.withOpacity(
0.1,
)
@ -676,23 +703,23 @@ class PetugasBumdesDashboardView
mainAxisSize: MainAxisSize.min,
children: [
Icon(
controller.isKenaikanPositif.value
isPositive
? Icons.arrow_upward
: Icons.arrow_downward,
size: 14,
color:
controller.isKenaikanPositif.value
isPositive
? AppColorsPetugas.success
: AppColorsPetugas.error,
),
const SizedBox(width: 4),
Text(
controller.persentaseKenaikan.value,
'${percent.toStringAsFixed(1)}%',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color:
controller.isKenaikanPositif.value
isPositive
? AppColorsPetugas.success
: AppColorsPetugas.error,
),
@ -709,8 +736,8 @@ class PetugasBumdesDashboardView
),
),
],
),
),
);
}),
],
),
),
@ -747,12 +774,29 @@ class PetugasBumdesDashboardView
return Row(
children: [
Expanded(
child: _buildRevenueQuickInfo(
'Pendapatan Sewa',
controller.pendapatanSewa.value,
child: Obx(() {
final stats = controller.pembayaranStats;
final totalTunai = stats['totalTunai'] ?? 0.0;
return _buildRevenueQuickInfo(
'Tunai',
formatRupiah(totalTunai),
AppColorsPetugas.navyBlue,
Icons.shopping_cart_outlined,
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,
);
}),
),
],
);
@ -811,81 +855,6 @@ class PetugasBumdesDashboardView
);
}
Widget _buildRevenueSources() {
return Card(
elevation: 2,
shadowColor: AppColorsPetugas.shadowColor,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Sumber Pendapatan',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.navyBlue,
),
),
const SizedBox(height: 20),
Row(
children: [
// Revenue Donut Chart
Expanded(
flex: 2,
child: Column(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColorsPetugas.navyBlue.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Text(
'Sewa Aset',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.navyBlue,
),
),
const SizedBox(height: 8),
Obx(
() => Text(
controller.pendapatanSewa.value,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.navyBlue,
),
),
),
const SizedBox(height: 8),
Text(
'100% dari total pendapatan',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade700,
),
),
],
),
),
],
),
),
],
),
],
),
),
);
}
Widget _buildRevenueTrend() {
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun'];
@ -912,6 +881,9 @@ class PetugasBumdesDashboardView
child: Obx(() {
// Get the trend data from controller
final List<double> trendData = controller.trendPendapatan;
if (trendData.isEmpty) {
return Center(child: Text('Tidak ada data'));
}
final double maxValue = trendData.reduce(
(curr, next) => curr > next ? curr : next,
);
@ -925,28 +897,28 @@ class PetugasBumdesDashboardView
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${maxValue.toStringAsFixed(1)}M',
formatRupiah(maxValue),
style: TextStyle(
fontSize: 10,
color: AppColorsPetugas.textSecondary,
),
),
Text(
'${(maxValue * 0.75).toStringAsFixed(1)}M',
formatRupiah(maxValue * 0.75),
style: TextStyle(
fontSize: 10,
color: AppColorsPetugas.textSecondary,
),
),
Text(
'${(maxValue * 0.5).toStringAsFixed(1)}M',
formatRupiah(maxValue * 0.5),
style: TextStyle(
fontSize: 10,
color: AppColorsPetugas.textSecondary,
),
),
Text(
'${(maxValue * 0.25).toStringAsFixed(1)}M',
formatRupiah(maxValue * 0.25),
style: TextStyle(
fontSize: 10,
color: AppColorsPetugas.textSecondary,

View File

@ -1,11 +1,13 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/petugas_paket_controller.dart';
import '../../../theme/app_colors_petugas.dart';
import 'package:bumrent_app/app/modules/petugas_bumdes/controllers/petugas_paket_controller.dart';
import 'package:bumrent_app/app/routes/app_pages.dart';
import 'package:bumrent_app/app/data/models/paket_model.dart';
import '../widgets/petugas_bumdes_bottom_navbar.dart';
import '../widgets/petugas_side_navbar.dart';
import '../controllers/petugas_bumdes_dashboard_controller.dart';
import '../../../routes/app_routes.dart';
import '../../../theme/app_colors_petugas.dart';
class PetugasPaketView extends GetView<PetugasPaketController> {
const PetugasPaketView({Key? key}) : super(key: key);
@ -53,7 +55,11 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => Get.toNamed(Routes.PETUGAS_TAMBAH_PAKET),
onPressed:
() => Get.toNamed(
Routes.PETUGAS_TAMBAH_PAKET,
arguments: {'isEditing': false},
),
label: Text(
'Tambah Paket',
style: TextStyle(
@ -115,7 +121,7 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
);
}
if (controller.filteredPaketList.isEmpty) {
if (controller.filteredPackages.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@ -136,7 +142,11 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => Get.toNamed(Routes.PETUGAS_TAMBAH_PAKET),
onPressed:
() => Get.toNamed(
Routes.PETUGAS_TAMBAH_PAKET,
arguments: {'isEditing': false},
),
icon: const Icon(Icons.add),
label: const Text('Tambah Paket'),
style: ElevatedButton.styleFrom(
@ -161,18 +171,192 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
color: AppColorsPetugas.blueGrotto,
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: controller.filteredPaketList.length,
itemCount: controller.filteredPackages.length + 1,
itemBuilder: (context, index) {
final paket = controller.filteredPaketList[index];
if (index < controller.filteredPackages.length) {
final paket = controller.filteredPackages[index];
return _buildPaketCard(context, paket);
} else {
// Blank space at the end
return const SizedBox(height: 80);
}
},
),
);
});
}
Widget _buildPaketCard(BuildContext context, Map<String, dynamic> paket) {
final isAvailable = paket['tersedia'] == true;
// Format price helper method
String _formatPrice(dynamic price) {
if (price == null) return '0';
// If price is a string that can be parsed to a number
if (price is String) {
final number = double.tryParse(price) ?? 0;
return number
.toStringAsFixed(0)
.replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
(Match m) => '${m[1]}.',
);
}
// If price is already a number
if (price is num) {
return price
.toStringAsFixed(0)
.replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
(Match m) => '${m[1]}.',
);
}
return '0';
}
// Helper method to get time unit name based on ID
String _getTimeUnitName(dynamic unitId) {
if (unitId == null) return 'unit';
// Convert to string in case it's not already
final unitIdStr = unitId.toString().toLowerCase();
// Map of known time unit IDs to their display names
final timeUnitMap = {
'6eaa32d9-855d-4214-b5b5-5c73d3edd9c5': 'jam',
'582b7e66-6869-4495-9856-cef4a46683b0': 'hari',
// Add more mappings as needed
};
// If the unitId is a known ID, return the corresponding name
if (timeUnitMap.containsKey(unitIdStr)) {
return timeUnitMap[unitIdStr]!;
}
// Check if the unit is already a name (like 'jam' or 'hari')
final knownUnits = ['jam', 'hari', 'minggu', 'bulan'];
if (knownUnits.contains(unitIdStr)) {
return unitIdStr;
}
// If the unit is a Map, try to extract the name from common fields
if (unitId is Map) {
return unitId['nama']?.toString().toLowerCase() ??
unitId['name']?.toString().toLowerCase() ??
unitId['satuan_waktu']?.toString().toLowerCase() ??
'unit';
}
// Default fallback
return 'unit';
}
// Helper method to log time unit details
void _logTimeUnitDetails(
String packageName,
List<Map<String, dynamic>> timeUnits,
) {
debugPrint('\n📦 [DEBUG] Package: $packageName');
debugPrint('🔄 Found ${timeUnits.length} time units:');
for (var i = 0; i < timeUnits.length; i++) {
final unit = timeUnits[i];
debugPrint('\n ⏱️ Time Unit #${i + 1}:');
// Log all available keys and values
debugPrint(' ├─ All fields: $unit');
// Log specific fields we're interested in
unit.forEach((key, value) {
debugPrint(' ├─ $key: $value (${value.runtimeType})');
});
// Special handling for satuan_waktu if it's a map
if (unit['satuan_waktu'] is Map) {
final satuanWaktu = unit['satuan_waktu'] as Map;
debugPrint(' └─ satuan_waktu details:');
satuanWaktu.forEach((k, v) {
debugPrint(' ├─ $k: $v (${v.runtimeType})');
});
}
}
debugPrint('\n');
}
Widget _buildPaketCard(BuildContext context, dynamic paket) {
// Handle both Map and PaketModel for backward compatibility
final isPaketModel = paket is PaketModel;
debugPrint('\n🔍 [_buildPaketCard] Paket type: ${paket.runtimeType}');
debugPrint('📋 Paket data: $paket');
// Extract status based on type
final String status =
isPaketModel
? (paket.status?.toString().capitalizeFirst ?? 'Tidak Diketahui')
: (paket['status']?.toString().capitalizeFirst ??
'Tidak Diketahui');
debugPrint('🏷️ Extracted status: $status (isPaketModel: $isPaketModel)');
// Extract availability based on type
final bool isAvailable =
isPaketModel
? (paket.kuantitas > 0)
: ((paket['kuantitas'] as int?) ?? 0) > 0;
final String nama =
isPaketModel
? paket.nama
: (paket['nama']?.toString() ?? 'Paket Tanpa Nama');
// Debug package info
debugPrint('\n📦 [PACKAGE] ${paket.runtimeType} - $nama');
debugPrint('├─ isPaketModel: $isPaketModel');
debugPrint('├─ Available: $isAvailable');
// Get the first rental time unit price if available, otherwise use the base price
final dynamic harga;
if (isPaketModel) {
if (paket.satuanWaktuSewa.isNotEmpty) {
_logTimeUnitDetails(nama, paket.satuanWaktuSewa);
// Get the first time unit with its price
final firstUnit = paket.satuanWaktuSewa.first;
final firstUnitPrice = firstUnit['harga'];
debugPrint('💰 First time unit price: $firstUnitPrice');
debugPrint('⏱️ First time unit ID: ${firstUnit['satuan_waktu_id']}');
debugPrint('📝 First time unit details: $firstUnit');
// Always use the first time unit's price if available
harga = firstUnitPrice ?? 0;
} else {
debugPrint('⚠️ No time units found for package: $nama');
debugPrint(' Using base price: ${paket.harga}');
harga = paket.harga;
}
} else {
// For non-PaketModel (Map) data
if (isPaketModel && paket.satuanWaktuSewa.isNotEmpty) {
final firstUnit = paket.satuanWaktuSewa.first;
final firstUnitPrice = firstUnit['harga'];
debugPrint('💰 [MAP] First time unit price: $firstUnitPrice');
harga = firstUnitPrice ?? 0;
} else {
debugPrint('⚠️ [MAP] No time units found for package: $nama');
debugPrint(' [MAP] Using base price: ${paket['harga']}');
harga = paket['harga'] ?? 0;
}
}
debugPrint('💵 Final price being used: $harga\n');
// Get the main photo URL
final String? foto =
isPaketModel
? (paket.images?.isNotEmpty == true
? paket.images!.first
: paket.foto_paket)
: (paket['foto_paket']?.toString() ??
(paket['foto'] is String ? paket['foto'] : null));
return Container(
margin: const EdgeInsets.only(bottom: 12),
@ -196,24 +380,85 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
child: Row(
children: [
// Paket image or icon
Container(
SizedBox(
width: 80,
height: 80,
decoration: BoxDecoration(
color: AppColorsPetugas.babyBlueLight,
child: ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
bottomLeft: Radius.circular(12),
),
),
child:
foto != null && foto.isNotEmpty
? Image.network(
foto,
width: 80,
height: 80,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) => Container(
color: AppColorsPetugas.babyBlueLight,
child: Center(
child: Icon(
_getPaketIcon(paket['kategori']),
color: AppColorsPetugas.navyBlue,
_getPaketIcon(
_getTimeUnitName(
isPaketModel
? (paket
.satuanWaktuSewa
.isNotEmpty
? paket
.satuanWaktuSewa
.first['satuan_waktu_id'] ??
'hari'
: 'hari')
: (paket['satuanWaktuSewa'] !=
null &&
paket['satuanWaktuSewa']
.isNotEmpty
? paket['satuanWaktuSewa'][0]['satuan_waktu_id']
?.toString() ??
'hari'
: 'hari'),
),
),
color: AppColorsPetugas.navyBlue
.withOpacity(0.5),
size: 32,
),
),
),
)
: Container(
color: AppColorsPetugas.babyBlueLight,
child: Center(
child: Icon(
_getPaketIcon(
_getTimeUnitName(
isPaketModel
? (paket.satuanWaktuSewa.isNotEmpty
? paket
.satuanWaktuSewa
.first['satuan_waktu_id'] ??
'hari'
: 'hari')
: (paket['satuanWaktuSewa'] != null &&
paket['satuanWaktuSewa']
.isNotEmpty
? paket['satuanWaktuSewa'][0]['satuan_waktu_id']
?.toString() ??
'hari'
: 'hari'),
),
),
color: AppColorsPetugas.navyBlue.withOpacity(
0.5,
),
size: 32,
),
),
),
),
),
// Paket info
Expanded(
@ -228,9 +473,10 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Package name
Text(
paket['nama'],
style: TextStyle(
nama,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: AppColorsPetugas.navyBlue,
@ -239,6 +485,111 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// Prices with time units
Builder(
builder: (context) {
final List<Map<String, dynamic>> timeUnits =
[];
// Get all time units
if (isPaketModel &&
paket.satuanWaktuSewa.isNotEmpty) {
timeUnits.addAll(paket.satuanWaktuSewa);
} else if (!isPaketModel &&
paket['satuanWaktuSewa'] != null &&
paket['satuanWaktuSewa'].isNotEmpty) {
timeUnits.addAll(
List<Map<String, dynamic>>.from(
paket['satuanWaktuSewa'],
),
);
}
// If no time units, show nothing
if (timeUnits.isEmpty)
return const SizedBox.shrink();
// Filter out time units with price 0 or null
final validTimeUnits =
timeUnits.where((unit) {
final price =
unit['harga'] is int
? unit['harga']
: int.tryParse(
unit['harga']
?.toString() ??
'0',
) ??
0;
return price > 0;
}).toList();
if (validTimeUnits.isEmpty)
return const SizedBox.shrink();
return Column(
children:
validTimeUnits
.asMap()
.entries
.map((entry) {
final index = entry.key;
final unit = entry.value;
final unitPrice =
unit['harga'] is int
? unit['harga']
: int.tryParse(
unit['harga']
?.toString() ??
'0',
) ??
0;
final unitName = _getTimeUnitName(
unit['satuan_waktu_id'],
);
final isFirst = index == 0;
if (unitPrice <= 0)
return const SizedBox.shrink();
return Row(
children: [
Flexible(
child: Text(
'Rp ${_formatPrice(unitPrice)}/$unitName',
style: TextStyle(
fontSize: 12,
color:
AppColorsPetugas
.textSecondary,
),
maxLines: 2,
overflow:
TextOverflow.ellipsis,
softWrap: true,
),
),
],
);
})
.where(
(widget) => widget is! SizedBox,
)
.toList(),
);
},
),
if (!isPaketModel &&
paket['harga'] != null &&
(paket['harga'] is int
? paket['harga']
: int.tryParse(
paket['harga']?.toString() ??
'0',
) ??
0) >
0) ...[
const SizedBox(height: 4),
Text(
'Rp ${_formatPrice(paket['harga'])}',
style: TextStyle(
@ -247,6 +598,7 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
),
),
],
],
),
),
@ -258,25 +610,31 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
),
decoration: BoxDecoration(
color:
isAvailable
status.toLowerCase() == 'tersedia'
? AppColorsPetugas.successLight
: status.toLowerCase() == 'pemeliharaan'
? AppColorsPetugas.warningLight
: AppColorsPetugas.errorLight,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color:
isAvailable
status.toLowerCase() == 'tersedia'
? AppColorsPetugas.success
: status.toLowerCase() == 'pemeliharaan'
? AppColorsPetugas.warning
: AppColorsPetugas.error,
width: 1,
),
),
child: Text(
isAvailable ? 'Aktif' : 'Nonaktif',
status,
style: TextStyle(
fontSize: 10,
color:
isAvailable
status.toLowerCase() == 'tersedia'
? AppColorsPetugas.success
: status.toLowerCase() == 'pemeliharaan'
? AppColorsPetugas.warning
: AppColorsPetugas.error,
fontWeight: FontWeight.w500,
),
@ -290,9 +648,12 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
// Edit icon
GestureDetector(
onTap:
() => _showAddEditPaketDialog(
context,
paket: paket,
() => Get.toNamed(
Routes.PETUGAS_TAMBAH_PAKET,
arguments: {
'isEditing': true,
'paket': paket,
},
),
child: Container(
padding: const EdgeInsets.all(5),
@ -350,33 +711,42 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
);
}
String _formatPrice(dynamic price) {
if (price == null) return '0';
// Convert the price to string and handle formatting
String priceStr = price.toString();
// Add thousand separators
final RegExp reg = RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))');
String formatted = priceStr.replaceAllMapped(reg, (Match m) => '${m[1]}.');
return formatted;
// Add this helper method to get color based on status
Color _getStatusColor(String status) {
switch (status.toLowerCase()) {
case 'aktif':
return AppColorsPetugas.success;
case 'tidak aktif':
case 'nonaktif':
return AppColorsPetugas.error;
case 'dalam perbaikan':
case 'maintenance':
return AppColorsPetugas.warning;
case 'tersedia':
return AppColorsPetugas.success;
case 'pemeliharaan':
return AppColorsPetugas.warning;
default:
return Colors.grey;
}
}
IconData _getPaketIcon(String? category) {
if (category == null) return Icons.category;
IconData _getPaketIcon(String? timeUnit) {
if (timeUnit == null) return Icons.access_time;
switch (category.toLowerCase()) {
case 'bulanan':
return Icons.calendar_month;
case 'tahunan':
switch (timeUnit.toLowerCase()) {
case 'jam':
return Icons.access_time;
case 'hari':
return Icons.calendar_today;
case 'premium':
return Icons.star;
case 'bisnis':
return Icons.business;
case 'minggu':
return Icons.date_range;
case 'bulan':
return Icons.calendar_month;
case 'tahun':
return Icons.calendar_view_month;
default:
return Icons.category;
return Icons.access_time;
}
}
@ -426,7 +796,27 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
);
}
void _showPaketDetails(BuildContext context, Map<String, dynamic> paket) {
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<Map<String, dynamic>> items = [];
showModalBottomSheet(
context: context,
isScrollControlled: true,
@ -448,7 +838,7 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
children: [
Expanded(
child: Text(
paket['nama'],
nama,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
@ -473,16 +863,15 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailItem('Kategori', paket['kategori']),
_buildDetailItem(
'Harga',
controller.formatPrice(paket['harga']),
'Rp ${_formatPrice(harga)}',
),
_buildDetailItem(
'Status',
paket['tersedia'] ? 'Tersedia' : 'Tidak Tersedia',
isAvailable ? 'Tersedia' : 'Tidak Tersedia',
),
_buildDetailItem('Deskripsi', paket['deskripsi']),
_buildDetailItem('Deskripsi', deskripsi ?? '-'),
],
),
),
@ -502,11 +891,11 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
child: ListView.separated(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: paket['items'].length,
itemCount: items.length,
separatorBuilder:
(context, index) => const Divider(height: 1),
itemBuilder: (context, index) {
final item = paket['items'][index];
final item = items[index];
return ListTile(
leading: CircleAvatar(
backgroundColor: AppColorsPetugas.babyBlue,
@ -601,10 +990,11 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
);
}
void _showAddEditPaketDialog(
BuildContext context, {
Map<String, dynamic>? paket,
}) {
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
@ -613,7 +1003,7 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
builder: (context) {
return AlertDialog(
title: Text(
isEditing ? 'Edit Paket' : 'Tambah Paket Baru',
title,
style: TextStyle(color: AppColorsPetugas.navyBlue),
),
content: const Text(
@ -652,10 +1042,13 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
);
}
void _showDeleteConfirmation(
BuildContext context,
Map<String, dynamic> paket,
) {
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) {
@ -664,9 +1057,7 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
'Konfirmasi Hapus',
style: TextStyle(color: AppColorsPetugas.navyBlue),
),
content: Text(
'Apakah Anda yakin ingin menghapus paket "${paket['nama']}"?',
),
content: Text('Apakah Anda yakin ingin menghapus paket "$nama"?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
@ -678,7 +1069,7 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
ElevatedButton(
onPressed: () {
Navigator.pop(context);
controller.deletePaket(paket['id']);
controller.deletePaket(id);
Get.snackbar(
'Paket Dihapus',
'Paket berhasil dihapus dari sistem',

View File

@ -6,6 +6,7 @@ import '../widgets/petugas_bumdes_bottom_navbar.dart';
import '../widgets/petugas_side_navbar.dart';
import '../controllers/petugas_bumdes_dashboard_controller.dart';
import 'petugas_detail_sewa_view.dart';
import '../../../data/models/rental_booking_model.dart';
class PetugasSewaView extends StatefulWidget {
const PetugasSewaView({Key? key}) : super(key: key);
@ -160,6 +161,10 @@ class _PetugasSewaViewState extends State<PetugasSewaView>
}
Widget _buildSearchSection() {
// Tambahkan controller untuk TextField agar bisa dikosongkan
final TextEditingController searchController = TextEditingController(
text: controller.searchQuery.value,
);
return Container(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
decoration: BoxDecoration(
@ -173,9 +178,9 @@ class _PetugasSewaViewState extends State<PetugasSewaView>
],
),
child: TextField(
controller: searchController,
onChanged: (value) {
controller.setSearchQuery(value);
controller.setOrderIdQuery(value);
},
decoration: InputDecoration(
hintText: 'Cari nama warga atau ID pesanan...',
@ -204,11 +209,22 @@ class _PetugasSewaViewState extends State<PetugasSewaView>
),
contentPadding: EdgeInsets.zero,
isDense: true,
suffixIcon: Icon(
Icons.tune_rounded,
suffixIcon: Obx(
() =>
controller.searchQuery.value.isNotEmpty
? IconButton(
icon: Icon(
Icons.close,
color: AppColorsPetugas.textSecondary,
size: 20,
),
onPressed: () {
searchController.clear();
controller.setSearchQuery('');
},
)
: SizedBox.shrink(),
),
),
),
);
@ -241,17 +257,44 @@ class _PetugasSewaViewState extends State<PetugasSewaView>
final filteredList =
status == 'Semua'
? controller.filteredSewaList
: status == 'Menunggu Pembayaran'
? controller.sewaList
.where(
(sewa) =>
sewa.status.toUpperCase() == 'MENUNGGU PEMBAYARAN' ||
sewa.status.toUpperCase() == 'PEMBAYARAN DENDA',
)
.toList()
: status == 'Periksa Pembayaran'
? controller.sewaList
.where(
(sewa) =>
sewa['status'] == 'Periksa Pembayaran' ||
sewa['status'] == 'Pembayaran Denda' ||
sewa['status'] == 'Periksa Denda',
sewa.status.toUpperCase() == 'PERIKSA PEMBAYARAN' ||
sewa.status.toUpperCase() == 'PERIKSA PEMBAYARAN DENDA',
)
.toList()
: status == 'Diterima'
? controller.sewaList
.where((sewa) => sewa.status.toUpperCase() == 'DITERIMA')
.toList()
: status == 'Aktif'
? controller.sewaList
.where((sewa) => sewa.status.toUpperCase() == 'AKTIF')
.toList()
: status == 'Dikembalikan'
? controller.sewaList
.where((sewa) => sewa.status.toUpperCase() == 'DIKEMBALIKAN')
.toList()
: status == 'Selesai'
? controller.sewaList
.where((sewa) => sewa.status.toUpperCase() == 'SELESAI')
.toList()
: status == 'Dibatalkan'
? controller.sewaList
.where((sewa) => sewa.status.toUpperCase() == 'DIBATALKAN')
.toList()
: controller.sewaList
.where((sewa) => sewa['status'] == status)
.where((sewa) => sewa.status == status)
.toList();
if (filteredList.isEmpty) {
@ -313,40 +356,25 @@ class _PetugasSewaViewState extends State<PetugasSewaView>
});
}
Widget _buildSewaCard(BuildContext context, Map<String, dynamic> sewa) {
final statusColor = controller.getStatusColor(sewa['status']);
final status = sewa['status'];
Widget _buildSewaCard(BuildContext context, SewaModel sewa) {
final statusColor = controller.getStatusColor(sewa.status);
final status = sewa.status;
// Get appropriate icon for status
IconData statusIcon;
switch (status) {
case 'Menunggu Pembayaran':
statusIcon = Icons.payments_outlined;
break;
case 'Periksa Pembayaran':
statusIcon = Icons.fact_check_outlined;
break;
case 'Diterima':
statusIcon = Icons.check_circle_outlined;
break;
case 'Pembayaran Denda':
statusIcon = Icons.money_off_csred_outlined;
break;
case 'Periksa Denda':
statusIcon = Icons.assignment_late_outlined;
break;
case 'Dikembalikan':
statusIcon = Icons.assignment_return_outlined;
break;
case 'Selesai':
statusIcon = Icons.task_alt_outlined;
break;
case 'Dibatalkan':
statusIcon = Icons.cancel_outlined;
break;
default:
statusIcon = Icons.help_outline_rounded;
}
IconData statusIcon = controller.getStatusIcon(status);
// Flag untuk membedakan tipe pesanan
final bool isAset = sewa.tipePesanan == 'tunggal';
final bool isPaket = sewa.tipePesanan == 'paket';
// Pilih nama aset/paket
final String namaAsetAtauPaket =
isAset
? (sewa.asetNama ?? '-')
: (isPaket ? (sewa.paketNama ?? '-') : '-');
// Pilih foto aset/paket jika ingin digunakan
final String? fotoAsetAtauPaket =
isAset ? sewa.asetFoto : (isPaket ? sewa.paketFoto : null);
return Container(
margin: const EdgeInsets.only(bottom: 16),
@ -370,6 +398,35 @@ class _PetugasSewaViewState extends State<PetugasSewaView>
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Status header inside the card
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.12),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Icon(statusIcon, size: 16, color: statusColor),
const SizedBox(width: 8),
Text(
status,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: statusColor,
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 0),
child: Row(
@ -378,14 +435,22 @@ class _PetugasSewaViewState extends State<PetugasSewaView>
CircleAvatar(
radius: 24,
backgroundColor: AppColorsPetugas.babyBlueLight,
child: Text(
sewa['nama_warga'].substring(0, 1).toUpperCase(),
backgroundImage:
(sewa.wargaAvatar != null &&
sewa.wargaAvatar.isNotEmpty)
? NetworkImage(sewa.wargaAvatar)
: null,
child:
(sewa.wargaAvatar == null || sewa.wargaAvatar.isEmpty)
? Text(
sewa.wargaNama.substring(0, 1).toUpperCase(),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.blueGrotto,
),
),
)
: null,
),
const SizedBox(width: 16),
@ -395,58 +460,25 @@ class _PetugasSewaViewState extends State<PetugasSewaView>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
sewa['nama_warga'],
sewa.wargaNama,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.textPrimary,
),
),
const SizedBox(height: 2),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.15),
borderRadius: BorderRadius.circular(30),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
statusIcon,
size: 12,
color: statusColor,
),
const SizedBox(width: 4),
Text(
status,
'Tanggal Pesan: ' +
(sewa.tanggalPemesanan != null
? '${sewa.tanggalPemesanan.day.toString().padLeft(2, '0')}-${sewa.tanggalPemesanan.month.toString().padLeft(2, '0')}-${sewa.tanggalPemesanan.year}'
: '-'),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: statusColor,
),
),
],
),
),
const SizedBox(width: 8),
Text(
'#${sewa['order_id']}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColorsPetugas.textSecondary,
),
),
],
),
],
),
),
// Price
@ -460,7 +492,7 @@ class _PetugasSewaViewState extends State<PetugasSewaView>
borderRadius: BorderRadius.circular(8),
),
child: Text(
controller.formatPrice(sewa['total_biaya']),
controller.formatPrice(sewa.totalTagihan),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
@ -481,12 +513,30 @@ class _PetugasSewaViewState extends State<PetugasSewaView>
child: Divider(height: 1, color: Colors.grey.shade200),
),
// Asset details
// Asset/Paket details
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
child: Row(
children: [
// Asset icon
// Asset/Paket image or icon
if (fotoAsetAtauPaket != null &&
fotoAsetAtauPaket.isNotEmpty)
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.network(
fotoAsetAtauPaket,
width: 40,
height: 40,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) => Icon(
Icons.inventory_2_outlined,
size: 28,
color: AppColorsPetugas.blueGrotto,
),
),
)
else
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
@ -501,13 +551,13 @@ class _PetugasSewaViewState extends State<PetugasSewaView>
),
const SizedBox(width: 12),
// Asset name and duration
// Asset/Paket name and duration
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
sewa['nama_aset'],
namaAsetAtauPaket,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
@ -524,7 +574,7 @@ class _PetugasSewaViewState extends State<PetugasSewaView>
),
const SizedBox(width: 4),
Text(
'${sewa['tanggal_mulai']} - ${sewa['tanggal_selesai']}',
'${sewa.waktuMulai.toIso8601String().substring(0, 10)} - ${sewa.waktuSelesai.toIso8601String().substring(0, 10)}',
style: TextStyle(
fontSize: 12,
color: AppColorsPetugas.textSecondary,

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import '../../../theme/app_colors_petugas.dart';
@ -9,32 +10,51 @@ class PetugasTambahAsetView extends GetView<PetugasTambahAsetController> {
@override
Widget build(BuildContext context) {
return Scaffold(
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Obx(() => Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text(
'Tambah Aset',
style: TextStyle(fontWeight: FontWeight.w600),
title: Text(
controller.isEditing.value ? 'Edit Aset' : 'Tambah Aset',
style: const TextStyle(fontWeight: FontWeight.w600),
),
backgroundColor: AppColorsPetugas.navyBlue,
elevation: 0,
centerTitle: true,
),
body: SafeArea(
body: Stack(
children: [
SafeArea(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [_buildHeaderSection(), _buildFormSection(context)],
children: [
_buildHeaderSection(),
_buildFormSection(context),
],
),
),
),
if (controller.isLoading.value)
Container(
color: Colors.black54,
child: const Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppColorsPetugas.blueGrotto),
),
),
),
],
),
bottomNavigationBar: _buildBottomBar(),
)),
);
}
Widget _buildHeaderSection() {
return Container(
padding: const EdgeInsets.all(20),
padding: const EdgeInsets.only(top: 10, left: 20, right: 20, bottom: 5), // Reduced padding
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [AppColorsPetugas.navyBlue, AppColorsPetugas.blueGrotto],
@ -42,50 +62,8 @@ class PetugasTambahAsetView extends GetView<PetugasTambahAsetController> {
end: Alignment.bottomCenter,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.inventory_2_outlined,
color: Colors.white,
size: 28,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Informasi Aset Baru',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 4),
Text(
'Isi data dengan lengkap untuk menambahkan aset',
style: TextStyle(
fontSize: 14,
color: Colors.white.withOpacity(0.8),
),
),
],
),
),
],
),
],
child: Container(
height: 12, // Further reduced height
),
);
}
@ -131,49 +109,29 @@ class PetugasTambahAsetView extends GetView<PetugasTambahAsetController> {
_buildImageUploader(),
const SizedBox(height: 24),
// Category Section
_buildSectionHeader(icon: Icons.category, title: 'Kategori & Status'),
// Status Section
_buildSectionHeader(icon: Icons.check_circle, title: 'Status'),
const SizedBox(height: 16),
// Category and Status as cards
Row(
children: [
Expanded(
child: _buildCategorySelect(
title: 'Kategori',
options: controller.categoryOptions,
selectedOption: controller.selectedCategory,
onChanged: controller.setCategory,
icon: Icons.inventory_2,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildCategorySelect(
// Status card
_buildCategorySelect(
title: 'Status',
options: controller.statusOptions,
selectedOption: controller.selectedStatus,
onChanged: controller.setStatus,
icon: Icons.check_circle,
),
),
],
),
const SizedBox(height: 24),
// Quantity Section
_buildSectionHeader(
icon: Icons.format_list_numbered,
title: 'Kuantitas & Pengukuran',
title: 'Kuantitas',
),
const SizedBox(height: 16),
// Quantity fields
Row(
children: [
Expanded(
flex: 2,
child: _buildTextField(
// Quantity field
_buildTextField(
label: 'Kuantitas',
hint: 'Jumlah aset',
controller: controller.quantityController,
@ -182,19 +140,6 @@ class PetugasTambahAsetView extends GetView<PetugasTambahAsetController> {
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
prefixIcon: Icons.numbers,
),
),
const SizedBox(width: 12),
Expanded(
flex: 3,
child: _buildTextField(
label: 'Satuan Ukur',
hint: 'contoh: Unit, Buah',
controller: controller.unitOfMeasureController,
prefixIcon: Icons.straighten,
),
),
],
),
const SizedBox(height: 24),
// Rental Options Section
@ -654,6 +599,114 @@ class PetugasTambahAsetView extends GetView<PetugasTambahAsetController> {
);
}
// Show image source options
void _showImageSourceOptions() {
Get.bottomSheet(
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 10,
offset: const Offset(0, -2),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2),
),
),
Text(
'Pilih Sumber Gambar',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.navyBlue,
),
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildImageSourceOption(
icon: Icons.camera_alt,
label: 'Kamera',
onTap: () {
Get.back();
controller.pickImageFromCamera();
},
),
_buildImageSourceOption(
icon: Icons.photo_library,
label: 'Galeri',
onTap: () {
Get.back();
controller.pickImageFromGallery();
},
),
],
),
const SizedBox(height: 10),
TextButton(
onPressed: () => Get.back(),
child: const Text('Batal'),
),
],
),
),
isScrollControlled: true,
);
}
Widget _buildImageSourceOption({
required IconData icon,
required String label,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Column(
children: [
Container(
width: 70,
height: 70,
decoration: BoxDecoration(
color: AppColorsPetugas.babyBlueBright,
shape: BoxShape.circle,
),
child: Icon(
icon,
size: 30,
color: AppColorsPetugas.blueGrotto,
),
),
const SizedBox(height: 8),
Text(
label,
style: TextStyle(
fontSize: 14,
color: AppColorsPetugas.textPrimary,
),
),
],
),
);
}
Widget _buildImageUploader() {
return Container(
padding: const EdgeInsets.all(16),
@ -696,7 +749,7 @@ class PetugasTambahAsetView extends GetView<PetugasTambahAsetController> {
children: [
// Add button
GestureDetector(
onTap: () => controller.addSampleImage(),
onTap: _showImageSourceOptions,
child: Container(
width: 100,
height: 100,
@ -732,37 +785,76 @@ class PetugasTambahAsetView extends GetView<PetugasTambahAsetController> {
),
// Image previews
...controller.selectedImages.asMap().entries.map((entry) {
final index = entry.key;
return Container(
...List<Widget>.generate(
controller.selectedImages.length,
(index) => Stack(
children: [
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: AppColorsPetugas.babyBlueLight,
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 5,
offset: const Offset(0, 2),
border: Border.all(color: Colors.grey[300]!),
),
],
child: Obx(
() {
// Check if we have a network URL for this index
if (index < controller.networkImageUrls.length &&
controller.networkImageUrls[index].isNotEmpty) {
// Display network image
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
controller.networkImageUrls[index],
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Icon(Icons.error_outline, color: Colors.red),
);
},
),
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Container(
width: 100,
height: 100,
color: AppColorsPetugas.babyBlueLight,
child: Center(
child: Icon(
Icons.image,
color: AppColorsPetugas.blueGrotto,
size: 40,
);
} else {
// Display local file
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: FutureBuilder<File>(
future: File(controller.selectedImages[index].path).exists().then((exists) {
if (exists) {
return File(controller.selectedImages[index].path);
} else {
return File(controller.selectedImages[index].path);
}
}),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data != null) {
return Image.file(
snapshot.data!,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
color: Colors.grey[200],
child: const Icon(Icons.broken_image, color: Colors.grey),
);
},
);
} else {
return Container(
color: Colors.grey[200],
child: const Center(
child: CircularProgressIndicator(),
),
);
}
},
),
);
}
},
),
),
Positioned(
@ -771,30 +863,29 @@ class PetugasTambahAsetView extends GetView<PetugasTambahAsetController> {
child: GestureDetector(
onTap: () => controller.removeImage(index),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
padding: const EdgeInsets.all(2),
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 3,
offset: const Offset(0, 1),
color: Colors.black26,
blurRadius: 4,
offset: Offset(0, 1),
),
],
),
child: Icon(
child: const Icon(
Icons.close,
color: AppColorsPetugas.error,
size: 16,
color: Colors.red,
),
),
),
),
],
),
);
}),
).toList(),
],
),
),
@ -850,7 +941,9 @@ class PetugasTambahAsetView extends GetView<PetugasTambahAsetController> {
),
)
: const Icon(Icons.save),
label: Text(isSubmitting ? 'Menyimpan...' : 'Simpan Aset'),
label: Obx(() => Text(
isSubmitting ? 'Menyimpan...' : (controller.isEditing.value ? 'Simpan Perubahan' : 'Simpan Aset'),
)),
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.blueGrotto,
foregroundColor: Colors.white,

View File

@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:get/get.dart';
import '../../../theme/app_colors_petugas.dart';
import '../controllers/petugas_tambah_paket_controller.dart';
import 'dart:io';
class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
const PetugasTambahPaketView({Key? key}) : super(key: key);
@ -12,9 +13,11 @@ class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text(
'Tambah Paket',
style: TextStyle(fontWeight: FontWeight.w600),
title: Obx(
() => Text(
controller.isEditing.value ? 'Edit Paket' : 'Tambah Paket',
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
backgroundColor: AppColorsPetugas.navyBlue,
elevation: 0,
@ -24,7 +27,7 @@ class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [_buildHeaderSection(), _buildFormSection(context)],
children: [_buildFormSection(context)],
),
),
),
@ -32,64 +35,6 @@ class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
);
}
Widget _buildHeaderSection() {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [AppColorsPetugas.navyBlue, AppColorsPetugas.blueGrotto],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.category,
color: Colors.white,
size: 28,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Informasi Paket Baru',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 4),
Text(
'Isi data dengan lengkap untuk menambahkan paket',
style: TextStyle(
fontSize: 14,
color: Colors.white.withOpacity(0.8),
),
),
],
),
),
],
),
],
),
);
}
Widget _buildFormSection(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
@ -132,22 +77,22 @@ class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
const SizedBox(height: 24),
// Category Section
_buildSectionHeader(icon: Icons.category, title: 'Kategori & Status'),
_buildSectionHeader(icon: Icons.category, title: 'Status'),
const SizedBox(height: 16),
// Category and Status as cards
Row(
children: [
Expanded(
child: _buildCategorySelect(
title: 'Kategori',
options: controller.categoryOptions,
selectedOption: controller.selectedCategory,
onChanged: controller.setCategory,
icon: Icons.category,
),
),
const SizedBox(width: 12),
// Expanded(
// child: _buildCategorySelect(
// title: 'Kategori',
// options: controller.categoryOptions,
// selectedOption: controller.selectedCategory,
// onChanged: controller.setCategory,
// icon: Icons.category,
// ),
// ),
// const SizedBox(width: 12),
Expanded(
child: _buildCategorySelect(
title: 'Status',
@ -161,24 +106,6 @@ class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
),
const SizedBox(height: 24),
// Price Section
_buildSectionHeader(
icon: Icons.monetization_on,
title: 'Harga Paket',
),
const SizedBox(height: 16),
_buildTextField(
label: 'Harga Paket',
hint: 'Masukkan harga paket',
controller: controller.priceController,
isRequired: true,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
prefixText: 'Rp ',
prefixIcon: Icons.payments,
),
const SizedBox(height: 24),
// Package Items Section
_buildSectionHeader(
icon: Icons.inventory_2,
@ -186,6 +113,40 @@ class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
),
const SizedBox(height: 16),
_buildPackageItems(),
const SizedBox(height: 24),
_buildSectionHeader(
icon: Icons.schedule,
title: 'Opsi Waktu & Harga Sewa',
),
const SizedBox(height: 16),
_buildTimeOptionsCards(),
const SizedBox(height: 16),
Obx(
() => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (controller.timeOptions['Per Jam']!.value)
_buildPriceCard(
title: 'Harga Per Jam',
icon: Icons.timer,
priceController: controller.pricePerHourController,
maxController: controller.maxHourController,
maxLabel: 'Maksimal Jam',
),
if (controller.timeOptions['Per Jam']!.value &&
controller.timeOptions['Per Hari']!.value)
const SizedBox(height: 16),
if (controller.timeOptions['Per Hari']!.value)
_buildPriceCard(
title: 'Harga Per Hari',
icon: Icons.calendar_today,
priceController: controller.pricePerDayController,
maxController: controller.maxDayController,
maxLabel: 'Maksimal Hari',
),
],
),
),
const SizedBox(height: 40),
],
),
@ -310,7 +271,7 @@ class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
const SizedBox(height: 16),
// Asset dropdown
DropdownButtonFormField<int>(
DropdownButtonFormField<String>(
value: controller.selectedAsset.value,
decoration: const InputDecoration(
labelText: 'Pilih Aset',
@ -319,8 +280,8 @@ class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
hint: const Text('Pilih Aset'),
items:
controller.availableAssets.map((asset) {
return DropdownMenuItem<int>(
value: asset['id'] as int,
return DropdownMenuItem<String>(
value: asset['id'].toString(),
child: Text(
'${asset['nama']} (Stok: ${asset['stok']})',
),
@ -422,7 +383,7 @@ class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
const SizedBox(height: 16),
// Asset dropdown
DropdownButtonFormField<int>(
DropdownButtonFormField<String>(
value: controller.selectedAsset.value,
decoration: const InputDecoration(
labelText: 'Pilih Aset',
@ -431,8 +392,8 @@ class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
hint: const Text('Pilih Aset'),
items:
controller.availableAssets.map((asset) {
return DropdownMenuItem<int>(
value: asset['id'] as int,
return DropdownMenuItem<String>(
value: asset['id'].toString(),
child: Text(
'${asset['nama']} (Stok: ${asset['stok']})',
),
@ -757,7 +718,7 @@ class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
children: [
// Add button
GestureDetector(
onTap: () => controller.addSampleImage(),
onTap: _showImageSourceOptions,
child: Container(
width: 100,
height: 100,
@ -791,37 +752,58 @@ class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
),
),
),
// Image previews
...controller.selectedImages.asMap().entries.map((entry) {
final index = entry.key;
return Container(
...List<Widget>.generate(controller.selectedImages.length, (
index,
) {
final img = controller.selectedImages[index];
return Stack(
children: [
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: AppColorsPetugas.babyBlueLight,
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 5,
offset: const Offset(0, 2),
border: Border.all(color: Colors.grey[300]!),
),
],
),
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Container(
width: 100,
height: 100,
color: AppColorsPetugas.babyBlueLight,
child: Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child:
(img is String && img.startsWith('http'))
? Image.network(
img,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
errorBuilder:
(context, error, stackTrace) =>
const Center(
child: Icon(
Icons.image,
color: AppColorsPetugas.blueGrotto,
size: 40,
Icons.broken_image,
color: Colors.grey,
),
),
)
: (img is String)
? Container(
color: Colors.grey[200],
child: const Icon(
Icons.broken_image,
color: Colors.grey,
),
)
: Image.file(
File(img.path),
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
errorBuilder:
(context, error, stackTrace) =>
const Center(
child: Icon(
Icons.broken_image,
color: Colors.grey,
),
),
),
),
@ -829,35 +811,125 @@ class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
Positioned(
top: 4,
right: 4,
child: GestureDetector(
child: InkWell(
onTap: () => controller.removeImage(index),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 3,
offset: const Offset(0, 1),
),
child: const Icon(
Icons.close,
size: 18,
color: Colors.red,
),
),
),
),
],
),
child: Icon(
Icons.close,
color: AppColorsPetugas.error,
size: 16,
),
),
);
}),
],
),
),
],
),
);
}),
}
void _showImageSourceOptions() {
Get.bottomSheet(
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 10,
offset: const Offset(0, -2),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2),
),
),
Text(
'Pilih Sumber Gambar',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.navyBlue,
),
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildImageSourceOption(
icon: Icons.camera_alt,
label: 'Kamera',
onTap: () {
Get.back();
controller.pickImageFromCamera();
},
),
_buildImageSourceOption(
icon: Icons.photo_library,
label: 'Galeri',
onTap: () {
Get.back();
controller.pickImageFromGallery();
},
),
],
),
const SizedBox(height: 10),
],
),
),
);
}
Widget _buildImageSourceOption({
required IconData icon,
required String label,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Column(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColorsPetugas.babyBlueBright,
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: AppColorsPetugas.blueGrotto, size: 28),
),
const SizedBox(height: 8),
Text(
label,
style: TextStyle(
fontSize: 14,
color: AppColorsPetugas.navyBlue,
fontWeight: FontWeight.w500,
),
),
],
),
@ -899,26 +971,37 @@ class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
final isSubmitting = controller.isSubmitting.value;
return ElevatedButton.icon(
onPressed:
isValid && !isSubmitting ? controller.savePaket : null,
controller.isFormChanged.value && !isSubmitting
? controller.savePaket
: null,
icon:
isSubmitting
? SizedBox(
height: 20,
width: 20,
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.save),
label: Text(isSubmitting ? 'Menyimpan...' : 'Simpan Paket'),
label: Text(
isSubmitting
? 'Menyimpan...'
: (controller.isEditing.value
? 'Simpan Paket'
: 'Tambah Paket'),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.blueGrotto,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 16),
textStyle: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(12),
),
disabledBackgroundColor: AppColorsPetugas.textLight,
),
@ -929,4 +1012,226 @@ class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
),
);
}
Widget _buildTimeOptionsCards() {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
children:
controller.timeOptions.entries.map((entry) {
final option = entry.key;
final isSelected = entry.value;
return Obx(
() => Material(
color: Colors.transparent,
child: InkWell(
onTap: () => controller.toggleTimeOption(option),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color:
isSelected.value
? AppColorsPetugas.blueGrotto.withOpacity(
0.1,
)
: Colors.grey.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(
option == 'Per Jam'
? Icons.hourglass_bottom
: Icons.calendar_today,
color:
isSelected.value
? AppColorsPetugas.blueGrotto
: Colors.grey,
size: 20,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
option,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color:
isSelected.value
? AppColorsPetugas.navyBlue
: Colors.grey.shade700,
),
),
const SizedBox(height: 2),
Text(
option == 'Per Jam'
? 'Sewa paket dengan basis perhitungan per jam'
: 'Sewa paket dengan basis perhitungan per hari',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
],
),
),
Checkbox(
value: isSelected.value,
onChanged:
(_) => controller.toggleTimeOption(option),
activeColor: AppColorsPetugas.blueGrotto,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
),
),
);
}).toList(),
),
);
}
Widget _buildPriceCard({
required String title,
required IconData icon,
required TextEditingController priceController,
required TextEditingController maxController,
required String maxLabel,
}) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColorsPetugas.babyBlue.withOpacity(0.5)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 20, color: AppColorsPetugas.blueGrotto),
const SizedBox(width: 8),
Text(
title,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColorsPetugas.navyBlue,
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Harga Sewa',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColorsPetugas.textSecondary,
),
),
const SizedBox(height: 8),
TextFormField(
controller: priceController,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: InputDecoration(
hintText: 'Masukkan harga',
hintStyle: TextStyle(color: AppColorsPetugas.textLight),
prefixText: 'Rp ',
filled: true,
fillColor: AppColorsPetugas.babyBlueBright,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
maxLabel,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColorsPetugas.textSecondary,
),
),
const SizedBox(height: 8),
TextFormField(
controller: maxController,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: InputDecoration(
hintText: 'Opsional',
hintStyle: TextStyle(color: AppColorsPetugas.textLight),
filled: true,
fillColor: AppColorsPetugas.babyBlueBright,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
),
),
],
),
),
],
),
],
),
);
}
}

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../theme/app_colors.dart';
import '../../../theme/app_colors_petugas.dart';
import '../controllers/petugas_bumdes_dashboard_controller.dart';
class PetugasSideNavbar extends StatelessWidget {
@ -11,7 +12,7 @@ class PetugasSideNavbar extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Drawer(
backgroundColor: Colors.white,
backgroundColor: AppColorsPetugas.babyBlueLight,
elevation: 0,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
@ -32,14 +33,17 @@ class PetugasSideNavbar extends StatelessWidget {
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.fromLTRB(20, 60, 20, 20),
color: AppColors.primary,
color: AppColorsPetugas.navyBlue,
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
Obx(() {
final avatar = controller.avatarUrl.value;
if (avatar.isNotEmpty) {
return Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
@ -47,9 +51,28 @@ class PetugasSideNavbar extends StatelessWidget {
child: CircleAvatar(
radius: 30,
backgroundColor: Colors.white,
child: Icon(Icons.person, color: AppColors.primary, size: 36),
backgroundImage: NetworkImage(avatar),
onBackgroundImageError: (error, stackTrace) {},
),
);
} else {
return Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
child: CircleAvatar(
radius: 30,
backgroundColor: Colors.white,
child: Icon(
Icons.person,
color: AppColors.primary,
size: 36,
),
),
);
}
}),
const SizedBox(width: 16),
Expanded(
child: Column(

View File

@ -34,7 +34,7 @@ class SplashView extends GetView<SplashController> {
child: Container(
decoration: const BoxDecoration(
image: DecorationImage(
image: AssetImage('assets/images/pattern.png'),
image: AssetImage('assets/images/logo.png'), // Using logo.png which exists
repeat: ImageRepeat.repeat,
scale: 4.0,
),

View File

@ -8,12 +8,6 @@ import '../../../data/providers/aset_provider.dart';
class WargaSewaBinding extends Bindings {
@override
void dependencies() {
// Ensure NavigationService is registered and set to Sewa tab
if (Get.isRegistered<NavigationService>()) {
final navService = Get.find<NavigationService>();
navService.setNavIndex(1); // Set to Sewa tab
}
// Ensure AuthProvider is registered
if (!Get.isRegistered<AuthProvider>()) {
Get.put(AuthProvider(), permanent: true);

View File

@ -47,17 +47,21 @@ class PembayaranSewaController extends GetxController
final isLoading = false.obs;
final currentStep = 0.obs;
// Payment proof images - now a list to support multiple images (both File and WebImageFile)
final RxList<dynamic> paymentProofImages = <dynamic>[].obs;
// Payment proof images for tagihan awal
final RxList<dynamic> paymentProofImagesTagihanAwal = <dynamic>[].obs;
// Payment proof images for denda
final RxList<dynamic> paymentProofImagesDenda = <dynamic>[].obs;
// Track original images loaded from database
final RxList<WebImageFile> originalImages = <WebImageFile>[].obs;
// Track images marked for deletion
final RxList<WebImageFile> imagesToDelete = <WebImageFile>[].obs;
final RxList<WebImageFile> imagesToDeleteTagihanAwal = <WebImageFile>[].obs;
final RxList<WebImageFile> imagesToDeleteDenda = <WebImageFile>[].obs;
// Flag to track if there are changes that need to be saved
final RxBool hasUnsavedChanges = false.obs;
final RxBool hasUnsavedChangesTagihanAwal = false.obs;
final RxBool hasUnsavedChangesDenda = false.obs;
// Get image widget for a specific image
Widget getImageWidget(dynamic imageFile) {
@ -98,12 +102,7 @@ class PembayaranSewaController extends GetxController
}
// For mobile with a File object
else if (imageFile is File) {
return Image.file(
imageFile,
height: 120,
width: 120,
fit: BoxFit.cover,
);
return Image.file(imageFile, height: 120, width: 120, fit: BoxFit.cover);
}
// Fallback for any other type
else {
@ -118,18 +117,26 @@ class PembayaranSewaController extends GetxController
// Remove an image from the list
void removeImage(dynamic image) {
// If this is an existing image (WebImageFile), add it to imagesToDelete
if (selectedPaymentType.value == 'denda') {
// Untuk denda
if (image is WebImageFile && image.id.isNotEmpty) {
imagesToDelete.add(image);
debugPrint('🗑️ Marked image for deletion: ${image.imageUrl} (ID: ${image.id})');
imagesToDeleteDenda.add(image);
debugPrint(
'🗑️ Marked image for deletion (denda): \\${image.imageUrl} (ID: \\${image.id})',
);
}
paymentProofImagesDenda.remove(image);
} else {
// Default/tagihan awal
if (image is WebImageFile && image.id.isNotEmpty) {
imagesToDeleteTagihanAwal.add(image);
debugPrint(
'🗑️ Marked image for deletion: \\${image.imageUrl} (ID: \\${image.id})',
);
}
paymentProofImagesTagihanAwal.remove(image);
}
// Remove from the current list
paymentProofImages.remove(image);
// Check if we have any changes (additions or deletions)
_checkForChanges();
update();
}
@ -161,14 +168,17 @@ class PembayaranSewaController extends GetxController
panEnabled: true,
minScale: 0.5,
maxScale: 4,
child: kIsWeb
child:
kIsWeb
? Image.network(
imageUrl,
fit: BoxFit.contain,
height: Get.height,
width: Get.width,
errorBuilder: (context, error, stackTrace) {
return const Center(child: Text('Error loading image'));
return const Center(
child: Text('Error loading image'),
);
},
)
: Image.file(
@ -196,35 +206,33 @@ class PembayaranSewaController extends GetxController
// Check if there are any changes to save (new images added or existing images removed)
void _checkForChanges() {
// We have changes if:
// 1. We have images marked for deletion
// 2. We have new images (files) added
// 3. The current list differs from the original list
bool hasChanges = false;
// Check if any images are marked for deletion
if (imagesToDelete.isNotEmpty) {
hasChanges = true;
bool hasChangesTagihanAwal = false;
bool hasChangesDenda = false;
if (imagesToDeleteTagihanAwal.isNotEmpty) {
hasChangesTagihanAwal = true;
}
// Check if any new images have been added
for (dynamic image in paymentProofImages) {
if (imagesToDeleteDenda.isNotEmpty) {
hasChangesDenda = true;
}
for (dynamic image in paymentProofImagesTagihanAwal) {
if (image is File) {
// This is a new image
hasChanges = true;
hasChangesTagihanAwal = true;
break;
}
}
// Check if the number of images has changed
if (paymentProofImages.length != originalImages.length) {
hasChanges = true;
for (dynamic image in paymentProofImagesDenda) {
if (image is File) {
hasChangesDenda = true;
break;
}
}
hasUnsavedChangesTagihanAwal.value = hasChangesTagihanAwal;
hasUnsavedChangesDenda.value = hasChangesDenda;
debugPrint(
'💾 Has unsaved changes (tagihan awal): $hasChangesTagihanAwal, (denda): $hasChangesDenda',
);
}
hasUnsavedChanges.value = hasChanges;
debugPrint('💾 Has unsaved changes: $hasChanges');
}
final isUploading = false.obs;
final uploadProgress = 0.0.obs;
@ -260,8 +268,16 @@ class PembayaranSewaController extends GetxController
'rental_period': rentalData['waktuSewa'] ?? '',
'duration': rentalData['duration'] ?? '',
'price_per_unit': 0, // This might not be available in rental data
'total_price': rentalData['totalPrice'] != null ?
int.tryParse(rentalData['totalPrice'].toString().replaceAll(RegExp(r'[^0-9]'), '')) ?? 0 : 0,
'total_price':
rentalData['totalPrice'] != null
? int.tryParse(
rentalData['totalPrice'].toString().replaceAll(
RegExp(r'[^0-9]'),
'',
),
) ??
0
: 0,
'status': rentalData['status'] ?? 'MENUNGGU PEMBAYARAN',
'created_at': DateTime.now().toString(),
'denda': 0, // Default value
@ -276,7 +292,7 @@ class PembayaranSewaController extends GetxController
checkSewaAsetTableStructure();
loadTagihanSewaDetails().then((_) {
// Load existing payment proof images after tagihan_sewa details are loaded
loadExistingPaymentProofImages();
loadExistingPaymentProofImages(jenisPembayaran: 'tagihan awal');
});
loadSewaAsetDetails();
loadBankAccounts(); // Load bank accounts data
@ -286,7 +302,7 @@ class PembayaranSewaController extends GetxController
loadOrderDetails();
loadTagihanSewaDetails().then((_) {
// Load existing payment proof images after tagihan_sewa details are loaded
loadExistingPaymentProofImages();
loadExistingPaymentProofImages(jenisPembayaran: 'tagihan awal');
});
loadSewaAsetDetails();
loadBankAccounts(); // Load bank accounts data
@ -382,18 +398,19 @@ class PembayaranSewaController extends GetxController
}
val?['quantity'] = data['kuantitas'] ?? 1;
val?['denda'] =
data['denda'] ??
0; // Use data from API or default to 0
val?['keterangan'] =
data['keterangan'] ??
''; // Use data from API or default to empty string
// Update status if it exists in the data
if (data['status'] != null && data['status'].toString().isNotEmpty) {
data['denda'] ?? 0; // Use data from API or default to 0
val?['keterangan'] = data['keterangan'] ?? '';
if (data['status'] != null &&
data['status'].toString().isNotEmpty) {
val?['status'] = data['status'];
debugPrint('📊 Order status from sewa_aset: ${data['status']}');
debugPrint(
'📊 Order status from sewa_aset: \\${data['status']}',
);
}
// Tambahkan mapping updated_at
if (data['updated_at'] != null) {
val?['updated_at'] = data['updated_at'];
}
// Format rental period
if (data['waktu_mulai'] != null &&
data['waktu_selesai'] != null) {
@ -401,12 +418,12 @@ class PembayaranSewaController extends GetxController
final startTime = DateTime.parse(data['waktu_mulai']);
final endTime = DateTime.parse(data['waktu_selesai']);
val?['rental_period'] =
'${startTime.day}/${startTime.month}/${startTime.year}, ${startTime.hour}:${startTime.minute.toString().padLeft(2, '0')} - ${endTime.hour}:${endTime.minute.toString().padLeft(2, '0')}';
'\\${startTime.day}/\\${startTime.month}/\\${startTime.year}, \\${startTime.hour}:\\${startTime.minute.toString().padLeft(2, '0')} - \\${endTime.hour}:\\${endTime.minute.toString().padLeft(2, '0')}';
debugPrint(
'✅ Successfully formatted rental period: ${val?['rental_period']}',
'✅ Successfully formatted rental period: \\${val?['rental_period']}',
);
} catch (e) {
debugPrint('❌ Error parsing date: $e');
debugPrint('❌ Error parsing date: \\${e}');
}
} else {
debugPrint(
@ -547,6 +564,11 @@ class PembayaranSewaController extends GetxController
// Select payment type (tagihan_awal or denda)
void selectPaymentType(String type) {
selectedPaymentType.value = type;
if (type == 'tagihan_awal') {
loadExistingPaymentProofImages(jenisPembayaran: 'tagihan awal');
} else if (type == 'denda') {
loadExistingPaymentProofImages(jenisPembayaran: 'denda');
}
update();
}
@ -558,21 +580,20 @@ class PembayaranSewaController extends GetxController
source: ImageSource.camera,
imageQuality: 80,
);
if (image != null) {
// Add to the list of images instead of replacing
paymentProofImages.add(File(image.path));
// Check for changes
if (selectedPaymentType.value == 'denda') {
paymentProofImagesDenda.add(File(image.path));
} else {
paymentProofImagesTagihanAwal.add(File(image.path));
}
_checkForChanges();
update();
}
} catch (e) {
debugPrint('❌ Error taking photo: $e');
Get.snackbar(
'Error',
'Gagal mengambil foto: ${e.toString()}',
'Gagal mengambil foto: \\${e.toString()}',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
@ -588,17 +609,20 @@ class PembayaranSewaController extends GetxController
source: ImageSource.gallery,
imageQuality: 80,
);
if (image != null) {
// Add to the list of images instead of replacing
paymentProofImages.add(File(image.path));
if (selectedPaymentType.value == 'denda') {
paymentProofImagesDenda.add(File(image.path));
} else {
paymentProofImagesTagihanAwal.add(File(image.path));
}
_checkForChanges();
update();
}
} catch (e) {
debugPrint('❌ Error selecting photo from gallery: $e');
Get.snackbar(
'Error',
'Gagal memilih foto dari galeri: ${e.toString()}',
'Gagal memilih foto dari galeri: \\${e.toString()}',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
@ -607,7 +631,19 @@ class PembayaranSewaController extends GetxController
}
// Upload payment proof to Supabase storage and save to foto_pembayaran table
Future<void> uploadPaymentProof() async {
Future<void> uploadPaymentProof({required String jenisPembayaran}) async {
final paymentProofImages =
jenisPembayaran == 'tagihan awal'
? paymentProofImagesTagihanAwal
: paymentProofImagesDenda;
final imagesToDelete =
jenisPembayaran == 'tagihan awal'
? imagesToDeleteTagihanAwal
: imagesToDeleteDenda;
final hasUnsavedChanges =
jenisPembayaran == 'tagihan awal'
? hasUnsavedChangesTagihanAwal
: hasUnsavedChangesDenda;
// If there are no images and none marked for deletion, show error
if (paymentProofImages.isEmpty && imagesToDelete.isEmpty) {
Get.snackbar(
@ -644,7 +680,9 @@ class PembayaranSewaController extends GetxController
// First, delete any images marked for deletion
if (imagesToDelete.isNotEmpty) {
debugPrint('🗑️ Deleting ${imagesToDelete.length} images from database and storage');
debugPrint(
'🗑️ Deleting ${imagesToDelete.length} images from database and storage',
);
for (WebImageFile image in imagesToDelete) {
// Delete the record from the foto_pembayaran table
@ -673,14 +711,16 @@ class PembayaranSewaController extends GetxController
// The filename is the last part of the path after the last '/'
final String fileName = path.substring(path.lastIndexOf('/') + 1);
debugPrint('🗑️ Attempting to delete file from storage: $fileName');
debugPrint(
'🗑️ Attempting to delete file from storage: $fileName',
);
// Delete the file from storage
await client.storage
.from('bukti.pembayaran')
.remove([fileName]);
await client.storage.from('bukti.pembayaran').remove([fileName]);
debugPrint('🗑️ Successfully deleted file from storage: $fileName');
debugPrint(
'🗑️ Successfully deleted file from storage: $fileName',
);
} catch (e) {
debugPrint('⚠️ Error deleting file from storage: $e');
// Continue even if file deletion fails - we've at least deleted from the database
@ -693,7 +733,9 @@ class PembayaranSewaController extends GetxController
}
// Upload each new image to Supabase Storage and save to database
debugPrint('🔄 Uploading new payment proof images to Supabase storage...');
debugPrint(
'🔄 Uploading new payment proof images to Supabase storage...',
);
List<String> uploadedUrls = [];
List<dynamic> newImagesToUpload = [];
@ -710,7 +752,9 @@ class PembayaranSewaController extends GetxController
}
}
debugPrint('🔄 Found ${existingImageUrls.length} existing images and ${newImagesToUpload.length} new images to upload');
debugPrint(
'🔄 Found ${existingImageUrls.length} existing images and ${newImagesToUpload.length} new images to upload',
);
// If there are new images to upload
if (newImagesToUpload.isNotEmpty) {
@ -721,13 +765,16 @@ class PembayaranSewaController extends GetxController
// Upload each new image
for (int i = 0; i < newImagesToUpload.length; i++) {
final dynamic imageFile = newImagesToUpload[i];
final String fileName = '${DateTime.now().millisecondsSinceEpoch}_${orderId.value}_$i.jpg';
final String fileName =
'${DateTime.now().millisecondsSinceEpoch}_${orderId.value}_$i.jpg';
// Create a sub-progress tracker for this image
final subProgressNotifier = StreamController<double>();
subProgressNotifier.stream.listen((subProgress) {
// Calculate overall progress
progressNotifier.add(currentProgress + (subProgress * progressIncrement));
progressNotifier.add(
currentProgress + (subProgress * progressIncrement),
);
});
// Upload to Supabase Storage
@ -754,15 +801,20 @@ class PembayaranSewaController extends GetxController
// Save all new URLs to foto_pembayaran table
for (String imageUrl in uploadedUrls) {
await _saveToFotoPembayaranTable(imageUrl);
await _saveToFotoPembayaranTable(imageUrl, jenisPembayaran);
}
// Reload the existing images to get fresh data with new IDs
await loadExistingPaymentProofImages();
await loadExistingPaymentProofImages(jenisPembayaran: jenisPembayaran);
// Update order status in orderDetails
orderDetails.update((val) {
if (jenisPembayaran == 'denda' &&
val?['status'] == 'PEMBAYARAN DENDA') {
val?['status'] = 'PERIKSA PEMBAYARAN DENDA';
} else {
val?['status'] = 'MEMERIKSA PEMBAYARAN';
}
});
// Also update the status in the sewa_aset table
@ -771,17 +823,28 @@ class PembayaranSewaController extends GetxController
final dynamic sewaAsetId = tagihanSewa.value['sewa_aset_id'];
if (sewaAsetId != null && sewaAsetId.toString().isNotEmpty) {
debugPrint('🔄 Updating status in sewa_aset table for ID: $sewaAsetId');
debugPrint(
'🔄 Updating status in sewa_aset table for ID: $sewaAsetId',
);
// Update the status in the sewa_aset table
final updateResult = await client
.from('sewa_aset')
.update({'status': 'PERIKSA PEMBAYARAN'})
.update({
'status':
(jenisPembayaran == 'denda' &&
orderDetails.value['status'] ==
'PERIKSA PEMBAYARAN DENDA')
? 'PERIKSA PEMBAYARAN DENDA'
: 'PERIKSA PEMBAYARAN',
})
.eq('id', sewaAsetId.toString());
debugPrint('✅ Status updated in sewa_aset table: $updateResult');
} else {
debugPrint('⚠️ Could not update sewa_aset status: No valid sewa_aset_id found');
debugPrint(
'⚠️ Could not update sewa_aset status: No valid sewa_aset_id found',
);
}
} catch (e) {
// Don't fail the entire operation if this update fails
@ -878,21 +941,23 @@ class PembayaranSewaController extends GetxController
case 'DITERIMA':
currentStep.value = 2;
break;
case 'PENGEMBALIAN':
case 'AKTIF':
currentStep.value = 3;
break;
case 'PEMBAYARAN DENDA':
case 'PENGEMBALIAN':
currentStep.value = 4;
break;
case 'MEMERIKSA PEMBAYARAN DENDA':
case 'PEMBAYARAN DENDA':
currentStep.value = 5;
break;
case 'SELESAI':
case 'PERIKSA PEMBAYARAN DENDA':
currentStep.value = 6;
break;
case 'SELESAI':
currentStep.value = 7;
break;
case 'DIBATALKAN':
// Special case for canceled orders
currentStep.value = 0;
currentStep.value = 8;
break;
default:
currentStep.value = 0;
@ -950,7 +1015,9 @@ class PembayaranSewaController extends GetxController
debugPrint('Available fields in sewa_aset table:');
record.forEach((key, value) {
debugPrint(' $key: (${value != null ? value.runtimeType : 'null'})');
debugPrint(
' $key: (${value != null ? value.runtimeType : 'null'})',
);
});
// Specifically check for time fields
@ -987,12 +1054,16 @@ class PembayaranSewaController extends GetxController
final data = await asetProvider.getBankAccounts();
if (data.isNotEmpty) {
bankAccounts.assignAll(data);
debugPrint('✅ Bank accounts loaded: ${bankAccounts.length} accounts found');
debugPrint(
'✅ Bank accounts loaded: ${bankAccounts.length} accounts found',
);
// Debug the bank accounts data
debugPrint('📋 BANK ACCOUNTS DETAILS:');
for (var account in bankAccounts) {
debugPrint(' Bank: ${account['nama_bank']}, Account: ${account['nama_akun']}, Number: ${account['no_rekening']}');
debugPrint(
' Bank: ${account['nama_bank']}, Account: ${account['nama_akun']}, Number: ${account['no_rekening']}',
);
}
} else {
debugPrint('⚠️ No bank accounts found in akun_bank table');
@ -1003,7 +1074,11 @@ class PembayaranSewaController extends GetxController
}
// Helper method to upload image to Supabase storage
Future<String?> _uploadToSupabaseStorage(dynamic imageFile, String fileName, StreamController<double> progressNotifier) async {
Future<String?> _uploadToSupabaseStorage(
dynamic imageFile,
String fileName,
StreamController<double> progressNotifier,
) async {
try {
debugPrint('🔄 Uploading image to Supabase storage: $fileName');
@ -1031,7 +1106,9 @@ class PembayaranSewaController extends GetxController
);
// Get public URL
final String publicUrl = client.storage.from('bukti.pembayaran').getPublicUrl(fileName);
final String publicUrl = client.storage
.from('bukti.pembayaran')
.getPublicUrl(fileName);
debugPrint('✅ Upload successful: $publicUrl');
progressNotifier.add(1.0); // Upload complete
@ -1050,7 +1127,10 @@ class PembayaranSewaController extends GetxController
}
// Helper method to save image URL to foto_pembayaran table
Future<void> _saveToFotoPembayaranTable(String imageUrl) async {
Future<void> _saveToFotoPembayaranTable(
String imageUrl,
String jenisPembayaran,
) async {
try {
debugPrint('🔄 Saving image URL to foto_pembayaran table...');
@ -1067,79 +1147,57 @@ class PembayaranSewaController extends GetxController
final Map<String, dynamic> data = {
'tagihan_sewa_id': tagihanSewaId,
'foto_pembayaran': imageUrl,
'jenis_pembayaran': jenisPembayaran,
'created_at': DateTime.now().toIso8601String(),
};
// Insert data into the foto_pembayaran table
final response = await client
.from('foto_pembayaran')
.insert(data)
.select()
.single();
final response =
await client.from('foto_pembayaran').insert(data).select().single();
debugPrint('✅ Image URL saved to foto_pembayaran table: ${response['id']}');
debugPrint(
'✅ Image URL saved to foto_pembayaran table: ${response['id']}',
);
} catch (e) {
debugPrint('❌ Error in _saveToFotoPembayaranTable: $e');
throw Exception('Failed to save image URL to database: $e');
}
}
// Load existing payment proof images
Future<void> loadExistingPaymentProofImages() async {
// Load existing payment proof images for a specific jenis_pembayaran
Future<void> loadExistingPaymentProofImages({
required String jenisPembayaran,
}) async {
try {
debugPrint('🔄 Loading existing payment proof images for tagihan_sewa_id: ${tagihanSewa.value['id']}');
// Check if we have a valid tagihan_sewa_id
debugPrint(
'🔄 Loading existing payment proof images for tagihan_sewa_id: \\${tagihanSewa.value['id']} dan jenis_pembayaran: $jenisPembayaran',
);
final dynamic tagihanSewaId = tagihanSewa.value['id'];
if (tagihanSewaId == null || tagihanSewaId.toString().isEmpty) {
debugPrint('⚠️ No valid tagihan_sewa_id found, skipping image load');
return;
}
// First, make a test query to see the structure of the response
final testResponse = await client
.from('foto_pembayaran')
.select()
.limit(1);
// Log the test response structure
if (testResponse.isNotEmpty) {
debugPrint('💾 DEBUG: Test database response: ${testResponse[0]}');
testResponse[0].forEach((key, value) {
debugPrint('💾 DEBUG: Field $key = $value (${value?.runtimeType})');
});
}
// Now make the actual query for this tagihan_sewa_id
final List<dynamic> response = await client
.from('foto_pembayaran')
.select()
.eq('tagihan_sewa_id', tagihanSewaId)
.eq('jenis_pembayaran', jenisPembayaran)
.order('created_at', ascending: false);
debugPrint('🔄 Found ${response.length} existing payment proof images');
// Clear existing tracking lists
paymentProofImages.clear();
originalImages.clear();
imagesToDelete.clear();
hasUnsavedChanges.value = false;
// Process each image in the response
debugPrint(
'🔄 Found \\${response.length} existing payment proof images for $jenisPembayaran',
);
final targetList =
jenisPembayaran == 'tagihan awal'
? paymentProofImagesTagihanAwal
: paymentProofImagesDenda;
targetList.clear();
for (final item in response) {
// Extract the image URL
final String imageUrl = item['foto_pembayaran'];
// Extract the ID - debug the item structure
debugPrint('💾 Image data: $item');
// Get the ID field - in Supabase, this is a UUID string
String imageId = '';
try {
if (item.containsKey('id')) {
final dynamic rawId = item['id'];
if (rawId != null) {
// Store ID as string since it's a UUID
imageId = rawId.toString();
}
debugPrint('🔄 Image ID: $imageId');
@ -1147,21 +1205,12 @@ class PembayaranSewaController extends GetxController
} catch (e) {
debugPrint('❌ Error getting image ID: $e');
}
// Create the WebImageFile object
final webImageFile = WebImageFile(imageUrl);
webImageFile.id = imageId;
// Add to tracking lists
paymentProofImages.add(webImageFile);
originalImages.add(webImageFile);
targetList.add(webImageFile);
debugPrint('✅ Added image: $imageUrl with ID: $imageId');
}
// Update the UI
update();
} catch (e) {
debugPrint('❌ Error loading payment proof images: $e');
}
@ -1174,7 +1223,9 @@ class PembayaranSewaController extends GetxController
try {
// Reload all data
await Future.delayed(const Duration(milliseconds: 500)); // Small delay for better UX
await Future.delayed(
const Duration(milliseconds: 500),
); // Small delay for better UX
loadOrderDetails();
loadTagihanSewaDetails();
loadSewaAsetDetails();

View File

@ -88,6 +88,19 @@ class SewaAsetController extends GetxController
void onReady() {
super.onReady();
debugPrint('🚀 SewaAsetController: onReady called');
// Set tab index from arguments (if any) after build
Future.delayed(Duration.zero, () {
final args = Get.arguments;
if (args != null && args is Map && args['tab'] != null) {
int initialTab =
args['tab'] is int
? args['tab']
: int.tryParse(args['tab'].toString()) ?? 0;
if (tabController.length > initialTab) {
tabController.index = initialTab;
}
}
});
}
@override

View File

@ -2,11 +2,14 @@ import 'package:get/get.dart';
import '../../../data/providers/auth_provider.dart';
import '../../../routes/app_routes.dart';
import '../../../services/navigation_service.dart';
import '../../../data/providers/aset_provider.dart';
import 'package:intl/intl.dart';
class WargaDashboardController extends GetxController {
// Dependency injection
final AuthProvider _authProvider = Get.find<AuthProvider>();
final NavigationService navigationService = Get.find<NavigationService>();
final AsetProvider _asetProvider = Get.find<AsetProvider>();
// User data
final userName = 'Pengguna Warga'.obs;
@ -28,6 +31,11 @@ class WargaDashboardController extends GetxController {
// Active penalties
final activePenalties = <Map<String, dynamic>>[].obs;
// Summary counts
final diterimaCount = 0.obs;
final tagihanAktifCount = 0.obs;
final dendaAktifCount = 0.obs;
@override
void onInit() {
super.onInit();
@ -36,6 +44,7 @@ class WargaDashboardController extends GetxController {
navigationService.setNavIndex(0);
// Load user data
fetchProfileFromWargaDesa();
_loadUserData();
// Load sample data
@ -46,6 +55,12 @@ class WargaDashboardController extends GetxController {
// Load unpaid rentals
loadUnpaidRentals();
// Debug count sewa_aset by status
_debugCountSewaAset();
// Load sewa aktif
loadActiveRentals();
}
Future<void> _loadUserData() async {
@ -112,7 +127,7 @@ class WargaDashboardController extends GetxController {
}
void refreshData() {
// Refresh data from repository
fetchProfileFromWargaDesa();
_loadSampleData();
loadDummyData();
}
@ -129,12 +144,17 @@ class WargaDashboardController extends GetxController {
// Already on Home tab
break;
case 1:
// Navigate to Sewa page
navigationService.toWargaSewa();
// Navigate to Sewa page, tab Aktif
toWargaSewaTabAktif();
break;
}
}
void toWargaSewaTabAktif() {
// Navigasi ke halaman warga sewa dan tab Aktif (index 3)
Get.toNamed(Routes.WARGA_SEWA, arguments: {'tab': 3});
}
void logout() async {
await _authProvider.signOut();
navigationService.toLogin();
@ -177,4 +197,137 @@ class WargaDashboardController extends GetxController {
print('Error loading unpaid rentals: $e');
}
}
Future<void> _debugCountSewaAset() async {
diterimaCount.value = await _asetProvider.countSewaAsetByStatus([
'DITERIMA',
]);
tagihanAktifCount.value = await _asetProvider.countSewaAsetByStatus([
'MENUNGGU PEMBAYARAN',
'PERIKSA PEMBAYARAN',
]);
dendaAktifCount.value = await _asetProvider.countSewaAsetByStatus([
'PEMBAYARAN DENDA',
'PERIKSA PEMBAYARAN DENDA',
]);
print('[DEBUG] Jumlah sewa diterima: ${diterimaCount.value}');
print('[DEBUG] Jumlah tagihan aktif: ${tagihanAktifCount.value}');
print('[DEBUG] Jumlah denda aktif: ${dendaAktifCount.value}');
}
Future<void> loadActiveRentals() async {
try {
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']);
}
String duration = '-';
final tagihan = await _asetProvider.getTagihanSewa(sewaAset['id']);
if (tagihan != null) {
final durasiTagihan = tagihan['durasi'] ?? sewaAset['durasi'];
final satuanTagihan = tagihan['nama_satuan_waktu'] ?? namaSatuanWaktu;
duration = '${durasiTagihan ?? '-'} ${satuanTagihan ?? ''}';
} else {
duration = '${sewaAset['durasi'] ?? '-'} ${namaSatuanWaktu ?? ''}';
}
activeRentals.add({
'id': sewaAset['id'] ?? '',
'name': assetName,
'imageUrl': imageUrl ?? 'assets/images/gambar_pendukung.jpg',
'jumlahUnit': sewaAset['kuantitas'] ?? 0,
'waktuSewa': waktuSewa,
'duration': duration,
'status': sewaAset['status'] ?? 'AKTIF',
'totalPrice': totalPrice,
'tanggalSewa': tanggalSewa,
'jamMulai': jamMulai,
'jamSelesai': jamSelesai,
'rentangWaktu': rentangWaktu,
'namaSatuanWaktu': namaSatuanWaktu,
'waktuMulai': sewaAset['waktu_mulai'],
'waktuSelesai': sewaAset['waktu_selesai'],
'can_extend': sewaAset['can_extend'] == true,
});
}
} catch (e) {
print('Error loading active rentals: $e');
}
}
void toSewaAsetTabPaket() {
// Navigasi ke halaman sewa_aset tab Paket (index 1)
Get.toNamed(Routes.SEWA_ASET, arguments: {'tab': 1});
}
Future<void> fetchProfileFromWargaDesa() async {
try {
final user = _authProvider.currentUser;
if (user == null) return;
final userId = user.id;
final data =
await _authProvider.client
.from('warga_desa')
.select('nik, alamat, email, nama_lengkap, no_hp, avatar')
.eq('user_id', userId)
.maybeSingle();
if (data != null) {
userNik.value = data['nik']?.toString() ?? '';
userAddress.value = data['alamat']?.toString() ?? '';
userEmail.value = data['email']?.toString() ?? '';
userName.value = data['nama_lengkap']?.toString() ?? '';
userPhone.value = data['no_hp']?.toString() ?? '';
userAvatar.value = data['avatar']?.toString() ?? '';
}
} catch (e) {
print('Error fetching profile from warga_desa: $e');
}
}
}

View File

@ -25,6 +25,8 @@ class WargaSewaController extends GetxController
final acceptedRentals = <Map<String, dynamic>>[].obs;
final completedRentals = <Map<String, dynamic>>[].obs;
final cancelledRentals = <Map<String, dynamic>>[].obs;
final returnedRentals = <Map<String, dynamic>>[].obs;
final activeRentals = <Map<String, dynamic>>[].obs;
// Loading states
final isLoading = false.obs;
@ -32,26 +34,26 @@ class WargaSewaController extends GetxController
final isLoadingAccepted = false.obs;
final isLoadingCompleted = false.obs;
final isLoadingCancelled = false.obs;
final isLoadingReturned = false.obs;
final isLoadingActive = false.obs;
bool _tabSetFromArgument = false;
@override
void onInit() {
super.onInit();
// Ensure tab index is set to Sewa (1)
navigationService.setNavIndex(1);
// Initialize tab controller with 6 tabs
tabController = TabController(length: 6, vsync: this);
// Set initial tab and ensure tab view is updated
tabController.index = 0;
// Initialize tab controller with 7 tabs
tabController = TabController(length: 7, vsync: this);
// Load real rental data for all tabs
loadRentalsData();
loadPendingRentals();
loadAcceptedRentals();
loadActiveRentals();
loadCompletedRentals();
loadCancelledRentals();
loadReturnedRentals();
// Listen to tab changes to update state if needed
tabController.addListener(() {
@ -77,7 +79,9 @@ class WargaSewaController extends GetxController
}
break;
case 3: // Aktif
// Add Aktif tab logic when needed
if (activeRentals.isEmpty && !isLoadingActive.value) {
loadActiveRentals();
}
break;
case 4: // Selesai
if (completedRentals.isEmpty && !isLoadingCompleted.value) {
@ -89,6 +93,11 @@ class WargaSewaController extends GetxController
loadCancelledRentals();
}
break;
case 6: // Dikembalikan
if (returnedRentals.isEmpty && !isLoadingReturned.value) {
loadReturnedRentals();
}
break;
}
});
}
@ -96,9 +105,26 @@ class WargaSewaController extends GetxController
@override
void onReady() {
super.onReady();
// Ensure nav index is set to Sewa (1) when the controller is ready
// This helps maintain correct state during hot reload
// Jalankan update nav index dan tab index setelah build selesai
Future.delayed(Duration.zero, () {
navigationService.setNavIndex(1);
final args = Get.arguments;
int initialTab = 0;
if (!_tabSetFromArgument &&
args != null &&
args is Map &&
args['tab'] != null) {
initialTab =
args['tab'] is int
? args['tab']
: int.tryParse(args['tab'].toString()) ?? 0;
if (tabController.length > initialTab) {
tabController.index = initialTab;
_tabSetFromArgument = true;
}
}
});
}
@override
@ -118,7 +144,7 @@ class WargaSewaController extends GetxController
// Get sewa_aset data with status "MENUNGGU PEMBAYARAN" or "PEMBAYARAN DENDA"
final sewaAsetList = await authProvider.getSewaAsetByStatus([
'MENUNGGU PEMBAYARAN',
'PEMBAYARAN DENDA'
'PEMBAYARAN DENDA',
]);
debugPrint('Fetched ${sewaAsetList.length} sewa_aset records');
@ -147,7 +173,8 @@ class WargaSewaController extends GetxController
String jamSelesai = '';
String rentangWaktu = '';
if (sewaAset['waktu_mulai'] != null && sewaAset['waktu_selesai'] != null) {
if (sewaAset['waktu_mulai'] != null &&
sewaAset['waktu_selesai'] != null) {
waktuMulai = DateTime.parse(sewaAset['waktu_mulai']);
waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']);
@ -175,7 +202,8 @@ class WargaSewaController extends GetxController
}
// Full time format for waktuSewa
waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
waktuSewa =
'${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
'${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}';
}
@ -208,6 +236,7 @@ class WargaSewaController extends GetxController
'namaSatuanWaktu': namaSatuanWaktu,
'waktuMulai': sewaAset['waktu_mulai'],
'waktuSelesai': sewaAset['waktu_selesai'],
'updated_at': sewaAset['updated_at'],
});
}
@ -245,12 +274,54 @@ class WargaSewaController extends GetxController
}
// Actions
void cancelRental(String id) {
Get.snackbar(
'Info',
'Pembatalan berhasil',
snackPosition: SnackPosition.BOTTOM,
void cancelRental(String id) async {
final confirmed = await Get.dialog<bool>(
AlertDialog(
title: const Text('Konfirmasi Pembatalan'),
content: const Text('Apakah Anda yakin ingin membatalkan pesanan ini?'),
actions: [
TextButton(
onPressed: () => Get.back(result: false),
child: const Text('Tidak'),
),
ElevatedButton(
onPressed: () => Get.back(result: true),
child: const Text('Ya, Batalkan'),
),
],
),
);
if (confirmed == true) {
try {
await asetProvider.client
.from('sewa_aset')
.update({'status': 'DIBATALKAN'})
.eq('id', id);
Get.snackbar(
'Berhasil',
'Pesanan berhasil dibatalkan',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.green,
colorText: Colors.white,
);
// Refresh data
loadRentalsData();
loadPendingRentals();
loadAcceptedRentals();
loadActiveRentals();
loadCompletedRentals();
loadCancelledRentals();
loadReturnedRentals();
} catch (e) {
Get.snackbar(
'Gagal',
'Gagal membatalkan pesanan: $e',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
}
}
}
// Navigate to payment page with the selected rental data
@ -260,10 +331,7 @@ class WargaSewaController extends GetxController
// Navigate to payment page with rental data
Get.toNamed(
Routes.PEMBAYARAN_SEWA,
arguments: {
'orderId': rental['id'],
'rentalData': rental,
},
arguments: {'orderId': rental['id'], 'rentalData': rental},
);
}
@ -312,7 +380,8 @@ class WargaSewaController extends GetxController
String jamSelesai = '';
String rentangWaktu = '';
if (sewaAset['waktu_mulai'] != null && sewaAset['waktu_selesai'] != null) {
if (sewaAset['waktu_mulai'] != null &&
sewaAset['waktu_selesai'] != null) {
waktuMulai = DateTime.parse(sewaAset['waktu_mulai']);
waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']);
@ -340,7 +409,8 @@ class WargaSewaController extends GetxController
}
// Full time format for waktuSewa
waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
waktuSewa =
'${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
'${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}';
}
@ -375,7 +445,9 @@ class WargaSewaController extends GetxController
});
}
debugPrint('Processed ${completedRentals.length} completed rental records');
debugPrint(
'Processed ${completedRentals.length} completed rental records',
);
} catch (e) {
debugPrint('Error loading completed rentals data: $e');
} finally {
@ -392,7 +464,9 @@ class WargaSewaController extends GetxController
cancelledRentals.clear();
// Get sewa_aset data with status "DIBATALKAN"
final sewaAsetList = await authProvider.getSewaAsetByStatus(['DIBATALKAN']);
final sewaAsetList = await authProvider.getSewaAsetByStatus([
'DIBATALKAN',
]);
debugPrint('Fetched ${sewaAsetList.length} cancelled sewa_aset records');
@ -420,7 +494,8 @@ class WargaSewaController extends GetxController
String jamSelesai = '';
String rentangWaktu = '';
if (sewaAset['waktu_mulai'] != null && sewaAset['waktu_selesai'] != null) {
if (sewaAset['waktu_mulai'] != null &&
sewaAset['waktu_selesai'] != null) {
waktuMulai = DateTime.parse(sewaAset['waktu_mulai']);
waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']);
@ -448,7 +523,8 @@ class WargaSewaController extends GetxController
}
// Full time format for waktuSewa
waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
waktuSewa =
'${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
'${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}';
}
@ -484,7 +560,9 @@ class WargaSewaController extends GetxController
});
}
debugPrint('Processed ${cancelledRentals.length} cancelled rental records');
debugPrint(
'Processed ${cancelledRentals.length} cancelled rental records',
);
} catch (e) {
debugPrint('Error loading cancelled rentals data: $e');
} finally {
@ -500,8 +578,11 @@ class WargaSewaController extends GetxController
// Clear existing data
pendingRentals.clear();
// Get sewa_aset data with status "PERIKSA PEMBAYARAN"
final sewaAsetList = await authProvider.getSewaAsetByStatus(['PERIKSA PEMBAYARAN']);
// Get sewa_aset data with status 'PERIKSA PEMBAYARAN' dan 'PERIKSA PEMBAYARAN DENDA'
final sewaAsetList = await authProvider.getSewaAsetByStatus([
'PERIKSA PEMBAYARAN',
'PERIKSA PEMBAYARAN DENDA',
]);
debugPrint('Fetched ${sewaAsetList.length} pending sewa_aset records');
@ -529,7 +610,8 @@ class WargaSewaController extends GetxController
String jamSelesai = '';
String rentangWaktu = '';
if (sewaAset['waktu_mulai'] != null && sewaAset['waktu_selesai'] != null) {
if (sewaAset['waktu_mulai'] != null &&
sewaAset['waktu_selesai'] != null) {
waktuMulai = DateTime.parse(sewaAset['waktu_mulai']);
waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']);
@ -557,7 +639,8 @@ class WargaSewaController extends GetxController
}
// Full time format for waktuSewa
waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
waktuSewa =
'${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
'${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}';
}
@ -637,7 +720,8 @@ class WargaSewaController extends GetxController
String jamSelesai = '';
String rentangWaktu = '';
if (sewaAset['waktu_mulai'] != null && sewaAset['waktu_selesai'] != null) {
if (sewaAset['waktu_mulai'] != null &&
sewaAset['waktu_selesai'] != null) {
waktuMulai = DateTime.parse(sewaAset['waktu_mulai']);
waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']);
@ -665,7 +749,8 @@ class WargaSewaController extends GetxController
}
// Full time format for waktuSewa
waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
waktuSewa =
'${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
'${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}';
}
@ -707,4 +792,166 @@ class WargaSewaController extends GetxController
isLoadingAccepted.value = false;
}
}
Future<void> 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<void> 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;
}
}
}

View File

@ -4,6 +4,7 @@ import 'package:get/get.dart';
import '../controllers/pembayaran_sewa_controller.dart';
import 'package:intl/intl.dart';
import '../../../theme/app_colors.dart';
import 'dart:async';
class PembayaranSewaView extends GetView<PembayaranSewaController> {
const PembayaranSewaView({super.key});
@ -81,6 +82,44 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
],
),
),
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'] ?? '')
.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();
}),
),
],
),
);
@ -174,30 +213,27 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
_buildPaymentTypeSelection(),
const SizedBox(height: 24),
Obx(() {
// Show payment method selection only after selecting a payment type
if (controller.selectedPaymentType.value.isNotEmpty) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildPaymentMethodSelection(),
const SizedBox(height: 24),
if (controller.paymentMethod.value == 'transfer')
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (controller.paymentMethod.value == 'transfer') ...[
_buildTransferInstructions(),
const SizedBox(height: 24),
_buildPaymentProofUpload(),
],
)
else if (controller.paymentMethod.value == 'cash')
if (controller.selectedPaymentType.value ==
'tagihan_awal')
_buildPaymentProofUploadTagihanAwal(),
if (controller.selectedPaymentType.value == 'denda')
_buildPaymentProofUploadDenda(),
] else if (controller.paymentMethod.value == 'cash')
_buildCashInstructions()
else
_buildSelectPaymentMethodPrompt(),
],
);
} else {
// Prompt to select payment type first
return _buildSelectPaymentTypePrompt();
}
}),
@ -272,18 +308,36 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
'Batas waktu pembayaran: ',
style: TextStyle(fontSize: 14, color: Colors.grey),
),
Obx(
() => Text(
controller.orderDetails.value['status'] == 'DIBATALKAN'
? 'Dibatalkan'
: controller.remainingTime.value,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.red[700],
),
),
),
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();
}),
],
),
],
@ -313,29 +367,41 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
'icon': Icons.check_circle,
'step': 2,
},
{
'title': 'Aktif',
'description': 'Aset sewa sedang digunakan',
'icon': Icons.play_circle_fill,
'step': 3,
},
{
'title': 'Pengembalian',
'description': 'Proses pengembalian aset sewa',
'icon': Icons.assignment_return,
'step': 3,
'step': 4,
},
{
'title': 'Pembayaran Denda',
'description': 'Pembayaran denda jika ada kerusakan atau keterlambatan',
'icon': Icons.money,
'step': 4,
'step': 5,
},
{
'title': 'Memeriksa Pembayaran Denda',
'title': 'Periksa Pembayaran Denda',
'description': 'Verifikasi pembayaran denda oleh petugas',
'icon': Icons.fact_check,
'step': 5,
'step': 6,
},
{
'title': 'Selesai',
'description': 'Pesanan sewa telah selesai',
'icon': Icons.task_alt,
'step': 6,
'step': 7,
},
{
'title': 'Dibatalkan',
'description': 'Pesanan ini telah dibatalkan',
'icon': Icons.cancel,
'step': 8,
},
];
@ -360,37 +426,69 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
const SizedBox(height: 20),
Obx(() {
final currentStep = controller.currentStep.value;
final isCancelled = currentStep == 8;
// Filter steps: tampilkan step Dibatalkan hanya jika status DIBATALKAN
final visibleSteps =
isCancelled
? steps
: steps.where((s) => s['step'] != 8).toList();
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: steps.length,
itemCount: visibleSteps.length,
itemBuilder: (context, index) {
final step = steps[index];
final step = visibleSteps[index];
final stepNumber = step['step'] as int;
final isActive = currentStep >= stepNumber;
final isCompleted = currentStep > stepNumber;
final isLast = index == steps.length - 1;
final isActive =
currentStep >= stepNumber &&
(!isCancelled || stepNumber == 8);
final isCompleted =
currentStep > stepNumber &&
(!isCancelled || stepNumber == 8);
final isLast = index == visibleSteps.length - 1;
// Determine the appropriate colors
// Custom color for dibatalkan
final bool isCancelledStep = stepNumber == 8;
final Color iconColor =
isActive
isCancelledStep
? Colors.red
: isCancelled
? Colors.grey[400]!
: isActive
? (isCompleted
? AppColors.success
: AppColors.primary)
: Colors.grey[300]!;
final Color lineColor =
isCompleted ? AppColors.success : Colors.grey[300]!;
isCancelledStep
? Colors.red
: isCancelled
? Colors.grey[400]!
: isCompleted
? AppColors.success
: Colors.grey[300]!;
final Color bgColor =
isActive
isCancelledStep
? Colors.red.withOpacity(0.1)
: isCancelled
? Colors.grey[100]!
: isActive
? (isCompleted
? AppColors.successLight
: AppColors.primarySoft)
: Colors.grey[100]!;
// Icon logic: silang untuk step lain jika dibatalkan
final IconData displayIcon =
isCancelled
? (isCancelledStep ? Icons.cancel : Icons.cancel)
: (isCompleted
? Icons.check
: step['icon'] as IconData);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
children: [
@ -403,9 +501,7 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
border: Border.all(color: iconColor, width: 2),
),
child: Icon(
isCompleted
? Icons.check
: step['icon'] as IconData,
displayIcon,
color: iconColor,
size: 18,
),
@ -425,7 +521,9 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
fontWeight: FontWeight.w600,
fontSize: 14,
color:
isActive
isCancelledStep
? Colors.red
: isActive
? AppColors.textPrimary
: AppColors.textSecondary,
),
@ -434,7 +532,10 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
Text(
step['description'] as String,
style: TextStyle(
color: AppColors.textSecondary,
color:
isCancelledStep
? Colors.red
: AppColors.textSecondary,
fontSize: 12,
),
),
@ -442,13 +543,18 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
],
),
),
if (isCompleted)
if (isCancelled && isCancelledStep)
Icon(Icons.cancel, color: Colors.red, size: 18)
else if (!isCancelled && isCompleted)
Icon(
Icons.check_circle,
color: AppColors.success,
size: 18,
)
else if (currentStep == stepNumber)
else if (isActive &&
!isCancelledStep &&
!isCompleted &&
!isCancelled)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
@ -592,19 +698,20 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
'${controller.sewaAsetDetails.value['kuantitas'] ?? controller.orderDetails.value['quantity'] ?? 0} unit',
),
// Waktu Sewa with sub-points for Waktu Mulai and Waktu Selesai
_buildDetailItemWithSubpoints(
'Waktu Sewa',
[
_buildDetailItemWithSubpoints('Waktu Sewa', [
{
'label': 'Waktu Mulai',
'value': _formatDateTime(controller.sewaAsetDetails.value['waktu_mulai']),
'value': _formatDateTime(
controller.sewaAsetDetails.value['waktu_mulai'],
),
},
{
'label': 'Waktu Selesai',
'value': _formatDateTime(controller.sewaAsetDetails.value['waktu_selesai']),
},
],
'value': _formatDateTime(
controller.sewaAsetDetails.value['waktu_selesai'],
),
},
]),
_buildDetailItem(
'Durasi',
controller.tagihanSewa.value['durasi'] != null
@ -665,7 +772,8 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
// Get values from tagihan_sewa table
final denda = controller.tagihanSewa.value['denda'];
final keterangan = controller.tagihanSewa.value['keterangan'];
final fotoKerusakan = controller.tagihanSewa.value['foto_kerusakan'];
final fotoKerusakan =
controller.tagihanSewa.value['foto_kerusakan'];
debugPrint('Tagihan Denda: $denda');
debugPrint('Tagihan Keterangan: $keterangan');
@ -748,18 +856,35 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
onTap: () {
// Show fullscreen image when tapped
// Use the BuildContext from the current widget tree
_showFullScreenImage(Get.context!, fotoKerusakan);
_showFullScreenImage(
Get.context!,
fotoKerusakan,
);
},
child: Hero(
tag: 'damage-photo-${fotoKerusakan ?? 'default'}',
child: fotoKerusakan != null && fotoKerusakan.toString().isNotEmpty && fotoKerusakan.toString().startsWith('http')
tag:
'damage-photo-${fotoKerusakan ?? 'default'}',
child:
fotoKerusakan != null &&
fotoKerusakan
.toString()
.isNotEmpty &&
fotoKerusakan.toString().startsWith(
'http',
)
? Image.network(
fotoKerusakan.toString(),
width: double.infinity,
height: 200,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
debugPrint('Error loading image: $error');
errorBuilder: (
context,
error,
stackTrace,
) {
debugPrint(
'Error loading image: $error',
);
return Image.asset(
'assets/images/gambar_pendukung.jpg',
width: double.infinity,
@ -880,7 +1005,8 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
// Show fullscreen image dialog
void _showFullScreenImage(BuildContext context, dynamic imageUrl) {
final String imageSource = (imageUrl != null &&
final String imageSource =
(imageUrl != null &&
imageUrl.toString().isNotEmpty &&
imageUrl.toString().startsWith('http'))
? imageUrl.toString()
@ -906,12 +1032,15 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
height: MediaQuery.of(context).size.height,
color: Colors.black.withOpacity(0.8),
child: Center(
child: imageSource.isNotEmpty
child:
imageSource.isNotEmpty
? Image.network(
imageSource,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
debugPrint('Error loading fullscreen image: $error');
debugPrint(
'Error loading fullscreen image: $error',
);
return Image.asset(
'assets/images/gambar_pendukung.jpg',
fit: BoxFit.contain,
@ -1022,7 +1151,7 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
0;
// Get denda value
final denda = controller.sewaAsetDetails.value['denda'] ?? 0;
final denda = controller.tagihanSewa.value['denda'] ?? 0;
return Column(
children: [
@ -1042,7 +1171,7 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
amount: 'Rp ${NumberFormat('#,###').format(denda)}',
type: 'denda',
description: 'Pembayaran untuk denda yang diberikan',
isDisabled: denda == 0,
isDisabled: denda == null || denda == 0,
isSelected: controller.selectedPaymentType.value == 'denda',
),
],
@ -1316,7 +1445,7 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
),
const SizedBox(height: 16),
Obx(() {
if (controller.bankAccounts.isEmpty) {
if (controller.isLoading.value) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
@ -1324,9 +1453,21 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
),
);
}
if (controller.bankAccounts.isEmpty) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Text(
'Tidak ada rekening bank yang tersedia.\nSilakan hubungi admin.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
),
);
}
return Column(
children: controller.bankAccounts.map((account) {
children:
controller.bankAccounts.map((account) {
return Column(
children: [
_buildBankAccount(
@ -1365,13 +1506,6 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
title: 'Tunggu konfirmasi',
description: 'Pembayaran Anda akan dikonfirmasi oleh petugas',
),
_buildTransferStep(
icon: Icons.receipt_long,
title: 'Dapatkan struk pembayaran',
description:
'Setelah dikonfirmasi, akan dibuatkan struk pembayaran',
isLast: true,
),
],
),
),
@ -1508,7 +1642,9 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
final scaffoldMessenger = ScaffoldMessenger.of(Get.context!);
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text('Nomor rekening $accountNumber disalin ke clipboard'),
content: Text(
'Nomor rekening $accountNumber disalin ke clipboard',
),
duration: const Duration(seconds: 2),
backgroundColor: Colors.green[700],
behavior: SnackBarBehavior.floating,
@ -1541,7 +1677,8 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
GestureDetector(
onTap: () {
// Get the total price
final totalPrice = controller.orderDetails.value['total_price'] ?? 0;
final totalPrice =
controller.orderDetails.value['total_price'] ?? 0;
// Format the total price as a number without 'Rp' prefix
final formattedPrice = totalPrice.toString();
@ -1552,7 +1689,9 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
final scaffoldMessenger = ScaffoldMessenger.of(Get.context!);
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text('Nominal Rp $formattedPrice disalin ke clipboard'),
content: Text(
'Nominal Rp $formattedPrice disalin ke clipboard',
),
duration: const Duration(seconds: 2),
backgroundColor: Colors.green[700],
behavior: SnackBarBehavior.floating,
@ -1570,11 +1709,7 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
),
),
const SizedBox(width: 4),
Icon(
Icons.copy,
size: 14,
color: Colors.deepPurple[300],
),
Icon(Icons.copy, size: 14, color: Colors.deepPurple[300]),
],
),
),
@ -1635,8 +1770,8 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
);
}
// Payment Proof Upload
Widget _buildPaymentProofUpload() {
// Payment Proof Upload for Tagihan Awal
Widget _buildPaymentProofUploadTagihanAwal() {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
@ -1661,48 +1796,55 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
spacing: 12,
runSpacing: 12,
children: [
// Display all existing images
...List.generate(
controller.paymentProofImages.length,
(index) => _buildImageItem(index),
controller.paymentProofImagesTagihanAwal.length,
(index) => _buildImageItemTagihanAwal(index),
),
// Add photo button
_buildAddPhotoButton(),
_buildAddPhotoButtonTagihanAwal(),
],
);
}),
const SizedBox(height: 16),
// Upload button
Obx(() {
// Disable button if there are no changes or if upload is in progress
final bool isDisabled = controller.isUploading.value || !controller.hasUnsavedChanges.value;
final bool isDisabled =
controller.isUploading.value ||
!controller.hasUnsavedChangesTagihanAwal.value;
return ElevatedButton.icon(
onPressed: isDisabled ? null : controller.uploadPaymentProof,
icon: controller.isUploading.value
onPressed:
isDisabled
? null
: () => controller.uploadPaymentProof(
jenisPembayaran: 'tagihan awal',
),
icon:
controller.isUploading.value
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
)
: const Icon(Icons.save),
label: Text(controller.isUploading.value
label: Text(
controller.isUploading.value
? 'Menyimpan...'
: (controller.hasUnsavedChanges.value ? 'Simpan' : 'Tidak Ada Perubahan')),
: (controller.hasUnsavedChangesTagihanAwal.value
? 'Simpan'
: 'Tidak Ada Perubahan'),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 48),
// Gray out button when disabled
disabledBackgroundColor: Colors.grey[300],
disabledForegroundColor: Colors.grey[600],
),
);
}),
// Upload progress indicator
Obx(() {
if (controller.isUploading.value) {
return Column(
@ -1711,7 +1853,9 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
LinearProgressIndicator(
value: controller.uploadProgress.value,
backgroundColor: Colors.grey[200],
valueColor: AlwaysStoppedAnimation<Color>(Colors.blue[700]!),
valueColor: AlwaysStoppedAnimation<Color>(
Colors.blue[700]!,
),
),
const SizedBox(height: 8),
Text(
@ -1730,12 +1874,14 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
);
}
// Build individual image item with remove button
Widget _buildImageItem(int index) {
final image = controller.paymentProofImages[index];
Widget _buildImageItemTagihanAwal(int index) {
final image = controller.paymentProofImagesTagihanAwal[index];
final status =
controller.orderDetails.value['status']?.toString().toUpperCase() ?? '';
final canDelete =
status == 'MENUNGGU PEMBAYARAN' || status == 'PERIKSA PEMBAYARAN';
return Stack(
children: [
// Make the container tappable to show full-screen image
GestureDetector(
onTap: () => controller.showFullScreenImage(image),
child: Container(
@ -1751,7 +1897,7 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
),
),
),
// Close/remove button remains the same
if (canDelete)
Positioned(
top: 4,
right: 4,
@ -1771,8 +1917,7 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
);
}
// Build add photo button
Widget _buildAddPhotoButton() {
Widget _buildAddPhotoButtonTagihanAwal() {
return InkWell(
onTap: () => _showImageSourceOptions(Get.context!),
child: Container(
@ -1786,11 +1931,183 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.add_a_photo,
size: 40,
Icon(Icons.add_a_photo, size: 40, color: Colors.blue[700]),
const SizedBox(height: 8),
Text(
'Tambah Foto',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.blue[700],
),
),
],
),
),
);
}
// Payment Proof Upload for Denda
Widget _buildPaymentProofUploadDenda() {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.photo_camera, size: 24),
SizedBox(width: 8),
Text(
'Unggah Bukti Pembayaran',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 16),
Obx(() {
return Wrap(
spacing: 12,
runSpacing: 12,
children: [
...List.generate(
controller.paymentProofImagesDenda.length,
(index) => _buildImageItemDenda(index),
),
_buildAddPhotoButtonDenda(),
],
);
}),
const SizedBox(height: 16),
Obx(() {
final bool isDisabled =
controller.isUploading.value ||
!controller.hasUnsavedChangesDenda.value;
return ElevatedButton.icon(
onPressed:
isDisabled
? null
: () => controller.uploadPaymentProof(
jenisPembayaran: 'denda',
),
icon:
controller.isUploading.value
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
)
: const Icon(Icons.save),
label: Text(
controller.isUploading.value
? 'Menyimpan...'
: (controller.hasUnsavedChangesDenda.value
? 'Simpan'
: 'Tidak Ada Perubahan'),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 48),
disabledBackgroundColor: Colors.grey[300],
disabledForegroundColor: Colors.grey[600],
),
);
}),
Obx(() {
if (controller.isUploading.value) {
return Column(
children: [
const SizedBox(height: 16),
LinearProgressIndicator(
value: controller.uploadProgress.value,
backgroundColor: Colors.grey[200],
valueColor: AlwaysStoppedAnimation<Color>(
Colors.blue[700]!,
),
),
const SizedBox(height: 8),
Text(
'Mengunggah bukti pembayaran... ${(controller.uploadProgress.value * 100).toInt()}%',
style: const TextStyle(fontSize: 12),
),
],
);
} else {
return const SizedBox.shrink();
}
}),
],
),
),
);
}
Widget _buildImageItemDenda(int index) {
final image = controller.paymentProofImagesDenda[index];
final status =
controller.orderDetails.value['status']?.toString().toUpperCase() ?? '';
final canDelete = status != 'SELESAI';
return Stack(
children: [
GestureDetector(
onTap: () => controller.showFullScreenImage(image),
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey[300]!),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: controller.getImageWidget(image),
),
),
),
if (canDelete)
Positioned(
top: 4,
right: 4,
child: InkWell(
onTap: () => controller.removeImage(image),
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: const Icon(Icons.close, size: 18, color: Colors.red),
),
),
),
],
);
}
Widget _buildAddPhotoButtonDenda() {
return InkWell(
onTap: () => _showImageSourceOptions(Get.context!),
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
color: Colors.blue[50],
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue[200]!),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add_a_photo, size: 40, color: Colors.blue[700]),
const SizedBox(height: 8),
Text(
'Tambah Foto',
@ -1871,13 +2188,6 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
description:
'Total: Rp ${controller.orderDetails.value['total_price'] ?? 0}',
),
_buildCashStep(
number: 4,
title: 'Dapatkan struk pembayaran',
description:
'Setelah dikonfirmasi, akan dibuatkan struk pembayaran',
isLast: true,
),
],
),
),
@ -2032,7 +2342,8 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
final denda = controller.tagihanSewa.value['denda'] ?? 0;
// Get total dibayarkan from tagihan_dibayar
final dibayarkan = controller.tagihanSewa.value['tagihan_dibayar'] ?? 0;
final dibayarkan =
controller.tagihanSewa.value['tagihan_dibayar'] ?? 0;
debugPrint('Tagihan Awal: $tagihanAwal');
debugPrint('Denda: $denda');
@ -2080,7 +2391,10 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
}
// Helper method to build detail item with subpoints
Widget _buildDetailItemWithSubpoints(String label, List<Map<String, String>> subpoints) {
Widget _buildDetailItemWithSubpoints(
String label,
List<Map<String, String>> subpoints,
) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
@ -2097,7 +2411,8 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
),
const SizedBox(height: 8),
// Subpoints with indentation
...subpoints.map((subpoint) => Padding(
...subpoints.map(
(subpoint) => Padding(
padding: const EdgeInsets.only(left: 16, bottom: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@ -2107,25 +2422,21 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
flex: 2,
child: Text(
subpoint['label'] ?? '',
style: TextStyle(
fontSize: 13,
color: Colors.grey[600],
),
style: TextStyle(fontSize: 13, color: Colors.grey[600]),
),
),
Expanded(
flex: 3,
child: Text(
subpoint['value'] ?? '-',
style: const TextStyle(
fontSize: 13,
),
style: const TextStyle(fontSize: 13),
textAlign: TextAlign.right,
),
),
],
),
)),
),
),
],
),
);
@ -2152,3 +2463,85 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
}
}
}
class CountdownTimerWidget extends StatefulWidget {
final DateTime updatedAt;
final VoidCallback? onTimeout;
const CountdownTimerWidget({
required this.updatedAt,
this.onTimeout,
Key? key,
}) : super(key: key);
@override
State<CountdownTimerWidget> createState() => _CountdownTimerWidgetState();
}
class _CountdownTimerWidgetState extends State<CountdownTimerWidget> {
late Duration remaining;
Timer? timer;
@override
void initState() {
super.initState();
print(
'DEBUG [CountdownTimerWidget] updatedAt: ' +
widget.updatedAt.toIso8601String(),
);
updateRemaining();
timer = Timer.periodic(
const Duration(seconds: 1),
(_) => updateRemaining(),
);
}
void updateRemaining() {
final now = DateTime.now();
final end = widget.updatedAt.add(const Duration(hours: 1));
setState(() {
remaining = end.difference(now);
if (remaining.isNegative) {
remaining = Duration.zero;
timer?.cancel();
widget.onTimeout?.call();
}
});
}
@override
void dispose() {
timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (remaining.inSeconds <= 0) {
return Text('Waktu habis', style: TextStyle(color: Colors.red));
}
final h = remaining.inHours;
final m = remaining.inMinutes % 60;
final s = remaining.inSeconds % 60;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.08),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.withOpacity(0.3), width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.timer_outlined, size: 14, color: Colors.red),
const SizedBox(width: 4),
Text(
'Bayar dalam ${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.red,
),
),
],
),
);
}
}

View File

@ -117,6 +117,7 @@ class SewaAsetView extends GetView<SewaAsetController> {
),
],
),
dividerColor: Colors.transparent,
labelColor: Colors.white,
unselectedLabelColor: const Color(
0xFF718093,

View File

@ -154,10 +154,10 @@ class WargaDashboardView extends GetView<WargaDashboardController> {
'route': () => controller.navigateToRentals(),
},
{
'title': 'Bayar',
'icon': Icons.payment_outlined,
'title': 'Paket',
'icon': Icons.widgets_outlined,
'color': const Color(0xFF2196F3),
'route': () => Get.toNamed(Routes.PEMBAYARAN_SEWA),
'route': () => controller.toSewaAsetTabPaket(),
},
];
@ -218,32 +218,44 @@ class WargaDashboardView extends GetView<WargaDashboardController> {
child: Column(
children: [
// Sewa Diterima
_buildActivityCard(
Obx(
() => _buildActivityCard(
title: 'Sewa Diterima',
value: controller.activeRentals.length.toString(),
value: controller.diterimaCount.value.toString(),
icon: Icons.check_circle_outline,
color: AppColors.success,
onTap: () => controller.navigateToRentals(),
onTap:
() =>
Get.toNamed(Routes.WARGA_SEWA, arguments: {'tab': 2}),
),
),
const SizedBox(height: 12),
// Tagihan Aktif
_buildActivityCard(
Obx(
() => _buildActivityCard(
title: 'Tagihan Aktif',
value: controller.activeBills.length.toString(),
value: controller.tagihanAktifCount.value.toString(),
icon: Icons.receipt_long_outlined,
color: AppColors.warning,
onTap: () => Get.toNamed(Routes.PEMBAYARAN_SEWA),
onTap:
() =>
Get.toNamed(Routes.WARGA_SEWA, arguments: {'tab': 0}),
),
),
const SizedBox(height: 12),
// Denda Aktif
_buildActivityCard(
Obx(
() => _buildActivityCard(
title: 'Denda Aktif',
value: controller.activePenalties.length.toString(),
value: controller.dendaAktifCount.value.toString(),
icon: Icons.warning_amber_outlined,
color: AppColors.error,
onTap: () => Get.toNamed(Routes.PEMBAYARAN_SEWA),
onTap:
() =>
Get.toNamed(Routes.WARGA_SEWA, arguments: {'tab': 0}),
),
),
],
),
@ -357,7 +369,7 @@ class WargaDashboardView extends GetView<WargaDashboardController> {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Sewa Diterima',
'Sewa Aktif',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
@ -498,31 +510,34 @@ class WargaDashboardView extends GetView<WargaDashboardController> {
),
child: Row(
children: [
// Asset icon
// Asset icon/gambar
Container(
padding: const EdgeInsets.all(12),
width: 48,
height: 48,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppColors.primary.withOpacity(0.7),
AppColors.primary,
],
),
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: AppColors.primary.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 3),
color: AppColors.primary.withOpacity(0.08),
),
],
),
child: const Icon(
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child:
rental['imageUrl'] != null &&
rental['imageUrl'].toString().isNotEmpty
? Image.network(
rental['imageUrl'],
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) => Icon(
Icons.local_shipping,
color: Colors.white,
size: 24,
color: AppColors.primary,
size: 28,
),
)
: Icon(
Icons.local_shipping,
color: AppColors.primary,
size: 28,
),
),
),
const SizedBox(width: 16),
@ -533,7 +548,7 @@ class WargaDashboardView extends GetView<WargaDashboardController> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
rental['name'],
rental['name'] ?? '-',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
@ -542,7 +557,7 @@ class WargaDashboardView extends GetView<WargaDashboardController> {
),
const SizedBox(height: 4),
Text(
rental['time'],
rental['waktuSewa'] ?? '',
style: TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
@ -567,7 +582,7 @@ class WargaDashboardView extends GetView<WargaDashboardController> {
),
),
child: Text(
rental['price'],
rental['totalPrice'] ?? 'Rp 0',
style: TextStyle(
color: AppColors.primary,
fontWeight: FontWeight.bold,
@ -591,14 +606,14 @@ class WargaDashboardView extends GetView<WargaDashboardController> {
child: _buildInfoItem(
icon: Icons.timer_outlined,
title: 'Durasi',
value: rental['duration'],
value: rental['duration'] ?? '-',
),
),
Expanded(
child: _buildInfoItem(
icon: Icons.calendar_today_outlined,
title: 'Status',
value: 'Diterima',
value: rental['status'] ?? '-',
valueColor: AppColors.success,
),
),
@ -608,7 +623,7 @@ class WargaDashboardView extends GetView<WargaDashboardController> {
const SizedBox(height: 16),
// Action buttons
if (rental['can_extend'])
if ((rental['can_extend'] ?? false) == true)
OutlinedButton.icon(
onPressed: () => controller.extendRental(rental['id']),
icon: const Icon(Icons.update, size: 18),

View File

@ -3,12 +3,16 @@ import 'package:get/get.dart';
import '../controllers/warga_dashboard_controller.dart';
import '../views/warga_layout.dart';
import '../../../theme/app_colors.dart';
import '../../../widgets/app_drawer.dart';
import '../../../services/navigation_service.dart';
class WargaProfileView extends GetView<WargaDashboardController> {
const WargaProfileView({super.key});
@override
Widget build(BuildContext context) {
final navigationService = Get.find<NavigationService>();
navigationService.setNavIndex(2);
return WargaLayout(
appBar: AppBar(
title: const Text('Profil Saya'),
@ -29,6 +33,14 @@ class WargaProfileView extends GetView<WargaDashboardController> {
),
],
),
drawer: AppDrawer(
onNavItemTapped: (index) {
// Handle navigation if needed
},
onLogout: () {
controller.logout();
},
),
backgroundColor: Colors.grey.shade100,
body: RefreshIndicator(
color: AppColors.primary,

View File

@ -6,6 +6,7 @@ import '../views/warga_layout.dart';
import '../../../services/navigation_service.dart';
import '../../../widgets/app_drawer.dart';
import '../../../theme/app_colors.dart';
import 'dart:async';
class WargaSewaView extends GetView<WargaSewaController> {
const WargaSewaView({super.key});
@ -50,6 +51,7 @@ class WargaSewaView extends GetView<WargaSewaController> {
_buildPendingTab(),
_buildDiterimaTab(),
_buildAktifTab(),
_buildDikembalikanTab(),
_buildSelesaiTab(),
_buildDibatalkanTab(),
],
@ -119,6 +121,7 @@ class WargaSewaView extends GetView<WargaSewaController> {
_buildTab(text: 'Pending', icon: Icons.pending_outlined),
_buildTab(text: 'Diterima', icon: Icons.check_circle_outline),
_buildTab(text: 'Aktif', icon: Icons.play_circle_outline),
_buildTab(text: 'Dikembalikan', icon: Icons.assignment_return),
_buildTab(text: 'Selesai', icon: Icons.task_alt_outlined),
_buildTab(text: 'Dibatalkan', icon: Icons.cancel_outlined),
],
@ -147,9 +150,7 @@ class WargaSewaView extends GetView<WargaSewaController> {
return Obx(() {
// Show loading indicator while fetching data
if (controller.isLoadingPending.value) {
return const Center(
child: CircularProgressIndicator(),
);
return const Center(child: CircularProgressIndicator());
}
// Check if there is any data to display
@ -158,11 +159,14 @@ class WargaSewaView extends GetView<WargaSewaController> {
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.all(20),
child: Column(
children: controller.pendingRentals
.map((rental) => Padding(
children:
controller.pendingRentals
.map(
(rental) => Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: _buildUnpaidRentalCard(rental),
))
),
)
.toList(),
),
);
@ -182,46 +186,189 @@ class WargaSewaView extends GetView<WargaSewaController> {
Widget _buildAktifTab() {
return Obx(() {
// Show loading indicator while fetching data
if (controller.isLoading.value) {
return const Center(
child: CircularProgressIndicator(),
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,
);
}
// Placeholder content for the Aktif tab
return const Center(
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(),
),
);
});
}
Widget _buildAktifRentalCard(Map<String, dynamic> rental) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.play_circle_filled,
size: 80,
// Header section with status
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColors.info.withOpacity(0.1),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Icon(Icons.play_circle_fill, size: 18, color: AppColors.info),
const SizedBox(width: 8),
Text(
rental['status'] ?? 'AKTIF',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: AppColors.info,
),
),
],
),
),
// Asset details
Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
rental['imageUrl'] ?? '',
width: 80,
height: 80,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) => Container(
width: 80,
height: 80,
color: Colors.grey[200],
child: const Icon(
Icons.broken_image,
color: Colors.grey,
),
SizedBox(height: 16),
Text(
'Tab Aktif',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Konten tab Aktif akan ditampilkan di sini',
style: TextStyle(color: Colors.grey),
rental['name'] ?? 'Aset',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8),
_buildInfoRow(
icon: Icons.inventory_2_outlined,
text: '${rental['jumlahUnit'] ?? 0} Unit',
),
const SizedBox(height: 6),
_buildInfoRow(
icon: Icons.calendar_today_outlined,
text: rental['tanggalSewa'] ?? '',
),
const SizedBox(height: 6),
_buildInfoRow(
icon: Icons.schedule,
text: rental['rentangWaktu'] ?? '',
),
],
),
),
],
),
),
Divider(height: 1, thickness: 1, color: Colors.grey.shade100),
// Price section
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Total',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
Text(
rental['totalPrice'] ?? 'Rp 0',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.primary,
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildActionButton(
icon: Icons.info_outline,
label: 'Lihat Detail',
onPressed: () => controller.viewRentalDetail(rental),
iconColor: AppColors.info,
),
),
],
),
],
),
),
],
),
);
});
}
Widget _buildBelumBayarTab() {
return Obx(() {
// Show loading indicator while fetching data
if (controller.isLoading.value) {
return const Center(
child: CircularProgressIndicator(),
);
return const Center(child: CircularProgressIndicator());
}
// Check if there is any data to display
@ -232,12 +379,16 @@ class WargaSewaView extends GetView<WargaSewaController> {
child: Column(
children: [
// Build a card for each rental item
...controller.rentals.map((rental) => Column(
...controller.rentals
.map(
(rental) => Column(
children: [
_buildUnpaidRentalCard(rental),
const SizedBox(height: 20),
],
)).toList(),
),
)
.toList(),
_buildTipsSection(),
],
),
@ -259,7 +410,8 @@ class WargaSewaView extends GetView<WargaSewaController> {
Widget _buildUnpaidRentalCard(Map<String, dynamic> rental) {
// Determine status color based on status
final bool isPembayaranDenda = rental['status'] == 'PEMBAYARAN DENDA';
final Color statusColor = isPembayaranDenda ? AppColors.error : AppColors.warning;
final Color statusColor =
isPembayaranDenda ? AppColors.error : AppColors.warning;
return Container(
decoration: BoxDecoration(
@ -289,7 +441,9 @@ class WargaSewaView extends GetView<WargaSewaController> {
mainAxisAlignment: MainAxisAlignment.start,
children: [
Icon(
isPembayaranDenda ? Icons.warning_amber_rounded : Icons.access_time_rounded,
isPembayaranDenda
? Icons.warning_amber_rounded
: Icons.access_time_rounded,
size: 18,
color: statusColor,
),
@ -315,7 +469,9 @@ class WargaSewaView extends GetView<WargaSewaController> {
// Asset image with rounded corners
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http')
child:
rental['imageUrl'] != null &&
rental['imageUrl'].toString().startsWith('http')
? Image.network(
rental['imageUrl'],
width: 90,
@ -338,7 +494,8 @@ class WargaSewaView extends GetView<WargaSewaController> {
},
)
: Image.asset(
rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg',
rental['imageUrl'] ??
'assets/images/gambar_pendukung.jpg',
width: 90,
height: 90,
fit: BoxFit.cover,
@ -389,39 +546,11 @@ class WargaSewaView extends GetView<WargaSewaController> {
text: rental['rentangWaktu'] ?? '',
),
const SizedBox(height: 12),
// Countdown timer
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: AppColors.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.error.withOpacity(0.3),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.timer_outlined,
size: 14,
color: AppColors.error,
),
const SizedBox(width: 4),
Text(
'Bayar dalam ${rental['countdown'] ?? '00:59:59'}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: AppColors.error,
),
),
],
),
if ((rental['status'] ?? '').toString().toUpperCase() ==
'MENUNGGU PEMBAYARAN' &&
rental['updated_at'] != null)
CountdownTimerWidget(
updatedAt: DateTime.parse(rental['updated_at']),
),
],
),
@ -465,7 +594,10 @@ class WargaSewaView extends GetView<WargaSewaController> {
ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(
backgroundColor: rental['status'] == 'PEMBAYARAN DENDA' ? AppColors.error : AppColors.warning,
backgroundColor:
rental['status'] == 'PEMBAYARAN DENDA'
? AppColors.error
: AppColors.warning,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(
@ -477,8 +609,13 @@ class WargaSewaView extends GetView<WargaSewaController> {
),
),
child: Text(
rental['status'] == 'PEMBAYARAN DENDA' ? 'Bayar Denda' : 'Bayar Sekarang',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
rental['status'] == 'PEMBAYARAN DENDA'
? 'Bayar Denda'
: 'Bayar Sekarang',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
],
@ -502,6 +639,10 @@ class WargaSewaView extends GetView<WargaSewaController> {
),
),
const SizedBox(width: 8),
if ((rental['status'] ?? '').toString().toUpperCase() !=
'PEMBAYARAN DENDA' &&
(rental['status'] ?? '').toString().toUpperCase() !=
'PERIKSA PEMBAYARAN DENDA')
Expanded(
child: _buildActionButton(
icon: Icons.cancel_outlined,
@ -560,9 +701,7 @@ class WargaSewaView extends GetView<WargaSewaController> {
return Obx(() {
// Show loading indicator while fetching data
if (controller.isLoadingAccepted.value) {
return const Center(
child: CircularProgressIndicator(),
);
return const Center(child: CircularProgressIndicator());
}
// Check if there is any data to display
@ -573,12 +712,16 @@ class WargaSewaView extends GetView<WargaSewaController> {
child: Column(
children: [
// Build a card for each accepted rental item
...controller.acceptedRentals.map((rental) => Column(
...controller.acceptedRentals
.map(
(rental) => Column(
children: [
_buildDiterimaRentalCard(rental),
const SizedBox(height: 20),
],
)).toList(),
),
)
.toList(),
_buildTipsSectionDiterima(),
],
),
@ -652,7 +795,9 @@ class WargaSewaView extends GetView<WargaSewaController> {
// Asset image with rounded corners
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http')
child:
rental['imageUrl'] != null &&
rental['imageUrl'].toString().startsWith('http')
? Image.network(
rental['imageUrl'],
width: 90,
@ -675,7 +820,8 @@ class WargaSewaView extends GetView<WargaSewaController> {
},
)
: Image.asset(
rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg',
rental['imageUrl'] ??
'assets/images/gambar_pendukung.jpg',
width: 90,
height: 90,
fit: BoxFit.cover,
@ -826,11 +972,14 @@ class WargaSewaView extends GetView<WargaSewaController> {
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.all(20),
child: Column(
children: controller.completedRentals
.map((rental) => Padding(
children:
controller.completedRentals
.map(
(rental) => Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: _buildSelesaiRentalCard(rental),
))
),
)
.toList(),
),
);
@ -888,7 +1037,9 @@ class WargaSewaView extends GetView<WargaSewaController> {
// Asset image with rounded corners
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http')
child:
rental['imageUrl'] != null &&
rental['imageUrl'].toString().startsWith('http')
? Image.network(
rental['imageUrl'],
width: 90,
@ -911,7 +1062,8 @@ class WargaSewaView extends GetView<WargaSewaController> {
},
)
: Image.asset(
rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg',
rental['imageUrl'] ??
'assets/images/gambar_pendukung.jpg',
width: 90,
height: 90,
fit: BoxFit.cover,
@ -972,11 +1124,7 @@ class WargaSewaView extends GetView<WargaSewaController> {
),
// Divider
Divider(
color: Colors.grey.shade200,
thickness: 1,
height: 1,
),
Divider(color: Colors.grey.shade200, thickness: 1, height: 1),
// Price section
Padding(
@ -1047,11 +1195,14 @@ class WargaSewaView extends GetView<WargaSewaController> {
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.all(20),
child: Column(
children: controller.cancelledRentals
.map((rental) => Padding(
children:
controller.cancelledRentals
.map(
(rental) => Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: _buildDibatalkanRentalCard(rental),
))
),
)
.toList(),
),
);
@ -1109,7 +1260,9 @@ class WargaSewaView extends GetView<WargaSewaController> {
// Asset image with rounded corners
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http')
child:
rental['imageUrl'] != null &&
rental['imageUrl'].toString().startsWith('http')
? Image.network(
rental['imageUrl'],
width: 90,
@ -1132,7 +1285,8 @@ class WargaSewaView extends GetView<WargaSewaController> {
},
)
: Image.asset(
rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg',
rental['imageUrl'] ??
'assets/images/gambar_pendukung.jpg',
width: 90,
height: 90,
fit: BoxFit.cover,
@ -1185,7 +1339,8 @@ class WargaSewaView extends GetView<WargaSewaController> {
icon: Icons.access_time,
text: rental['duration'] ?? '-',
),
if (rental['alasanPembatalan'] != null && rental['alasanPembatalan'] != '-')
if (rental['alasanPembatalan'] != null &&
rental['alasanPembatalan'] != '-')
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: _buildInfoRow(
@ -1201,11 +1356,7 @@ class WargaSewaView extends GetView<WargaSewaController> {
),
// Divider
Divider(
color: Colors.grey.shade200,
thickness: 1,
height: 1,
),
Divider(color: Colors.grey.shade200, thickness: 1, height: 1),
// Price section
Padding(
@ -1243,15 +1394,6 @@ class WargaSewaView extends GetView<WargaSewaController> {
iconColor: AppColors.info,
),
),
const SizedBox(width: 8),
Expanded(
child: _buildActionButton(
icon: Icons.refresh,
label: 'Pesan Kembali',
onPressed: () {},
iconColor: AppColors.success,
),
),
],
),
],
@ -1490,4 +1632,261 @@ class WargaSewaView extends GetView<WargaSewaController> {
),
);
}
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(),
),
);
});
}
Widget _buildDikembalikanRentalCard(Map<String, dynamic> rental) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
children: [
// Header section with status
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColors.info.withOpacity(0.1),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Icon(Icons.assignment_return, size: 18, color: AppColors.info),
const SizedBox(width: 8),
Text(
rental['status'] ?? 'DIKEMBALIKAN',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: AppColors.info,
),
),
],
),
),
// Asset details
Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
rental['imageUrl'] ?? '',
width: 64,
height: 64,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) => Container(
width: 64,
height: 64,
color: Colors.grey[200],
child: const Icon(Icons.image, color: Colors.grey),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
rental['name'] ?? 'Aset',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8),
_buildInfoRow(
icon: Icons.inventory_2_outlined,
text: '${rental['jumlahUnit'] ?? 0} Unit',
),
const SizedBox(height: 6),
_buildInfoRow(
icon: Icons.calendar_today_outlined,
text: rental['tanggalSewa'] ?? '',
),
const SizedBox(height: 6),
_buildInfoRow(
icon: Icons.schedule,
text: rental['rentangWaktu'] ?? '',
),
],
),
),
],
),
),
Divider(height: 1, thickness: 1, color: Colors.grey.shade100),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Total',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
Text(
rental['totalPrice'] ?? 'Rp 0',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.primary,
),
),
],
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: _buildActionButton(
icon: Icons.info_outline,
label: 'Lihat Detail',
onPressed: () => controller.viewRentalDetail(rental),
iconColor: AppColors.info,
),
),
],
),
),
],
),
);
}
}
class CountdownTimerWidget extends StatefulWidget {
final DateTime updatedAt;
final VoidCallback? onTimeout;
const CountdownTimerWidget({
required this.updatedAt,
this.onTimeout,
Key? key,
}) : super(key: key);
@override
State<CountdownTimerWidget> createState() => _CountdownTimerWidgetState();
}
class _CountdownTimerWidgetState extends State<CountdownTimerWidget> {
late Duration remaining;
Timer? timer;
@override
void initState() {
super.initState();
updateRemaining();
timer = Timer.periodic(
const Duration(seconds: 1),
(_) => updateRemaining(),
);
print('DEBUG updated_at: ${widget.updatedAt}');
}
void updateRemaining() {
final now = DateTime.now();
final deadline = widget.updatedAt.add(const Duration(hours: 1));
setState(() {
remaining = deadline.difference(now);
if (remaining.isNegative) {
remaining = Duration.zero;
timer?.cancel();
widget.onTimeout?.call();
}
});
}
@override
void dispose() {
timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (remaining.inSeconds <= 0) {
return Text('Waktu habis', style: TextStyle(color: Colors.red));
}
final h = remaining.inHours;
final m = remaining.inMinutes % 60;
final s = remaining.inSeconds % 60;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.08),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.withOpacity(0.3), width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.timer_outlined, size: 14, color: Colors.red),
const SizedBox(width: 4),
Text(
'Bayar dalam ${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.red,
),
),
],
),
);
}
}

View File

@ -0,0 +1,88 @@
import 'package:supabase_flutter/supabase_flutter.dart';
import '../data/models/pembayaran_model.dart';
class PembayaranService {
final SupabaseClient _supabase = Supabase.instance.client;
/// Ambil data pembayaran antara [start] (inklusif) dan [end] (eksklusif).
Future<List<PembayaranModel>> _fetchBetween(
DateTime start,
DateTime end,
) async {
final data = await _supabase
.from('pembayaran')
.select('id, metode_pembayaran, total_pembayaran, waktu_pembayaran')
.gte('waktu_pembayaran', start.toIso8601String())
.lt('waktu_pembayaran', end.toIso8601String())
.order('waktu_pembayaran', ascending: true);
return (data as List<dynamic>)
.map((e) => PembayaranModel.fromJson(e as Map<String, dynamic>))
.toList();
}
/// Hitung statistik yang diminta.
Future<Map<String, dynamic>> fetchStats() async {
final now = DateTime.now();
// Rentang bulan ini: [awal bulan ini, awal bulan depan)
final thisMonthStart = DateTime(now.year, now.month, 1);
final nextMonthStart = DateTime(now.year, now.month + 1, 1);
// Bulan lalu: [awal bulan lalu, awal bulan ini)
final lastMonthStart = DateTime(now.year, now.month - 1, 1);
final thisMonthEnd = thisMonthStart;
// 6 bulan terakhir: [6 bulan lalu, sekarang]
final sixMonthsAgo = DateTime(now.year, now.month - 6, 1);
// 1) Data bulan ini & bulan lalu
final thisMonthData = await _fetchBetween(thisMonthStart, nextMonthStart);
final lastMonthData = await _fetchBetween(lastMonthStart, thisMonthEnd);
// 2) Data 6 bulan terakhir
final sixMonthsData = await _fetchBetween(sixMonthsAgo, nextMonthStart);
// 3) Hitung total pendapatan
double sum(List<PembayaranModel> list) =>
list.fold(0.0, (acc, e) => acc + e.totalPembayaran);
final totalThis = sum(thisMonthData);
final totalLast = sum(lastMonthData);
final totalSix = sum(sixMonthsData);
// 4) Persentase selisih (bulanan)
double percentDiff = 0.0;
if (totalLast != 0) {
percentDiff = ((totalThis - totalLast) / totalLast) * 100;
}
// 5) Total per metode (hanya dari bulan ini, misalnya)
double totTunai = 0.0, totTransfer = 0.0;
for (var p in thisMonthData) {
if (p.metodePembayaran.toLowerCase() == 'tunai') {
totTunai += p.totalPembayaran;
} else if (p.metodePembayaran.toLowerCase() == 'transfer') {
totTransfer += p.totalPembayaran;
}
}
// 6) Trend per month (6 months, oldest to newest)
List<double> trendPerMonth = [];
for (int i = 5; i >= 0; i--) {
final dt = DateTime(now.year, now.month - i, 1);
final dtNext = DateTime(now.year, now.month - i + 1, 1);
final monthData = await _fetchBetween(dt, dtNext);
trendPerMonth.add(sum(monthData));
}
return {
'totalThisMonth': totalThis,
'percentComparedLast': percentDiff,
'totalTunai': totTunai,
'totalTransfer': totTransfer,
'totalLastSixMonths': totalSix,
'trendPerMonth': trendPerMonth,
};
}
}

View File

@ -2,6 +2,8 @@ import 'package:get/get.dart';
import 'navigation_service.dart';
import '../data/providers/auth_provider.dart';
import '../modules/warga/controllers/warga_dashboard_controller.dart';
import '../data/providers/aset_provider.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
/// Abstract class untuk mengelola lifecycle service dan dependency
abstract class ServiceManager {
@ -26,6 +28,11 @@ abstract class ServiceManager {
Get.put(AuthProvider(), permanent: true);
}
// Register AsetProvider if not already registered
if (!Get.isRegistered<AsetProvider>()) {
Get.put(AsetProvider(), permanent: true);
}
// Register WargaDashboardController as a permanent controller
// This ensures it's always available for the drawer
registerWargaDashboardController();

View File

@ -0,0 +1,222 @@
import 'package:supabase_flutter/supabase_flutter.dart';
import '../data/models/rental_booking_model.dart';
import 'package:flutter/foundation.dart';
class SewaService {
final SupabaseClient _supabase = Supabase.instance.client;
Future<List<SewaModel>> fetchAllSewa() async {
// 1) Ambil semua sewa_aset
final sewaData =
await _supabase.from('sewa_aset').select('''
id,
user_id,
status,
waktu_mulai,
waktu_selesai,
tanggal_pemesanan,
tipe_pesanan,
kuantitas,
aset_id,
paket_id
''')
as List<dynamic>;
if (sewaData.isEmpty) return [];
// Konversi dasar ke map
final List<Map<String, dynamic>> rawSewa =
sewaData.map((e) => e as Map<String, dynamic>).toList();
// Kumpulkan semua ID
final sewaIds = rawSewa.map((e) => e['id'] as String).toList();
final userIds = rawSewa.map((e) => e['user_id'] as String).toSet().toList();
// Pisahkan aset dan paket IDs
final asetIds =
rawSewa
.where((e) => e['tipe_pesanan'] == 'tunggal')
.map((e) => e['aset_id'] as String?)
.whereType<String>()
.toSet()
.toList();
final paketIds =
rawSewa
.where((e) => e['tipe_pesanan'] == 'paket')
.map((e) => e['paket_id'] as String?)
.whereType<String>()
.toSet()
.toList();
// 2) Ambil tagihan_sewa
final tagihanData =
await _supabase
.from('tagihan_sewa')
.select('sewa_aset_id, total_tagihan, denda, tagihan_dibayar')
.filter('sewa_aset_id', 'in', '(${sewaIds.join(",")})')
as List<dynamic>;
final Map<String, Map<String, dynamic>> mapTagihan = {
for (var t in tagihanData)
t['sewa_aset_id'] as String: t as Map<String, dynamic>,
};
// 3) Ambil data warga_desa
final wargaData =
await _supabase
.from('warga_desa')
.select('user_id, nama_lengkap, no_hp, avatar')
.filter('user_id', 'in', '(${userIds.join(",")})')
as List<dynamic>;
debugPrint('DEBUG wargaData (raw): ' + wargaData.toString());
final Map<String, Map<String, String>> mapWarga = {
for (var w in wargaData)
(w['user_id'] as String): {
'nama': w['nama_lengkap'] as String? ?? '-',
'noHp': w['no_hp'] as String? ?? '-',
'avatar': w['avatar'] as String? ?? '-',
},
};
debugPrint('DEBUG mapWarga (mapped): ' + mapWarga.toString());
// 4) Ambil data aset (untuk tunggal)
Map<String, Map<String, String>> mapAset = {};
if (asetIds.isNotEmpty) {
final asetData =
await _supabase
.from('aset')
.select('id, nama')
.filter('id', 'in', '(${asetIds.join(",")})')
as List<dynamic>;
final fotoData =
await _supabase
.from('foto_aset')
.select('id_aset, foto_aset')
.filter('id_aset', 'in', '(${asetIds.join(",")})')
as List<dynamic>;
// Map aset id → nama
mapAset = {
for (var a in asetData)
(a['id'] as String): {'nama': a['nama'] as String},
};
// Ambil foto pertama per aset
for (var f in fotoData) {
final aid = f['id_aset'] as String;
if (mapAset.containsKey(aid) && mapAset[aid]!['foto'] == null) {
mapAset[aid]!['foto'] = f['foto_aset'] as String;
}
}
}
// 5) Ambil data paket (untuk paket)
Map<String, Map<String, String>> mapPaket = {};
if (paketIds.isNotEmpty) {
final paketData =
await _supabase
.from('paket')
.select('id, nama')
.filter('id', 'in', '(${paketIds.join(",")})')
as List<dynamic>;
final fotoData =
await _supabase
.from('foto_aset')
.select('id_paket, foto_aset')
.filter('id_paket', 'in', '(${paketIds.join(",")})')
as List<dynamic>;
mapPaket = {
for (var p in paketData)
(p['id'] as String): {'nama': p['nama'] as String},
};
for (var f in fotoData) {
final pid = f['id_paket'] as String;
if (mapPaket.containsKey(pid) && mapPaket[pid]!['foto'] == null) {
mapPaket[pid]!['foto'] = f['foto_aset'] as String;
}
}
}
// Debug print hasil query utama
debugPrint('sewaData: ' + sewaData.toString());
debugPrint('sewaData count: ' + sewaData.length.toString());
debugPrint('tagihanData: ' + tagihanData.toString());
debugPrint('wargaData: ' + wargaData.toString());
debugPrint('mapAset: ' + mapAset.toString());
debugPrint('mapPaket: ' + mapPaket.toString());
// 6) Gabungkan dan bangun SewaModel
final List<SewaModel> result = [];
for (var row in rawSewa) {
final id = row['id'] as String;
final tipe = row['tipe_pesanan'] as String;
final warga = mapWarga[row['user_id']];
final tagihan = mapTagihan[id];
if (warga == null || tagihan == null) {
// Skip data jika relasi tidak ditemukan
continue;
}
result.add(
SewaModel(
id: id,
userId: row['user_id'],
status: row['status'] as String,
waktuMulai: DateTime.parse(row['waktu_mulai'] as String),
waktuSelesai: DateTime.parse(row['waktu_selesai'] as String),
tanggalPemesanan: DateTime.parse(row['tanggal_pemesanan'] as String),
tipePesanan: tipe,
kuantitas: row['kuantitas'] as int,
asetId: tipe == 'tunggal' ? row['aset_id'] as String : null,
asetNama:
(row['aset_id'] != null &&
tipe == 'tunggal' &&
mapAset[row['aset_id']] != null)
? mapAset[row['aset_id']]!['nama']
: null,
asetFoto:
(row['aset_id'] != null &&
tipe == 'tunggal' &&
mapAset[row['aset_id']] != null)
? mapAset[row['aset_id']]!['foto']
: null,
paketId: tipe == 'paket' ? row['paket_id'] as String : null,
paketNama:
(row['paket_id'] != null &&
tipe == 'paket' &&
mapPaket[row['paket_id']] != null)
? mapPaket[row['paket_id']]!['nama']
: null,
paketFoto:
(row['paket_id'] != null &&
tipe == 'paket' &&
mapPaket[row['paket_id']] != null)
? mapPaket[row['paket_id']]!['foto']
: null,
totalTagihan:
tagihan['total_tagihan'] != null
? (tagihan['total_tagihan'] as num?)?.toDouble() ?? 0.0
: 0.0,
denda:
tagihan['denda'] != null
? (tagihan['denda'] as num?)?.toDouble()
: null,
dibayar:
tagihan['tagihan_dibayar'] != null
? (tagihan['tagihan_dibayar'] as num?)?.toDouble()
: null,
paidAmount:
tagihan['total_tagihan'] != null
? (tagihan['total_tagihan'] as num?)?.toDouble()
: null,
wargaNama: warga['nama'] ?? '-',
wargaNoHp: warga['noHp'] ?? '-',
wargaAvatar: warga['avatar'] ?? '-',
),
);
}
// Debug print hasil query result
debugPrint('SewaModel result: ' + result.toString());
debugPrint('SewaModel result count: ' + result.length.toString());
return result;
}
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'app_colors.dart';
/// App theme configuration
@ -19,13 +20,16 @@ class AppTheme {
),
scaffoldBackgroundColor: AppColors.background,
// Set Lato as the default font for the entire app
fontFamily: GoogleFonts.lato().fontFamily,
// App bar theme
appBarTheme: AppBarTheme(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
elevation: 0,
iconTheme: const IconThemeData(color: Colors.white),
titleTextStyle: const TextStyle(
titleTextStyle: GoogleFonts.lato(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
@ -50,7 +54,10 @@ class AppTheme {
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
textStyle: GoogleFonts.lato(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
@ -62,7 +69,10 @@ class AppTheme {
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
textStyle: GoogleFonts.lato(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
@ -70,7 +80,10 @@ class AppTheme {
style: TextButton.styleFrom(
foregroundColor: AppColors.primary,
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
textStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
textStyle: GoogleFonts.lato(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
@ -98,8 +111,8 @@ class AppTheme {
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: AppColors.error, width: 1.5),
),
hintStyle: TextStyle(color: AppColors.textLight),
labelStyle: TextStyle(color: AppColors.textSecondary),
hintStyle: GoogleFonts.lato(color: AppColors.textLight),
labelStyle: GoogleFonts.lato(color: AppColors.textSecondary),
),
// Checkbox theme
@ -115,21 +128,21 @@ class AppTheme {
// Text themes
textTheme: TextTheme(
displayLarge: TextStyle(color: AppColors.textPrimary),
displayMedium: TextStyle(color: AppColors.textPrimary),
displaySmall: TextStyle(color: AppColors.textPrimary),
headlineLarge: TextStyle(color: AppColors.textPrimary),
headlineMedium: TextStyle(color: AppColors.textPrimary),
headlineSmall: TextStyle(color: AppColors.textPrimary),
titleLarge: TextStyle(color: AppColors.textPrimary),
titleMedium: TextStyle(color: AppColors.textPrimary),
titleSmall: TextStyle(color: AppColors.textPrimary),
bodyLarge: TextStyle(color: AppColors.textPrimary),
bodyMedium: TextStyle(color: AppColors.textPrimary),
bodySmall: TextStyle(color: AppColors.textSecondary),
labelLarge: TextStyle(color: AppColors.textPrimary),
labelMedium: TextStyle(color: AppColors.textSecondary),
labelSmall: TextStyle(color: AppColors.textLight),
displayLarge: GoogleFonts.lato(color: AppColors.textPrimary),
displayMedium: GoogleFonts.lato(color: AppColors.textPrimary),
displaySmall: GoogleFonts.lato(color: AppColors.textPrimary),
headlineLarge: GoogleFonts.lato(color: AppColors.textPrimary),
headlineMedium: GoogleFonts.lato(color: AppColors.textPrimary),
headlineSmall: GoogleFonts.lato(color: AppColors.textPrimary),
titleLarge: GoogleFonts.lato(color: AppColors.textPrimary),
titleMedium: GoogleFonts.lato(color: AppColors.textPrimary),
titleSmall: GoogleFonts.lato(color: AppColors.textPrimary),
bodyLarge: GoogleFonts.lato(color: AppColors.textPrimary),
bodyMedium: GoogleFonts.lato(color: AppColors.textPrimary),
bodySmall: GoogleFonts.lato(color: AppColors.textSecondary),
labelLarge: GoogleFonts.lato(color: AppColors.textPrimary),
labelMedium: GoogleFonts.lato(color: AppColors.textSecondary),
labelSmall: GoogleFonts.lato(color: AppColors.textLight),
),
// Divider theme

View File

@ -0,0 +1,10 @@
import 'package:intl/intl.dart';
String formatRupiah(double value) {
final formatter = NumberFormat.currency(
locale: 'id_ID',
symbol: 'Rp',
decimalDigits: 0,
);
return formatter.format(value);
}

View File

@ -291,34 +291,6 @@ class AppDrawer extends StatelessWidget {
),
// Settings Items
_buildDrawerItem(
icon: Icons.info_outline_rounded,
title: 'Tentang Aplikasi',
subtitle: 'Informasi dan bantuan',
showTrailing: false,
onTap: () {
Navigator.pop(context);
// Show about dialog
showAboutDialog(
context: context,
applicationName: 'BumRent App',
applicationVersion: '1.0.0',
applicationIcon: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.asset(
'assets/images/logo.png',
width: 40,
height: 40,
),
),
children: [
const Text(
'Aplikasi penyewaan dan berlangganan aset milik BUMDes untuk warga desa.',
),
],
);
},
),
_buildDrawerItem(
icon: Icons.logout_rounded,
title: 'Keluar',

View File

@ -525,6 +525,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.1.1"
logger:
dependency: "direct main"
description:
name: logger
sha256: be4b23575aac7ebf01f225a241eb7f6b5641eeaf43c6a8613510fc2f8cf187d1
url: "https://pub.dev"
source: hosted
version: "2.5.0"
logging:
dependency: transitive
description:

View File

@ -48,6 +48,7 @@ dependencies:
flutter_dotenv: ^5.1.0
image_picker: ^1.0.7
intl: 0.19.0
logger: ^2.1.0
flutter_localizations:
sdk: flutter
get_storage: ^2.1.1

30
widget_test.dart Normal file
View File

@ -0,0 +1,30 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:bumrent_app/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}