Tambahkan fitur konfirmasi penyaluran bantuan untuk Petugas Desa

- Tambahkan kontroler untuk mengelola proses konfirmasi penerima
- Buat tampilan konfirmasi penyaluran dengan validasi input
- Tambahkan fitur pemilihan tanggal, foto bukti, dan tanda tangan
- Perbarui rute untuk mendukung halaman konfirmasi
- Integrasikan intl package untuk format tanggal dalam bahasa Indonesia
This commit is contained in:
Khafidh Fuadi
2025-03-09 10:00:36 +07:00
parent 9b23adb5aa
commit c54c0a27d9
7 changed files with 835 additions and 10 deletions

View File

@ -1,10 +1,21 @@
import 'package:get/get.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class PenerimaController extends GetxController {
final RxList<Map<String, dynamic>> daftarPenerima =
<Map<String, dynamic>>[].obs;
final RxBool isLoading = false.obs;
// Variabel untuk halaman konfirmasi penerima
final RxBool isKonfirmasiChecked = false.obs;
final RxBool isIdentitasChecked = false.obs;
final RxBool isDataValidChecked = false.obs;
final RxString tanggalPenyaluran = ''.obs;
final RxString fotoBuktiPath = ''.obs;
final RxString tandaTanganPath = ''.obs;
final TextEditingController catatanController = TextEditingController();
@override
void onInit() {
super.onInit();
@ -20,6 +31,12 @@ class PenerimaController extends GetxController {
}
}
@override
void onClose() {
catatanController.dispose();
super.onClose();
}
void fetchDaftarPenerima() {
isLoading.value = true;
@ -149,4 +166,147 @@ class PenerimaController extends GetxController {
return null;
}
}
// Fungsi untuk memilih tanggal penyaluran
Future<void> pilihTanggalPenyaluran(BuildContext context) async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime.now(),
builder: (context, child) {
return Theme(
data: ThemeData.light().copyWith(
colorScheme: const ColorScheme.light(
primary: Color(0xFF2E5077),
onPrimary: Colors.white,
surface: Colors.white,
onSurface: Colors.black,
),
),
child: child!,
);
},
);
if (picked != null) {
tanggalPenyaluran.value =
DateFormat('dd MMMM yyyy', 'id_ID').format(picked);
}
}
// Fungsi untuk memilih foto bukti
void pilihFotoBukti() {
// Simulasi pemilihan foto
// Dalam implementasi nyata, gunakan image_picker
fotoBuktiPath.value = 'assets/images/bukti_penyaluran.jpg';
}
// Fungsi untuk menghapus foto bukti
void hapusFotoBukti() {
fotoBuktiPath.value = '';
}
// Fungsi untuk membuka signature pad
void bukaSignaturePad(BuildContext context) {
// Simulasi tanda tangan
// Dalam implementasi nyata, gunakan signature_pad atau library serupa
tandaTanganPath.value = 'assets/images/tanda_tangan.png';
}
// Fungsi untuk menghapus tanda tangan
void hapusTandaTangan() {
tandaTanganPath.value = '';
}
// Fungsi untuk konfirmasi penyaluran
void konfirmasiPenyaluran(String id) {
// Validasi input
if (!isKonfirmasiChecked.value) {
Get.snackbar(
'Perhatian',
'Anda harus mengkonfirmasi penyaluran bantuan',
backgroundColor: Colors.orange,
colorText: Colors.white,
);
return;
}
if (!isIdentitasChecked.value) {
Get.snackbar(
'Perhatian',
'Anda harus memverifikasi identitas penerima',
backgroundColor: Colors.orange,
colorText: Colors.white,
);
return;
}
if (!isDataValidChecked.value) {
Get.snackbar(
'Perhatian',
'Anda harus menyatakan kebenaran data',
backgroundColor: Colors.orange,
colorText: Colors.white,
);
return;
}
if (fotoBuktiPath.value.isEmpty) {
Get.snackbar(
'Perhatian',
'Bukti foto penyaluran harus diunggah',
backgroundColor: Colors.orange,
colorText: Colors.white,
);
return;
}
if (tandaTanganPath.value.isEmpty) {
Get.snackbar(
'Perhatian',
'Tanda tangan penerima harus diisi',
backgroundColor: Colors.orange,
colorText: Colors.white,
);
return;
}
// Simulasi proses konfirmasi
isLoading.value = true;
// Dalam implementasi nyata, kirim data ke API
Future.delayed(const Duration(seconds: 2), () {
// Update status penerima
final index =
daftarPenerima.indexWhere((penerima) => penerima['id'] == id);
if (index != -1) {
final updatedPenerima =
Map<String, dynamic>.from(daftarPenerima[index]);
updatedPenerima['status'] = 'Selesai';
daftarPenerima[index] = updatedPenerima;
}
isLoading.value = false;
// Reset form
isKonfirmasiChecked.value = false;
isIdentitasChecked.value = false;
isDataValidChecked.value = false;
fotoBuktiPath.value = '';
tandaTanganPath.value = '';
catatanController.clear();
// Tampilkan pesan sukses
Get.snackbar(
'Sukses',
'Konfirmasi penyaluran bantuan berhasil disimpan',
backgroundColor: Colors.green,
colorText: Colors.white,
);
// Kembali ke halaman sebelumnya
Get.back();
});
}
}

