Perbarui model dan tampilan untuk mendukung fitur tanda tangan dan pembatalan penyaluran. Modifikasi PenerimaPenyaluranModel untuk menambahkan properti tandaTangan. Ubah PenyaluranBantuanModel dengan mengganti alasanPenolakan menjadi alasanPembatalan dan menambahkan tanggalPembatalan serta tanggalSelesai. Perbarui DetailPenyaluranController untuk menangani data penyaluran dan penerima dengan lebih baik. Tambahkan logika baru di DetailPenyaluranPage untuk menampilkan informasi pembatalan dan tanda tangan. Perbarui tampilan KonfirmasiPenerimaPage untuk menyertakan fitur tanda tangan saat konfirmasi penerimaan.

This commit is contained in:
Khafidh Fuadi
2025-03-16 08:42:51 +07:00
parent da06611c3a
commit 49b60f3195
155 changed files with 21110 additions and 769 deletions

View File

@ -22,8 +22,18 @@ class DetailPenyaluranController extends GetxController {
void onInit() {
super.onInit();
final String? penyaluranId = Get.parameters['id'];
print('DetailPenyaluranController - ID Penyaluran: $penyaluranId');
if (penyaluranId != null) {
final PenyaluranBantuanModel? penyaluranData =
Get.arguments as PenyaluranBantuanModel?;
if (penyaluranData != null) {
// Jika data penyaluran diterima langsung dari argumen
penyaluran.value = penyaluranData;
if (penyaluran.value?.id != null) {
loadPenyaluranDetails(penyaluran.value!.id!);
}
checkUserRole();
} else if (penyaluranId != null) {
// Jika hanya ID penyaluran yang diterima
loadPenyaluranData(penyaluranId);
checkUserRole();
} else {
@ -42,7 +52,7 @@ class DetailPenyaluranController extends GetxController {
.eq('id', user.id)
.single();
if (userData != null && userData['role'] == 'petugas_desa') {
if (userData['role'] == 'petugas_desa') {
isPetugasDesa.value = true;
}
}
@ -66,90 +76,84 @@ class DetailPenyaluranController extends GetxController {
print('DetailPenyaluranController - Data penyaluran: $penyaluranData');
if (penyaluranData != null) {
// Pastikan data yang diterima sesuai dengan tipe data yang diharapkan
Map<String, dynamic> sanitizedData =
Map<String, dynamic>.from(penyaluranData);
// Pastikan data yang diterima sesuai dengan tipe data yang diharapkan
Map<String, dynamic> sanitizedData =
Map<String, dynamic>.from(penyaluranData);
// Konversi jumlah_penerima ke int jika bertipe String
if (sanitizedData['jumlah_penerima'] is String) {
sanitizedData['jumlah_penerima'] =
int.tryParse(sanitizedData['jumlah_penerima'] as String) ?? 0;
}
// Konversi jumlah_penerima ke int jika bertipe String
if (sanitizedData['jumlah_penerima'] is String) {
sanitizedData['jumlah_penerima'] =
int.tryParse(sanitizedData['jumlah_penerima'] as String) ?? 0;
}
penyaluran.value = PenyaluranBantuanModel.fromJson(sanitizedData);
penyaluran.value = PenyaluranBantuanModel.fromJson(sanitizedData);
print(
'DetailPenyaluranController - Model penyaluran: ${penyaluran.value?.nama}');
// Ambil data skema bantuan jika ada
if (penyaluran.value?.skemaId != null &&
penyaluran.value!.skemaId!.isNotEmpty) {
print(
'DetailPenyaluranController - Model penyaluran: ${penyaluran.value?.nama}');
'DetailPenyaluranController - Memuat skema bantuan dengan ID: ${penyaluran.value!.skemaId}');
final skemaData = await _supabaseService.client
.from('xx02_skema_bantuan')
.select('*')
.eq('id', penyaluran.value!.skemaId!)
.single();
// Ambil data skema bantuan jika ada
if (penyaluran.value?.skemaId != null &&
penyaluran.value!.skemaId!.isNotEmpty) {
print(
'DetailPenyaluranController - Memuat skema bantuan dengan ID: ${penyaluran.value!.skemaId}');
final skemaData = await _supabaseService.client
.from('xx02_skema_bantuan')
.select('*')
.eq('id', penyaluran.value!.skemaId!)
.single();
print('DetailPenyaluranController - Data skema bantuan: $skemaData');
if (skemaData != null) {
// Pastikan data skema sesuai dengan tipe data yang diharapkan
Map<String, dynamic> sanitizedSkemaData =
Map<String, dynamic>.from(skemaData);
print('DetailPenyaluranController - Data skema bantuan: $skemaData');
if (skemaData != null) {
// Pastikan data skema sesuai dengan tipe data yang diharapkan
Map<String, dynamic> sanitizedSkemaData =
Map<String, dynamic>.from(skemaData);
// Konversi kuota ke int jika bertipe String
if (sanitizedSkemaData['kuota'] is String) {
sanitizedSkemaData['kuota'] =
int.tryParse(sanitizedSkemaData['kuota'] as String) ?? 0;
}
// Konversi petugas_verifikasi_id ke int jika bertipe String
if (sanitizedSkemaData['petugas_verifikasi_id'] is String) {
sanitizedSkemaData['petugas_verifikasi_id'] = int.tryParse(
sanitizedSkemaData['petugas_verifikasi_id'] as String);
}
skemaBantuan.value = SkemaBantuanModel.fromJson(sanitizedSkemaData);
print(
'DetailPenyaluranController - Model skema bantuan: ${skemaBantuan.value?.nama}');
// Konversi kuota ke int jika bertipe String
if (sanitizedSkemaData['kuota'] is String) {
sanitizedSkemaData['kuota'] =
int.tryParse(sanitizedSkemaData['kuota'] as String) ?? 0;
}
// Konversi petugas_verifikasi_id ke int jika bertipe String
if (sanitizedSkemaData['petugas_verifikasi_id'] is String) {
sanitizedSkemaData['petugas_verifikasi_id'] = int.tryParse(
sanitizedSkemaData['petugas_verifikasi_id'] as String);
}
skemaBantuan.value = SkemaBantuanModel.fromJson(sanitizedSkemaData);
print(
'DetailPenyaluranController - Model skema bantuan: ${skemaBantuan.value?.nama}');
}
}
// Ambil data penerima penyaluran
final penerimaPenyaluranData = await _supabaseService.client
.from('penerima_penyaluran')
.select('*, warga:warga_id(*)')
.eq('penyaluran_bantuan_id', penyaluranId);
// Ambil data penerima penyaluran
final penerimaPenyaluranData = await _supabaseService.client
.from('penerima_penyaluran')
.select('*, warga:warga_id(*)')
.eq('penyaluran_bantuan_id', penyaluranId);
print(
'DetailPenyaluranController - Data penerima penyaluran: $penerimaPenyaluranData');
if (penerimaPenyaluranData != null) {
final List<PenerimaPenyaluranModel> penerima = [];
for (var item in penerimaPenyaluranData) {
// Pastikan data penerima sesuai dengan tipe data yang diharapkan
Map<String, dynamic> sanitizedPenerimaData =
Map<String, dynamic>.from(item);
// Konversi jumlah_bantuan ke double jika bertipe String
if (sanitizedPenerimaData['jumlah_bantuan'] is String) {
sanitizedPenerimaData['jumlah_bantuan'] = double.tryParse(
sanitizedPenerimaData['jumlah_bantuan'] as String);
}
penerima.add(PenerimaPenyaluranModel.fromJson(sanitizedPenerimaData));
}
penerimaPenyaluran.assignAll(penerima);
print(
'DetailPenyaluranController - Data penerima penyaluran: $penerimaPenyaluranData');
if (penerimaPenyaluranData != null) {
final List<PenerimaPenyaluranModel> penerima = [];
for (var item in penerimaPenyaluranData) {
// Pastikan data penerima sesuai dengan tipe data yang diharapkan
Map<String, dynamic> sanitizedPenerimaData =
Map<String, dynamic>.from(item);
'DetailPenyaluranController - Jumlah penerima: ${penerima.length}');
// Konversi id ke int jika bertipe String
if (sanitizedPenerimaData['id'] is String) {
sanitizedPenerimaData['id'] =
int.tryParse(sanitizedPenerimaData['id'] as String);
}
// Konversi jumlah_bantuan ke double jika bertipe String
if (sanitizedPenerimaData['jumlah_bantuan'] is String) {
sanitizedPenerimaData['jumlah_bantuan'] = double.tryParse(
sanitizedPenerimaData['jumlah_bantuan'] as String);
}
penerima
.add(PenerimaPenyaluranModel.fromJson(sanitizedPenerimaData));
}
penerimaPenyaluran.assignAll(penerima);
print(
'DetailPenyaluranController - Jumlah penerima: ${penerima.length}');
}
//print id
print('DetailPenyaluranController - ID penerima: ${penerima[0].id}');
}
} catch (e) {
print('Error loading penyaluran data: $e');
@ -165,7 +169,8 @@ class DetailPenyaluranController extends GetxController {
Future<void> refreshData() async {
if (penyaluran.value?.id != null) {
await loadPenyaluranData(penyaluran.value!.id!);
// Jika data penyaluran sudah ada, cukup muat detail saja
await loadPenyaluranDetails(penyaluran.value!.id!);
}
}
@ -178,10 +183,10 @@ class DetailPenyaluranController extends GetxController {
throw Exception('ID penyaluran tidak ditemukan');
}
// Update status penyaluran menjadi "BERLANGSUNG"
// Update status penyaluran menjadi "AKTIF"
await _supabaseService.client
.from('penyaluran_bantuan')
.update({'status': 'BERLANGSUNG'}).eq('id', penyaluran.value!.id!);
.update({'status': 'AKTIF'}).eq('id', penyaluran.value!.id!);
await refreshData();
@ -208,7 +213,7 @@ class DetailPenyaluranController extends GetxController {
// Fungsi untuk konfirmasi penerimaan bantuan oleh penerima
Future<void> konfirmasiPenerimaan(PenerimaPenyaluranModel penerima,
{String? buktiPenerimaan}) async {
{required String buktiPenerimaan, required String tandaTangan}) async {
try {
isProcessing.value = true;
@ -216,39 +221,39 @@ class DetailPenyaluranController extends GetxController {
throw Exception('ID penerima tidak ditemukan');
}
if (buktiPenerimaan.isEmpty) {
throw Exception('Bukti penerimaan tidak boleh kosong');
}
if (tandaTangan.isEmpty) {
throw Exception('Tanda tangan tidak boleh kosong');
}
// Update status penerimaan menjadi "DITERIMA"
final Map<String, dynamic> updateData = {
'status_penerimaan': 'DITERIMA',
'tanggal_penerimaan': DateTime.now().toIso8601String(),
'bukti_penerimaan': buktiPenerimaan,
'tanda_tangan': tandaTangan,
};
if (buktiPenerimaan != null) {
updateData['bukti_penerimaan'] = buktiPenerimaan;
}
print(
'DetailPenyaluranController - Updating penerima with ID: ${penerima.id}');
print('DetailPenyaluranController - Update data: $updateData');
await _supabaseService.client
.from('penerima_penyaluran')
.update(updateData)
.eq('id', penerima.id!);
// Refresh data setelah konfirmasi berhasil
await refreshData();
Get.snackbar(
'Sukses',
'Konfirmasi penerimaan bantuan berhasil',
backgroundColor: Colors.green,
colorText: Colors.white,
snackPosition: SnackPosition.BOTTOM,
);
// Tidak perlu menampilkan snackbar di sini karena sudah ditampilkan di halaman konfirmasi penerima
} catch (e) {
print('Error konfirmasi penerimaan: $e');
Get.snackbar(
'Error',
'Terjadi kesalahan saat konfirmasi penerimaan bantuan',
backgroundColor: Colors.red,
colorText: Colors.white,
snackPosition: SnackPosition.BOTTOM,
);
// Tidak perlu menampilkan snackbar di sini karena sudah ditampilkan di halaman konfirmasi penerima
rethrow; // Melempar kembali exception agar dapat ditangkap di _konfirmasiPenerimaan
} finally {
isProcessing.value = false;
}
@ -331,9 +336,9 @@ class DetailPenyaluranController extends GetxController {
throw Exception('ID penyaluran tidak ditemukan');
}
// Update status penyaluran menjadi "DIBATALKAN"
// Update status penyaluran menjadi "BATALTERLAKSANA"
await _supabaseService.client.from('penyaluran_bantuan').update({
'status': 'DIBATALKAN',
'status': 'BATALTERLAKSANA',
'alasan_pembatalan': alasan,
'tanggal_selesai': DateTime.now().toIso8601String(),
}).eq('id', penyaluran.value!.id!);
@ -361,36 +366,140 @@ class DetailPenyaluranController extends GetxController {
}
}
// Fungsi untuk mengupload bukti penerimaan
Future<String?> uploadBuktiPenerimaan(String filePath) async {
// Fungsi untuk mengupload bukti penerimaan atau tanda tangan
Future<String> uploadBuktiPenerimaan(String filePath,
{bool isTandaTangan = false}) async {
try {
final fileName =
'bukti_penerimaan_${DateTime.now().millisecondsSinceEpoch}.jpg';
final String folderName =
isTandaTangan ? 'tanda_tangan' : 'bukti_penerimaan';
final String filePrefix =
isTandaTangan ? 'tanda_tangan' : 'bukti_penerimaan';
final String fileName =
'${filePrefix}_${DateTime.now().millisecondsSinceEpoch}.jpg';
final file = File(filePath);
print(
'Uploading ${isTandaTangan ? "tanda tangan" : "bukti penerimaan"} dari: $filePath');
print('File exists: ${file.existsSync()}');
print('File size: ${file.lengthSync()} bytes');
if (!file.existsSync()) {
throw Exception('File tidak ditemukan: $filePath');
}
print('Uploading ke bucket: $folderName dengan nama file: $fileName');
final storageResponse = await _supabaseService.client.storage
.from('bukti_penerimaan')
.from(folderName)
.upload(fileName, file);
print('Storage response: $storageResponse');
if (storageResponse.isEmpty) {
throw Exception('Gagal mengupload bukti penerimaan');
throw Exception(
'Gagal mengupload ${isTandaTangan ? 'tanda tangan' : 'bukti penerimaan'}');
}
final fileUrl = _supabaseService.client.storage
.from('bukti_penerimaan')
.from(folderName)
.getPublicUrl(fileName);
print('File URL: $fileUrl');
if (fileUrl.isEmpty) {
throw Exception(
'Gagal mendapatkan URL ${isTandaTangan ? 'tanda tangan' : 'bukti penerimaan'}');
}
return fileUrl;
} catch (e) {
print('Error upload bukti penerimaan: $e');
print(
'Error upload ${isTandaTangan ? 'tanda tangan' : 'bukti penerimaan'}: $e');
// Tidak perlu menampilkan snackbar di sini karena sudah ditampilkan di halaman konfirmasi penerima
throw Exception(
'Gagal mengupload ${isTandaTangan ? 'tanda tangan' : 'bukti penerimaan'}: $e');
}
}
// Fungsi untuk memuat detail penyaluran (skema dan penerima) tanpa memuat ulang data penyaluran
Future<void> loadPenyaluranDetails(String penyaluranId) async {
try {
isLoading.value = true;
print(
'DetailPenyaluranController - Memuat detail penyaluran dengan ID: $penyaluranId');
// Ambil data skema bantuan jika ada
if (penyaluran.value?.skemaId != null &&
penyaluran.value!.skemaId!.isNotEmpty) {
print(
'DetailPenyaluranController - Memuat skema bantuan dengan ID: ${penyaluran.value!.skemaId}');
final skemaData = await _supabaseService.client
.from('xx02_skema_bantuan')
.select('*')
.eq('id', penyaluran.value!.skemaId!)
.single();
print('DetailPenyaluranController - Data skema bantuan: $skemaData');
if (skemaData != null) {
// Pastikan data skema sesuai dengan tipe data yang diharapkan
Map<String, dynamic> sanitizedSkemaData =
Map<String, dynamic>.from(skemaData);
// Konversi kuota ke int jika bertipe String
if (sanitizedSkemaData['kuota'] is String) {
sanitizedSkemaData['kuota'] =
int.tryParse(sanitizedSkemaData['kuota'] as String) ?? 0;
}
// Konversi petugas_verifikasi_id ke int jika bertipe String
if (sanitizedSkemaData['petugas_verifikasi_id'] is String) {
sanitizedSkemaData['petugas_verifikasi_id'] = int.tryParse(
sanitizedSkemaData['petugas_verifikasi_id'] as String);
}
skemaBantuan.value = SkemaBantuanModel.fromJson(sanitizedSkemaData);
print(
'DetailPenyaluranController - Model skema bantuan: ${skemaBantuan.value?.nama}');
}
}
// Ambil data penerima penyaluran
final penerimaPenyaluranData = await _supabaseService.client
.from('penerima_penyaluran')
.select('*, warga:warga_id(*)')
.eq('penyaluran_bantuan_id', penyaluranId);
print(
'DetailPenyaluranController - Data penerima penyaluran: $penerimaPenyaluranData');
if (penerimaPenyaluranData != null) {
final List<PenerimaPenyaluranModel> penerima = [];
for (var item in penerimaPenyaluranData) {
// Pastikan data penerima sesuai dengan tipe data yang diharapkan
Map<String, dynamic> sanitizedPenerimaData =
Map<String, dynamic>.from(item);
// Konversi jumlah_bantuan ke double jika bertipe String
if (sanitizedPenerimaData['jumlah_bantuan'] is String) {
sanitizedPenerimaData['jumlah_bantuan'] = double.tryParse(
sanitizedPenerimaData['jumlah_bantuan'] as String);
}
penerima.add(PenerimaPenyaluranModel.fromJson(sanitizedPenerimaData));
}
penerimaPenyaluran.assignAll(penerima);
print(
'DetailPenyaluranController - Jumlah penerima: ${penerima.length}');
if (penerima.isNotEmpty) {
print('DetailPenyaluranController - ID penerima: ${penerima[0].id}');
}
}
} catch (e) {
print('Error loading penyaluran details: $e');
Get.snackbar(
'Error',
'Terjadi kesalahan saat mengupload bukti penerimaan',
backgroundColor: Colors.red,
colorText: Colors.white,
'Terjadi kesalahan saat memuat detail penyaluran',
snackPosition: SnackPosition.BOTTOM,
);
return null;
} finally {
isLoading.value = false;
}
}
}

View File

@ -1,12 +1,12 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:penyaluran_app/app/data/models/penerima_penyaluran_model.dart';
import 'package:penyaluran_app/app/modules/penyaluran/detail_penyaluran_controller.dart';
import 'package:penyaluran_app/app/theme/app_theme.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:io';
import 'package:penyaluran_app/app/modules/penyaluran/konfirmasi_penerima_page.dart';
import 'package:penyaluran_app/app/utils/date_formatter.dart';
import 'package:penyaluran_app/app/data/models/penyaluran_bantuan_model.dart';
class DetailPenyaluranPage extends StatelessWidget {
final controller = Get.put(DetailPenyaluranController());
@ -49,19 +49,34 @@ class DetailPenyaluranPage extends StatelessWidget {
const SizedBox(height: 16),
_buildPenerimaPenyaluranSection(context),
const SizedBox(height: 24),
Obx(() => _buildActionButtons(context)),
// Menampilkan section alasan pembatalan jika status BATALTERLAKSANA
if (controller.penyaluran.value?.status?.toUpperCase() ==
'BATALTERLAKSANA' &&
controller.penyaluran.value?.alasanPembatalan != null &&
controller.penyaluran.value!.alasanPembatalan!.isNotEmpty)
_buildPembatalanSection(context),
const SizedBox(height: 24),
// Tombol aksi akan ditampilkan di bottomNavigationBar
],
),
),
);
}),
bottomNavigationBar: Obx(() {
final status = controller.penyaluran.value?.status?.toUpperCase() ?? '';
if (status == 'AKTIF' ||
status == 'DISETUJUI' ||
status == 'DIJADWALKAN') {
return _buildActionButtons(context);
}
return const SizedBox.shrink();
}),
);
}
Widget _buildInfoCard(BuildContext context) {
final penyaluran = controller.penyaluran.value!;
final skema = controller.skemaBantuan.value;
final dateFormat = DateFormat('dd MMMM yyyy', 'id_ID');
return Card(
elevation: 2,
@ -95,8 +110,17 @@ class DetailPenyaluranPage extends StatelessWidget {
_buildInfoRow(
'Tanggal',
penyaluran.tanggalPenyaluran != null
? dateFormat.format(penyaluran.tanggalPenyaluran!)
? DateFormatter.formatDateTime(
penyaluran.tanggalPenyaluran!)
: 'Belum dijadwalkan'),
// Tampilkan tanggal selesai jika status TERLAKSANA atau BATALTERLAKSANA
if (penyaluran.status == 'TERLAKSANA' ||
penyaluran.status == 'BATALTERLAKSANA')
_buildInfoRow(
'Tanggal Selesai',
penyaluran.tanggalSelesai != null
? DateFormatter.formatDateTime(penyaluran.tanggalSelesai!)
: '-'),
_buildInfoRow(
'Jumlah Penerima', '${penyaluran.jumlahPenerima ?? 0} orang'),
@ -128,24 +152,69 @@ class DetailPenyaluranPage extends StatelessWidget {
],
// Alasan penolakan jika ada
if (penyaluran.alasanPenolakan != null &&
penyaluran.alasanPenolakan!.isNotEmpty) ...[
if (penyaluran.alasanPembatalan != null &&
penyaluran.alasanPembatalan!.isNotEmpty) ...[
const Divider(height: 24),
Text(
'Alasan Penolakan:',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.red[700],
if (penyaluran.status?.toUpperCase() == 'BATALTERLAKSANA') ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.errorColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppTheme.errorColor.withOpacity(0.5),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.cancel_outlined,
color: AppTheme.errorColor,
size: 20,
),
const SizedBox(width: 8),
Text(
'Alasan Pembatalan:',
style: TextStyle(
fontWeight: FontWeight.bold,
color: AppTheme.errorColor,
fontSize: 16,
),
),
],
),
const SizedBox(height: 8),
Text(
penyaluran.alasanPembatalan!,
style: TextStyle(
color: Colors.red[700],
),
),
],
),
),
),
const SizedBox(height: 4),
Text(
penyaluran.alasanPenolakan!,
style: TextStyle(
color: Colors.red[700],
fontStyle: FontStyle.italic,
] else ...[
Text(
'Alasan Pembatalan:',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.red[700],
),
),
),
const SizedBox(height: 4),
Text(
penyaluran.alasanPembatalan!,
style: TextStyle(
color: Colors.red[700],
fontStyle: FontStyle.italic,
),
),
],
],
],
),
@ -345,23 +414,16 @@ class DetailPenyaluranPage extends StatelessWidget {
String statusText = _getStatusText(status);
switch (status.toUpperCase()) {
case 'MENUNGGU':
case 'DIJADWALKAN':
backgroundColor = AppTheme.processedColor;
break;
case 'DISETUJUI':
backgroundColor = AppTheme.verifiedColor;
textColor = Colors.black87;
break;
case 'DITOLAK':
backgroundColor = AppTheme.rejectedColor;
break;
case 'AKTIF':
backgroundColor = AppTheme.scheduledColor;
break;
case 'TERLAKSANA':
backgroundColor = AppTheme.completedColor;
break;
case 'DIBATALKAN':
case 'BATALTERLAKSANA':
backgroundColor = AppTheme.errorColor;
break;
default:
@ -391,7 +453,7 @@ class DetailPenyaluranPage extends StatelessWidget {
String statusText = _getStatusPenerimaanText(status);
// Konversi status ke format yang diinginkan
if (status.toUpperCase() == 'SUDAHMENERIMA') {
if (status.toUpperCase() == 'DITERIMA') {
backgroundColor = AppTheme.successColor;
statusText = 'Sudah Menerima';
} else {
@ -421,67 +483,71 @@ class DetailPenyaluranPage extends StatelessWidget {
final status = controller.penyaluran.value?.status?.toUpperCase() ?? '';
if (controller.isProcessing.value) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
return Container(
padding: const EdgeInsets.all(16.0),
child: const Center(
child: CircularProgressIndicator(),
),
);
}
// Jika status DISETUJUI, tampilkan tombol Mulai Penyaluran
if (status == 'DISETUJUI') {
return SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
icon: const Icon(Icons.play_arrow),
label: const Text('Mulai Penyaluran'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
),
onPressed: controller.mulaiPenyaluran,
// Container untuk tombol-tombol
Widget buildButtonContainer(List<Widget> children) {
return Container(
padding: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.3),
spreadRadius: 1,
blurRadius: 5,
offset: const Offset(0, -3),
),
],
),
child: Row(
children: children,
),
);
}
// Jika status AKTIF, tampilkan tombol Selesaikan Penyaluran dan Batalkan
// Tombol Batalkan yang digunakan berulang
Widget cancelButton = Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.cancel),
label: const Text('Batalkan'),
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.errorColor,
side: const BorderSide(color: AppTheme.errorColor),
padding: const EdgeInsets.symmetric(vertical: 16),
),
onPressed: () => _showBatalkanDialog(context),
),
);
if (status == 'AKTIF') {
return Column(
children: [
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
icon: const Icon(Icons.check_circle),
label: const Text('Selesaikan Penyaluran'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.successColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
),
onPressed: controller.selesaikanPenyaluran,
return buildButtonContainer([
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.check_circle),
label: const Text('Selesaikan'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.successColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
),
onPressed: controller.selesaikanPenyaluran,
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
icon: const Icon(Icons.cancel),
label: const Text('Batalkan Penyaluran'),
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.errorColor,
side: const BorderSide(color: AppTheme.errorColor),
padding: const EdgeInsets.symmetric(vertical: 16),
),
onPressed: () => _showBatalkanDialog(context),
),
),
],
);
),
const SizedBox(width: 12),
cancelButton,
]);
} else if (status == 'DIJADWALKAN') {
return buildButtonContainer([cancelButton]);
}
// Jika status TERLAKSANA atau DIBATALKAN, tidak perlu menampilkan tombol aksi
// Untuk status lainnya tidak menampilkan tombol
return const SizedBox.shrink();
}
@ -593,7 +659,6 @@ class DetailPenyaluranPage extends StatelessWidget {
void _showDetailPenerima(
BuildContext context, PenerimaPenyaluranModel penerima) {
final dateFormat = DateFormat('dd MMMM yyyy', 'id_ID');
final warga = penerima.warga;
showModalBottomSheet(
@ -646,7 +711,7 @@ class DetailPenyaluranPage extends StatelessWidget {
_getStatusPenerimaanText(penerima.statusPenerimaan ?? '-')),
if (penerima.tanggalPenerimaan != null)
_buildInfoRow('Tanggal Penerimaan',
dateFormat.format(penerima.tanggalPenerimaan!)),
DateFormatter.formatDate(penerima.tanggalPenerimaan!)),
if (penerima.jumlahBantuan != null)
_buildInfoRow(
'Jumlah Bantuan', penerima.jumlahBantuan.toString()),
@ -688,8 +753,7 @@ class DetailPenyaluranPage extends StatelessWidget {
const SizedBox(height: 30),
if (controller.penyaluran.value?.status?.toUpperCase() ==
'AKTIF' &&
penerima.statusPenerimaan?.toUpperCase() !=
'SUDAHMENERIMA') ...[
penerima.statusPenerimaan?.toUpperCase() != 'DITERIMA') ...[
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
@ -736,18 +800,14 @@ class DetailPenyaluranPage extends StatelessWidget {
String _getStatusText(String status) {
switch (status.toUpperCase()) {
case 'MENUNGGU':
return 'Menunggu Persetujuan';
case 'DISETUJUI':
return 'Disetujui';
case 'DITOLAK':
return 'Ditolak';
case 'DIJADWALKAN':
return 'Terjadwal';
case 'AKTIF':
return 'Sedang AKTIF';
return 'Aktif';
case 'TERLAKSANA':
return 'Terlaksana';
case 'DIBATALKAN':
return 'Dibatalkan';
case 'BATALTERLAKSANA':
return 'Batal Terlaksana';
default:
return status;
}
@ -755,11 +815,82 @@ class DetailPenyaluranPage extends StatelessWidget {
String _getStatusPenerimaanText(String status) {
// Konversi status ke format yang diinginkan
if (status.toUpperCase() == 'SUDAHMENERIMA') {
if (status.toUpperCase() == 'DITERIMA') {
return 'Sudah Menerima';
} else {
// Semua status selain DITERIMA dianggap sebagai BELUMMENERIMA
return 'Belum Menerima';
}
}
Widget _buildPembatalanSection(BuildContext context) {
final penyaluran = controller.penyaluran.value!;
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: AppTheme.errorColor.withOpacity(0.5),
width: 1,
),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
Icons.cancel_outlined,
color: AppTheme.errorColor,
size: 24,
),
const SizedBox(width: 8),
const Text(
'Informasi Pembatalan',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.errorColor,
),
),
],
),
const Divider(height: 24),
_buildInfoRow('Status', 'Batal Terlaksana'),
if (penyaluran.tanggalSelesai != null)
_buildInfoRow('Tanggal Pembatalan',
DateFormatter.formatDateTime(penyaluran.tanggalSelesai!)),
const SizedBox(height: 8),
const Text(
'Alasan Pembatalan:',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: Colors.black87,
),
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.errorColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
penyaluran.alasanPembatalan!,
style: TextStyle(
fontSize: 14,
color: Colors.red[700],
),
),
),
],
),
),
);
}
}