View File

@ -38,14 +38,6 @@ class DetailPenerimaView extends GetView<PenerimaController> {
return Scaffold(
appBar: AppBar(
title: const Text('Detail Penerima'),
actions: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () {
// Implementasi edit penerima
},
),
],
),
body: SingleChildScrollView(
child: Column(
@ -413,7 +405,9 @@ class DetailPenerimaView extends GetView<PenerimaController> {
Expanded(
child: ElevatedButton.icon(
onPressed: () {
// Implementasi konfirmasi penyaluran
// Navigasi ke halaman konfirmasi penerima
Get.toNamed('/daftar-penerima/konfirmasi',
arguments: penerima['id']);
},
icon: const Icon(Icons.check_circle),
label: const Text('Konfirmasi Penyaluran'),

View File

@ -0,0 +1,652 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:penyaluran_app/app/modules/petugas_desa/controllers/penerima_controller.dart';
import 'package:penyaluran_app/app/theme/app_theme.dart';
class KonfirmasiPenerimaView extends GetView<PenerimaController> {
const KonfirmasiPenerimaView({super.key});
@override
Widget build(BuildContext context) {
final String id = Get.arguments as String;
return Obx(() {
if (controller.isLoading.value) {
return Scaffold(
appBar: AppBar(
title: const Text('Konfirmasi Penerima'),
),
body: const Center(
child: CircularProgressIndicator(),
),
);
}
final penerima = controller.getPenerimaById(id);
if (penerima == null) {
return Scaffold(
appBar: AppBar(
title: const Text('Konfirmasi Penerima'),
),
body: const Center(
child: Text('Data penerima tidak ditemukan'),
),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('Konfirmasi Penerima'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Get.back(),
),
),
body: SingleChildScrollView(
child: Column(
children: [
// Header dengan foto dan nama
_buildHeader(penerima),
// Detail informasi penerima
_buildDetailInfo(penerima),
// Detail jadwal dan bantuan
_buildDetailJadwalBantuan(penerima),
// Form konfirmasi
_buildKonfirmasiForm(context, penerima),
const SizedBox(height: 20),
],
),
),
bottomNavigationBar: _buildBottomButtons(penerima),
);
});
}
Widget _buildHeader(Map<String, dynamic> penerima) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: AppTheme.primaryGradient,
),
child: Column(
children: [
// Foto profil
CircleAvatar(
radius: 40,
backgroundColor: Colors.white,
child: penerima['foto'] != null
? ClipRRect(
borderRadius: BorderRadius.circular(40),
child: Image.asset(
penerima['foto'],
width: 80,
height: 80,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.person,
size: 40,
color: AppTheme.primaryColor,
);
},
),
)
: const Icon(
Icons.person,
size: 40,
color: AppTheme.primaryColor,
),
),
const SizedBox(height: 12),
// Nama penerima
Text(
penerima['nama'] ?? 'Nama tidak tersedia',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 4),
// NIK
Text(
penerima['nik'] ?? 'NIK tidak tersedia',
style: const TextStyle(
fontSize: 14,
color: Colors.white,
),
),
const SizedBox(height: 8),
// Badge status
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: penerima['status'] == 'Terjadwal'
? AppTheme.scheduledColor
: AppTheme.completedColor,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
penerima['status'] == 'Terjadwal'
? Icons.event_available
: Icons.check_circle,
color: Colors.white,
size: 16,
),
const SizedBox(width: 4),
Text(
penerima['status'] ?? 'Status tidak tersedia',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
);
}
Widget _buildDetailInfo(Map<String, dynamic> penerima) {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Detail Penerima',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
_buildDetailRow('NIK', penerima['nik'] ?? '-'),
_buildDetailRow('No KK', penerima['noKK'] ?? '-'),
_buildDetailRow(
'No Handphone', penerima['noHandphone'] ?? '-'),
_buildDetailRow('Email', penerima['email'] ?? '-'),
_buildDetailRow(
'Jenis Kelamin', penerima['jenisKelamin'] ?? '-'),
_buildDetailRow('Agama', penerima['agama'] ?? '-'),
_buildDetailRow('Tempat, Tanggal Lahir',
penerima['tempatTanggalLahir'] ?? '-'),
_buildDetailRow(
'Alamat Lengkap', penerima['alamatLengkap'] ?? '-'),
_buildDetailRow('Pekerjaan', penerima['pekerjaan'] ?? '-'),
_buildDetailRow('Pendidikan Terakhir',
penerima['pendidikanTerakhir'] ?? '-'),
],
),
),
),
],
),
);
}
Widget _buildDetailJadwalBantuan(Map<String, dynamic> penerima) {
// Simulasi data jadwal dan bantuan
final jadwalBantuan = {
'tanggal': '15 Agustus 2023',
'waktu': '09:00 - 12:00 WIB',
'lokasi': 'Balai Desa Gunung Putri, Jl. Raya Gunung Putri No. 10',
'jenisBantuan': 'Bantuan Sosial Tunai (BST)',
'nilaiNominal': 'Rp 600.000',
'keterangan': 'Bantuan diberikan dalam bentuk tunai'
};
return Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Detail Jadwal & Bantuan',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
_buildDetailRow(
'Tanggal Penyaluran', jadwalBantuan['tanggal'] ?? '-'),
_buildDetailRow('Waktu', jadwalBantuan['waktu'] ?? '-'),
_buildDetailRow('Lokasi', jadwalBantuan['lokasi'] ?? '-'),
_buildDetailRow(
'Jenis Bantuan', jadwalBantuan['jenisBantuan'] ?? '-'),
_buildDetailRow(
'Nilai Nominal', jadwalBantuan['nilaiNominal'] ?? '-'),
_buildDetailRow(
'Keterangan', jadwalBantuan['keterangan'] ?? '-'),
],
),
),
),
],
),
);
}
Widget _buildDetailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 140,
child: Text(
label,
style: const TextStyle(
fontSize: 14,
color: Colors.grey,
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
Widget _buildKonfirmasiForm(
BuildContext context, Map<String, dynamic> penerima) {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Konfirmasi Penyaluran Bantuan',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Status penyaluran
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.infoColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.infoColor.withOpacity(0.2),
shape: BoxShape.circle,
),
child: const Icon(
Icons.info_outline,
color: AppTheme.infoColor,
),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Pastikan penerima hadir dan menerima bantuan sesuai dengan ketentuan.',
style: TextStyle(
fontSize: 14,
color: AppTheme.infoColor,
),
),
),
],
),
),
const SizedBox(height: 20),
// Checkbox persetujuan petugas
const Text(
'Persetujuan Petugas',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
// Checkbox 1
Row(
children: [
Obx(() => Checkbox(
value: controller.isKonfirmasiChecked.value,
onChanged: (value) {
controller.isKonfirmasiChecked.value =
value ?? false;
},
activeColor: AppTheme.primaryColor,
)),
const Expanded(
child: Text(
'Saya konfirmasi bahwa penerima ini telah hadir dan menerima bantuan sesuai dengan ketentuan',
style: TextStyle(fontSize: 14),
),
),
],
),
// Checkbox 2
Row(
children: [
Obx(() => Checkbox(
value: controller.isIdentitasChecked.value,
onChanged: (value) {
controller.isIdentitasChecked.value =
value ?? false;
},
activeColor: AppTheme.primaryColor,
)),
const Expanded(
child: Text(
'Saya telah memverifikasi identitas penerima sesuai dengan KTP/KK yang ditunjukkan',
style: TextStyle(fontSize: 14),
),
),
],
),
// Checkbox 3
Row(
children: [
Obx(() => Checkbox(
value: controller.isDataValidChecked.value,
onChanged: (value) {
controller.isDataValidChecked.value =
value ?? false;
},
activeColor: AppTheme.primaryColor,
)),
const Expanded(
child: Text(
'Saya menyatakan bahwa data yang diinput adalah benar dan dapat dipertanggungjawabkan',
style: TextStyle(fontSize: 14),
),
),
],
),
const SizedBox(height: 20),
// Form bukti foto
const Text(
'Bukti Foto Penyaluran',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
InkWell(
onTap: () => controller.pilihFotoBukti(),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 20),
decoration: BoxDecoration(
border:
Border.all(color: Colors.grey.shade300, width: 1),
borderRadius: BorderRadius.circular(10),
),
child: Obx(() => controller.fotoBuktiPath.value.isEmpty
? Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.camera_alt,
color: AppTheme.primaryColor,
size: 40,
),
const SizedBox(height: 8),
const Text(
'Tambahkan Foto Bukti',
style: TextStyle(
color: AppTheme.primaryColor,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
'Format: JPG, PNG (Maks. 5MB)',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
],
)
: Stack(
alignment: Alignment.topRight,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.asset(
controller.fotoBuktiPath.value,
height: 200,
width: double.infinity,
fit: BoxFit.cover,
),
),
Positioned(
top: 8,
right: 8,
child: InkWell(
onTap: () => controller.hapusFotoBukti(),
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: const Icon(
Icons.close,
color: Colors.red,
size: 20,
),
),
),
),
],
)),
),
),
const SizedBox(height: 20),
// Tanda tangan digital penerima
const Text(
'Tanda Tangan Digital Penerima',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
InkWell(
onTap: () => controller.bukaSignaturePad(context),
child: Container(
height: 150,
width: double.infinity,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(10),
),
child: Obx(() => controller.tandaTanganPath.value.isEmpty
? const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.draw,
color: AppTheme.primaryColor,
size: 40,
),
SizedBox(height: 8),
Text(
'Tap untuk menambahkan tanda tangan',
style: TextStyle(
color: AppTheme.primaryColor,
fontWeight: FontWeight.w500,
),
),
],
)
: Stack(
alignment: Alignment.topRight,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.asset(
controller.tandaTanganPath.value,
height: 150,
width: double.infinity,
fit: BoxFit.contain,
),
),
Positioned(
top: 8,
right: 8,
child: InkWell(
onTap: () => controller.hapusTandaTangan(),
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: const Icon(
Icons.close,
color: Colors.red,
size: 20,
),
),
),
),
],
)),
),
),
const SizedBox(height: 20),
// Form catatan
const Text(
'Catatan Penyaluran (Opsional)',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
TextField(
controller: controller.catatanController,
maxLines: 3,
decoration: InputDecoration(
hintText: 'Masukkan catatan penyaluran jika ada',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
],
),
),
),
],
),
);
}
Widget _buildBottomButtons(Map<String, dynamic> penerima) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.2),
spreadRadius: 1,
blurRadius: 5,
offset: const Offset(0, -3),
),
],
),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Get.back(),
child: const Text('Kembali'),
),
),
const SizedBox(width: 16),
Expanded(
child: Obx(() => ElevatedButton(
onPressed: controller.isKonfirmasiChecked.value &&
controller.isIdentitasChecked.value &&
controller.isDataValidChecked.value &&
controller.fotoBuktiPath.value.isNotEmpty &&
controller.tandaTanganPath.value.isNotEmpty
? () => controller.konfirmasiPenyaluran(penerima['id'])
: null,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
disabledBackgroundColor: Colors.grey.shade300,
),
child: const Text('Konfirmasi'),
)),
),
],
),
);
}
}