View File

@ -1,12 +1,15 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:penyaluran_app/app/data/models/penerima_penyaluran_model.dart';
import 'package:penyaluran_app/app/data/models/bentuk_bantuan_model.dart';
import 'package:penyaluran_app/app/modules/penyaluran/detail_penyaluran_controller.dart';
import 'package:penyaluran_app/app/theme/app_theme.dart';
import 'package:image_picker/image_picker.dart';
import 'package:signature/signature.dart';
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:penyaluran_app/app/utils/date_formatter.dart';
class KonfirmasiPenerimaPage extends StatefulWidget {
final PenerimaPenyaluranModel penerima;
@ -15,12 +18,12 @@ class KonfirmasiPenerimaPage extends StatefulWidget {
final DateTime? tanggalPenyaluran;
const KonfirmasiPenerimaPage({
Key? key,
super.key,
required this.penerima,
this.bentukBantuan,
this.jumlahBantuan,
this.tanggalPenyaluran,
}) : super(key: key);
});
@override
State<KonfirmasiPenerimaPage> createState() => _KonfirmasiPenerimaPageState();
@ -34,11 +37,26 @@ class _KonfirmasiPenerimaPageState extends State<KonfirmasiPenerimaPage> {
bool _setujuPenggunaan = false;
bool _isLoading = false;
// Controller untuk tanda tangan
final SignatureController _signatureController = SignatureController(
penStrokeWidth: 3,
penColor: AppTheme.primaryColor,
exportBackgroundColor: Colors.white,
);
// Untuk menyimpan gambar tanda tangan
Uint8List? _signatureImage;
@override
void dispose() {
// Pastikan controller signature dibersihkan
_signatureController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final warga = widget.penerima.warga;
final dateFormat = DateFormat('dd MMMM yyyy', 'id_ID');
final timeFormat = DateFormat('HH:mm', 'id_ID');
return Scaffold(
appBar: AppBar(
@ -49,29 +67,40 @@ class _KonfirmasiPenerimaPageState extends State<KonfirmasiPenerimaPage> {
onPressed: () => Get.back(),
),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
body: Obx(
() => controller.isProcessing.value || _isLoading
? const Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildDetailPenerimaSection(warga),
const SizedBox(height: 16),
_buildDetailBantuanSection(),
const SizedBox(height: 16),
_buildFotoBuktiSection(),
const SizedBox(height: 16),
_buildTandaTanganSection(),
const SizedBox(height: 16),
_buildFormPersetujuanSection(),
const SizedBox(height: 24),
_buildKonfirmasiButton(),
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Sedang memproses konfirmasi...'),
],
),
)
: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailPenerimaSection(warga),
const SizedBox(height: 16),
_buildDetailBantuanSection(),
const SizedBox(height: 16),
_buildFotoBuktiSection(),
const SizedBox(height: 16),
_buildTandaTanganSection(),
const SizedBox(height: 16),
_buildFormPersetujuanSection(),
const SizedBox(height: 24),
_buildKonfirmasiButton(),
],
),
),
),
),
),
);
}
@ -158,7 +187,7 @@ class _KonfirmasiPenerimaPageState extends State<KonfirmasiPenerimaPage> {
'Tempat, Tanggal Lahir',
warga?['tempat_lahir'] != null &&
warga?['tanggal_lahir'] != null
? '${warga!['tempat_lahir']}, ${DateFormat('d MMMM yyyy').format(DateTime.parse(warga['tanggal_lahir']))}'
? '${warga!['tempat_lahir']}, ${DateFormatter.formatDate(DateTime.parse(warga['tanggal_lahir']), format: 'd MMMM yyyy')}'
: 'Bogor, 2 Juni 1990'),
const Divider(),
@ -183,9 +212,6 @@ class _KonfirmasiPenerimaPageState extends State<KonfirmasiPenerimaPage> {
}
Widget _buildDetailBantuanSection() {
final dateFormat = DateFormat('dd MMMM yyyy', 'id_ID');
final timeFormat = DateFormat('HH:mm', 'id_ID');
// Tentukan satuan berdasarkan data yang tersedia
String satuan = '';
if (widget.bentukBantuan?.satuan != null) {
@ -197,10 +223,10 @@ class _KonfirmasiPenerimaPageState extends State<KonfirmasiPenerimaPage> {
String tanggalWaktuPenyaluran = '';
if (widget.tanggalPenyaluran != null) {
final tanggal = dateFormat.format(widget.tanggalPenyaluran!);
final waktuMulai = timeFormat.format(widget.tanggalPenyaluran!);
final waktuSelesai = timeFormat
.format(widget.tanggalPenyaluran!.add(const Duration(hours: 1)));
final tanggal = DateFormatter.formatDate(widget.tanggalPenyaluran!);
final waktuMulai = DateFormatter.formatTime(widget.tanggalPenyaluran!);
final waktuSelesai = DateFormatter.formatTime(
widget.tanggalPenyaluran!.add(const Duration(hours: 1)));
tanggalWaktuPenyaluran = '$tanggal $waktuMulai-$waktuSelesai';
} else {
tanggalWaktuPenyaluran = '09 April 2025 13:00-14:00';
@ -328,39 +354,65 @@ class _KonfirmasiPenerimaPageState extends State<KonfirmasiPenerimaPage> {
),
),
const SizedBox(height: 16),
// Area tanda tangan
Container(
width: double.infinity,
height: 100,
height: 200,
decoration: BoxDecoration(
color: Colors.grey[100],
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey[300]!),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Tanda Tangan Digital',
style: TextStyle(
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Image.asset(
'assets/images/signature_placeholder.png',
height: 50,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return Image.network(
'https://i.imgur.com/JMoZ0nR.png',
height: 50,
child: _signatureImage != null
? ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.memory(
_signatureImage!,
fit: BoxFit.contain,
);
},
),
)
: Signature(
controller: _signatureController,
backgroundColor: Colors.white,
height: 200,
width: double.infinity,
),
),
const SizedBox(height: 12),
// Tombol aksi untuk tanda tangan
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// Tombol hapus tanda tangan
ElevatedButton.icon(
onPressed: () {
setState(() {
_signatureController.clear();
_signatureImage = null;
});
},
icon: const Icon(Icons.delete_outline),
label: const Text('Hapus'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red[100],
foregroundColor: Colors.red[800],
),
],
),
),
// Tombol simpan tanda tangan
ElevatedButton.icon(
onPressed: _saveSignature,
icon: const Icon(Icons.check),
label: const Text('Simpan'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green[100],
foregroundColor: Colors.green[800],
),
),
],
),
],
),
@ -531,6 +583,36 @@ class _KonfirmasiPenerimaPageState extends State<KonfirmasiPenerimaPage> {
}
}
Future<void> _saveSignature() async {
if (_signatureController.isEmpty) {
Get.snackbar(
'Perhatian',
'Tanda tangan tidak boleh kosong',
backgroundColor: Colors.orange,
colorText: Colors.white,
snackPosition: SnackPosition.BOTTOM,
);
return;
}
// Mendapatkan data tanda tangan
final signature = await _signatureController.toPngBytes();
if (signature != null) {
setState(() {
_signatureImage = signature;
});
Get.snackbar(
'Sukses',
'Tanda tangan berhasil disimpan',
backgroundColor: Colors.green,
colorText: Colors.white,
snackPosition: SnackPosition.BOTTOM,
);
}
}
Future<void> _konfirmasiPenerimaan() async {
if (!_setujuPenerimaan || !_setujuPenggunaan) {
Get.snackbar(
@ -543,35 +625,98 @@ class _KonfirmasiPenerimaPageState extends State<KonfirmasiPenerimaPage> {
return;
}
if (_signatureImage == null) {
Get.snackbar(
'Perhatian',
'Tanda tangan digital diperlukan',
backgroundColor: Colors.orange,
colorText: Colors.white,
snackPosition: SnackPosition.BOTTOM,
);
return;
}
if (_buktiPenerimaan == null) {
Get.snackbar(
'Perhatian',
'Foto bukti penerimaan diperlukan',
backgroundColor: Colors.orange,
colorText: Colors.white,
snackPosition: SnackPosition.BOTTOM,
);
return;
}
setState(() {
_isLoading = true;
});
try {
String? imageUrl;
Directory? tempDir;
File? signatureFile;
if (_buktiPenerimaan != null) {
// Upload bukti penerimaan
imageUrl =
await controller.uploadBuktiPenerimaan(_buktiPenerimaan!.path);
}
try {
String imageUrl;
String signatureUrl;
// Upload bukti penerimaan
imageUrl = await controller.uploadBuktiPenerimaan(_buktiPenerimaan!.path);
// Simpan tanda tangan ke file sementara dan upload
tempDir = await Directory.systemTemp.createTemp('signature');
signatureFile = File('${tempDir.path}/signature.png');
await signatureFile.writeAsBytes(_signatureImage!);
print('Signature file path: ${signatureFile.path}');
print('Signature file exists: ${signatureFile.existsSync()}');
print('Signature file size: ${signatureFile.lengthSync()} bytes');
signatureUrl = await controller.uploadBuktiPenerimaan(
signatureFile.path,
isTandaTangan: true,
);
// Konfirmasi penerimaan
await controller.konfirmasiPenerimaan(
widget.penerima,
buktiPenerimaan: imageUrl,
tandaTangan: signatureUrl,
);
// Hapus file sementara sebelum navigasi
try {
if (signatureFile.existsSync()) {
await signatureFile.delete();
}
if (tempDir.existsSync()) {
await tempDir.delete();
}
} catch (e) {
print('Error saat menghapus file sementara: $e');
}
// Tutup semua snackbar yang mungkin masih terbuka
if (Get.isSnackbarOpen) {
Get.closeAllSnackbars();
}
// Kembali ke halaman sebelumnya dengan hasil true (berhasil)
// Gunakan Get.back(result: true) untuk kembali ke halaman detail penyaluran
// dengan membawa hasil bahwa konfirmasi berhasil
Get.back(result: true);
Get.snackbar(
'Sukses',
'Konfirmasi penerimaan bantuan berhasil',
backgroundColor: Colors.green,
colorText: Colors.white,
snackPosition: SnackPosition.BOTTOM,
);
// Tampilkan snackbar sukses di halaman detail penyaluran
Future.delayed(const Duration(milliseconds: 300), () {
Get.snackbar(
'Sukses',
'Konfirmasi penerimaan bantuan berhasil',
backgroundColor: Colors.green,
colorText: Colors.white,
snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 2),
);
});
} catch (e) {
// Tampilkan pesan error
Get.snackbar(
'Error',
'Terjadi kesalahan: $e',
@ -580,6 +725,18 @@ class _KonfirmasiPenerimaPageState extends State<KonfirmasiPenerimaPage> {
snackPosition: SnackPosition.BOTTOM,
);
} finally {
// Hapus file sementara jika belum dihapus
try {
if (signatureFile != null && signatureFile.existsSync()) {
await signatureFile.delete();
}
if (tempDir != null && tempDir.existsSync()) {
await tempDir.delete();
}
} catch (e) {
print('Error saat menghapus file sementara: $e');
}
setState(() {
_isLoading = false;
});