View File

@ -12,7 +12,8 @@ import 'package:penyaluran_app/app/modules/petugas_desa/bindings/petugas_desa_bi
import 'package:penyaluran_app/app/modules/petugas_desa/views/permintaan_penjadwalan_view.dart';
import 'package:penyaluran_app/app/modules/petugas_desa/views/daftar_penerima_view.dart';
import 'package:penyaluran_app/app/modules/petugas_desa/views/detail_penerima_view.dart';
import 'package:penyaluran_app/app/modules/petugas_desa/controllers/penerima_controller.dart';
import 'package:penyaluran_app/app/modules/petugas_desa/views/konfirmasi_penerima_view.dart';
import 'package:penyaluran_app/app/modules/petugas_desa/bindings/penerima_binding.dart';
part 'app_routes.dart';
@ -68,5 +69,10 @@ class AppPages {
page: () => const DetailPenerimaView(),
binding: PenerimaBinding(),
),
GetPage(
name: _Paths.konfirmasiPenerima,
page: () => const KonfirmasiPenerimaView(),
binding: PenerimaBinding(),
),
];
}

View File

@ -13,6 +13,7 @@ abstract class Routes {
static const permintaanPenjadwalan = _Paths.permintaanPenjadwalan;
static const daftarPenerima = _Paths.daftarPenerima;
static const detailPenerima = _Paths.detailPenerima;
static const konfirmasiPenerima = _Paths.konfirmasiPenerima;
}
abstract class _Paths {
@ -28,4 +29,5 @@ abstract class _Paths {
static const permintaanPenjadwalan = '/permintaan-penjadwalan';
static const daftarPenerima = '/daftar-penerima';
static const detailPenerima = '/daftar-penerima/detail';
static const konfirmasiPenerima = '/daftar-penerima/konfirmasi';
}