Perbarui model dan tampilan untuk mendukung fungsionalitas QR code dalam proses verifikasi penerima. Tambahkan properti qrCodeHash pada PenerimaPenyaluranModel dan implementasikan metode verifikasi QR code di DetailPenyaluranController. Modifikasi tampilan di WargaDetailPenerimaanView dan DetailPenyaluranPage untuk menampilkan QR code dan menambahkan fungsionalitas pemindaian QR code. Perbarui rute aplikasi untuk mendukung navigasi ke halaman pemindaian QR code dan konfirmasi penerima.

This commit is contained in:
Khafidh Fuadi
2025-03-19 13:11:24 +07:00
parent 984b8336f0
commit 0597f0aea0
19 changed files with 839 additions and 263 deletions

View File

@ -6,6 +6,8 @@ import 'package:penyaluran_app/app/theme/app_theme.dart';
import 'package:penyaluran_app/app/utils/date_time_helper.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import 'package:penyaluran_app/app/modules/petugas_desa/views/konfirmasi_penerima_page.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:penyaluran_app/app/modules/petugas_desa/views/qr_scanner_page.dart';
class DetailPenyaluranPage extends StatelessWidget {
final controller = Get.put(DetailPenyaluranController());
@ -75,20 +77,31 @@ class DetailPenyaluranPage extends StatelessWidget {
),
);
}),
floatingActionButton: Obx(() => showScrollToTop.value
? FloatingActionButton(
mini: true,
backgroundColor: AppTheme.primaryColor,
child: const Icon(Icons.arrow_upward),
onPressed: () {
scrollController.animateTo(
0,
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
);
},
)
: const SizedBox.shrink()),
floatingActionButton: Obx(() {
final status = controller.penyaluran.value?.status?.toUpperCase() ?? '';
if (status == 'AKTIF') {
return FloatingActionButton(
backgroundColor: AppTheme.primaryColor,
onPressed: () => _showQrCodeScanner(context),
tooltip: 'Scan QR Code',
child: const Icon(Icons.qr_code_scanner, color: Colors.white),
);
}
return showScrollToTop.value
? FloatingActionButton(
mini: true,
backgroundColor: AppTheme.primaryColor,
child: const Icon(Icons.arrow_upward),
onPressed: () {
scrollController.animateTo(
0,
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
);
},
)
: const SizedBox.shrink();
}),
bottomNavigationBar: Obx(() {
final status = controller.penyaluran.value?.status?.toUpperCase() ?? '';
if (status == 'AKTIF' ||
@ -749,6 +762,18 @@ class DetailPenyaluranPage extends StatelessWidget {
);
}
// Method untuk mendapatkan warna status
Color _getStatusColor(String status) {
switch (status.toUpperCase()) {
case 'DITERIMA':
return AppTheme.successColor;
case 'BELUMMENERIMA':
return AppTheme.warningColor;
default:
return Colors.grey;
}
}
Widget _buildPenerimaItem(
BuildContext context, PenerimaPenyaluranModel item) {
final warga = item.warga;
@ -848,52 +873,6 @@ class DetailPenyaluranPage extends StatelessWidget {
);
}
Widget _buildStatusChipNew(String status) {
Color backgroundColor;
Color textColor = Colors.white;
String statusText = _getStatusPenerimaanText(status);
IconData iconData;
// Konversi status ke format yang diinginkan
if (status.toUpperCase() == 'DITERIMA') {
backgroundColor = AppTheme.successColor;
statusText = 'Sudah Menerima';
iconData = Icons.check_circle;
} else {
// Semua status selain DITERIMA dianggap sebagai BELUMMENERIMA
backgroundColor = AppTheme.warningColor;
statusText = 'Belum Menerima';
iconData = Icons.pending;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
iconData,
color: textColor,
size: 12,
),
const SizedBox(width: 4),
Text(
statusText,
style: TextStyle(
color: textColor,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
],
),
);
}
Widget _buildStatusBadge(String status) {
Color backgroundColor;
Color textColor = Colors.white;
@ -1111,8 +1090,9 @@ class DetailPenyaluranPage extends StatelessWidget {
);
}
void _showDetailPenerima(
void _showDetailPenerimaan(
BuildContext context, PenerimaPenyaluranModel penerima) {
// Tampilkan detail penerimaan menggunakan bottom sheet
final warga = penerima.warga;
final bool sudahMenerima =
penerima.statusPenerimaan?.toUpperCase() == 'DITERIMA';
@ -1125,7 +1105,7 @@ class DetailPenyaluranPage extends StatelessWidget {
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) {
builder: (BuildContext context) {
return Container(
padding: const EdgeInsets.all(20),
constraints: BoxConstraints(
@ -1185,7 +1165,26 @@ class DetailPenyaluranPage extends StatelessWidget {
),
),
const SizedBox(height: 4),
_buildStatusChipNew(penerima.statusPenerimaan ?? '-'),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: statusColor.withOpacity(0.3)),
),
child: Text(
sudahMenerima
? 'Sudah Menerima'
: 'Belum Menerima',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: statusColor,
),
),
),
],
),
),
@ -1366,25 +1365,26 @@ class DetailPenyaluranPage extends StatelessWidget {
// Tombol tutup
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => Navigator.pop(context),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey.shade200,
foregroundColor: Colors.black87,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
width: double.infinity,
child: ElevatedButton(
onPressed: () => Navigator.pop(context),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey.shade200,
foregroundColor: Colors.black87,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'Tutup',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
child: const Text(
'Tutup',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
)),
),
),
),
],
),
),
@ -1527,4 +1527,68 @@ class DetailPenyaluranPage extends StatelessWidget {
return filteredList;
}
// Fungsi untuk membuka scanner QR code
void _showQrCodeScanner(BuildContext context) async {
if (controller.penyaluran.value?.id == null) return;
final result = await Get.to(
() => QrScannerPage(
penyaluranId: controller.penyaluran.value!.id!,
),
);
if (result == true) {
// Refresh data setelah kembali dari scanner jika berhasil
await controller.refreshData();
Get.snackbar(
'Berhasil',
'Penerima berhasil diverifikasi',
backgroundColor: Colors.green,
colorText: Colors.white,
);
}
}
// Widget untuk menampilkan QR Code (dikosongkan untuk petugas desa)
Widget _buildQrCodeSection(PenerimaPenyaluranModel penerima) {
// Widget QR Code tetap dibuat tapi tidak digunakan di petugas desa
return const SizedBox.shrink();
}
// Widget untuk status chip baru
Widget _buildStatusChipNew(String status) {
final Color statusColor;
final String statusText;
if (status.toUpperCase() == 'DITERIMA') {
statusColor = AppTheme.successColor;
statusText = 'Sudah Menerima';
} else {
statusColor = AppTheme.warningColor;
statusText = 'Belum Menerima';
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: statusColor.withOpacity(0.3)),
),
child: Text(
statusText,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: statusColor,
),
),
);
}
void _showDetailPenerima(
BuildContext context, PenerimaPenyaluranModel penerima) {
_showDetailPenerimaan(context, penerima);
}
}

View File

@ -29,6 +29,7 @@ class KonfirmasiPenerimaPage extends StatefulWidget {
}
class _KonfirmasiPenerimaPageState extends State<KonfirmasiPenerimaPage> {
late PenerimaPenyaluranModel penerima;
final controller = Get.find<DetailPenyaluranController>();
final ImagePicker _picker = ImagePicker();
File? _buktiPenerimaan;
@ -46,6 +47,16 @@ class _KonfirmasiPenerimaPageState extends State<KonfirmasiPenerimaPage> {
// Untuk menyimpan gambar tanda tangan
Uint8List? _signatureImage;
@override
void initState() {
super.initState();
// Menggunakan data penerima yang diberikan dari arguments
penerima = widget.penerima;
print('KonfirmasiPenerimaPage - ID Penerima: ${penerima.id}');
print(
'KonfirmasiPenerimaPage - Nama Penerima: ${penerima.warga?['nama_lengkap']}');
}
@override
void dispose() {
// Pastikan controller signature dibersihkan
@ -55,7 +66,7 @@ class _KonfirmasiPenerimaPageState extends State<KonfirmasiPenerimaPage> {
@override
Widget build(BuildContext context) {
final warga = widget.penerima.warga;
final warga = penerima.warga;
return Scaffold(
appBar: AppBar(
@ -66,40 +77,38 @@ class _KonfirmasiPenerimaPageState extends State<KonfirmasiPenerimaPage> {
onPressed: () => Get.back(),
),
),
body: Obx(
() => controller.isProcessing.value || _isLoading
? const Center(
body: _isLoading
? const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Sedang memproses konfirmasi...'),
],
),
)
: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Sedang memproses konfirmasi...'),
_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(),
],
),
)
: 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(),
],
),
),
),
),
),
);
}
@ -156,6 +165,9 @@ class _KonfirmasiPenerimaPageState extends State<KonfirmasiPenerimaPage> {
),
const Divider(),
//nama lengkap
_buildInfoRow('Nama Lengkap', warga?['nama_lengkap'] ?? 'Bajiyadi'),
// NIK
_buildInfoRow('NIK', warga?['nik'] ?? '3201020107030010'),
const Divider(),
@ -215,6 +227,8 @@ class _KonfirmasiPenerimaPageState extends State<KonfirmasiPenerimaPage> {
String satuan = '';
if (widget.bentukBantuan?.satuan != null) {
satuan = widget.bentukBantuan!.satuan!;
} else if (penerima.satuan != null) {
satuan = penerima.satuan!;
} else {
// Default satuan jika tidak ada
satuan = 'Kg';
@ -227,10 +241,35 @@ class _KonfirmasiPenerimaPageState extends State<KonfirmasiPenerimaPage> {
final waktuSelesai = DateTimeHelper.formatTime(
widget.tanggalPenyaluran!.add(const Duration(hours: 1)));
tanggalWaktuPenyaluran = '$tanggal $waktuMulai-$waktuSelesai';
} else if (penerima.penyaluranBantuan != null &&
penerima.penyaluranBantuan!['tanggal_penyaluran'] != null) {
final tanggalPenyaluran =
DateTime.parse(penerima.penyaluranBantuan!['tanggal_penyaluran']);
final tanggal = DateTimeHelper.formatDate(tanggalPenyaluran);
final waktuMulai = DateTimeHelper.formatTime(tanggalPenyaluran);
final waktuSelesai = DateTimeHelper.formatTime(
tanggalPenyaluran.add(const Duration(hours: 1)));
tanggalWaktuPenyaluran = '$tanggal $waktuMulai-$waktuSelesai';
} else {
tanggalWaktuPenyaluran = '09 April 2025 13:00-14:00';
}
// Ambil nama bantuan dari model jika tersedia
String namaBantuan = 'Beras';
if (widget.bentukBantuan?.nama != null) {
namaBantuan = widget.bentukBantuan!.nama!;
} else if (penerima.kategoriNama != null) {
namaBantuan = penerima.kategoriNama!;
}
// Ambil jumlah bantuan
String jumlahBantuan = '5';
if (widget.jumlahBantuan != null) {
jumlahBantuan = widget.jumlahBantuan!;
} else if (penerima.jumlahBantuan != null) {
jumlahBantuan = penerima.jumlahBantuan.toString();
}
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
@ -252,13 +291,11 @@ class _KonfirmasiPenerimaPageState extends State<KonfirmasiPenerimaPage> {
const SizedBox(height: 16),
// Bentuk Bantuan
_buildInfoRow(
'Bentuk Bantuan', widget.bentukBantuan?.nama ?? 'Beras'),
_buildInfoRow('Bentuk Bantuan', namaBantuan),
const Divider(),
// Nilai Bantuan
_buildInfoRow(
'Nilai Bantuan', '${widget.jumlahBantuan ?? '5'}$satuan'),
_buildInfoRow('Nilai Bantuan', '$jumlahBantuan$satuan'),
const Divider(),
// Tanggal Penyaluran
@ -654,39 +691,62 @@ class _KonfirmasiPenerimaPageState extends State<KonfirmasiPenerimaPage> {
File? signatureFile;
try {
String imageUrl;
String signatureUrl;
String imageUrl = '';
String signatureUrl = '';
// Upload bukti penerimaan
imageUrl = await controller.uploadBuktiPenerimaan(_buktiPenerimaan!.path);
try {
imageUrl =
await controller.uploadBuktiPenerimaan(_buktiPenerimaan!.path);
print('Berhasil upload bukti penerimaan: $imageUrl');
} catch (e) {
// Jika upload bukti penerimaan gagal, tampilkan pesan dan hentikan proses
print('Error upload bukti penerimaan: $e');
throw Exception('Gagal mengupload bukti penerimaan: $e');
}
// Simpan tanda tangan ke file sementara dan upload
tempDir = await Directory.systemTemp.createTemp('signature');
signatureFile = File('${tempDir.path}/signature.png');
await signatureFile.writeAsBytes(_signatureImage!);
try {
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');
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,
);
signatureUrl = await controller.uploadBuktiPenerimaan(
signatureFile.path,
isTandaTangan: true,
);
print('Berhasil upload tanda tangan: $signatureUrl');
} catch (e) {
// Jika upload tanda tangan gagal, tampilkan pesan dan hentikan proses
print('Error upload tanda tangan: $e');
throw Exception('Gagal mengupload tanda tangan: $e');
}
// Konfirmasi penerimaan
await controller.konfirmasiPenerimaan(
widget.penerima,
buktiPenerimaan: imageUrl,
tandaTangan: signatureUrl,
);
try {
print('Melakukan konfirmasi penerimaan untuk ID: ${penerima.id}');
await controller.konfirmasiPenerimaan(
penerima,
buktiPenerimaan: imageUrl,
tandaTangan: signatureUrl,
);
print('Konfirmasi penerimaan berhasil');
} catch (e) {
// Jika konfirmasi penerimaan gagal, tampilkan pesan dan hentikan proses
print('Error konfirmasi penerimaan: $e');
throw Exception('Gagal melakukan konfirmasi penerimaan: $e');
}
// Hapus file sementara sebelum navigasi
try {
if (signatureFile.existsSync()) {
if (signatureFile != null && signatureFile.existsSync()) {
await signatureFile.delete();
}
if (tempDir.existsSync()) {
if (tempDir != null && tempDir.existsSync()) {
await tempDir.delete();
}
} catch (e) {
@ -736,9 +796,12 @@ class _KonfirmasiPenerimaPageState extends State<KonfirmasiPenerimaPage> {
print('Error saat menghapus file sementara: $e');
}
setState(() {
_isLoading = false;
});
// Pastikan state loading diatur kembali ke false
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
}

View File

@ -0,0 +1,196 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart';
import 'package:penyaluran_app/app/theme/app_theme.dart';
import 'package:penyaluran_app/app/modules/petugas_desa/controllers/detail_penyaluran_controller.dart';
class QrScannerPage extends StatefulWidget {
final String penyaluranId;
const QrScannerPage({
super.key,
required this.penyaluranId,
});
@override
State<QrScannerPage> createState() => _QrScannerPageState();
}
class _QrScannerPageState extends State<QrScannerPage> {
final GlobalKey qrKey = GlobalKey(debugLabel: 'QR');
QRViewController? controller;
bool isScanning = true;
final DetailPenyaluranController detailController =
Get.find<DetailPenyaluranController>();
bool isProcessing = false;
@override
void reassemble() {
super.reassemble();
if (Platform.isAndroid) {
controller!.pauseCamera();
} else if (Platform.isIOS) {
controller!.resumeCamera();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Scan QR Code Penerima'),
backgroundColor: AppTheme.primaryColor,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Get.back(),
),
actions: [
IconButton(
icon: const Icon(Icons.flash_on),
onPressed: () async {
await controller?.toggleFlash();
},
),
IconButton(
icon: const Icon(Icons.flip_camera_ios),
onPressed: () async {
await controller?.flipCamera();
},
),
],
),
body: Column(
children: [
Expanded(
flex: 5,
child: Stack(
alignment: Alignment.center,
children: [
QRView(
key: qrKey,
onQRViewCreated: _onQRViewCreated,
overlay: QrScannerOverlayShape(
borderColor: AppTheme.primaryColor,
borderRadius: 10,
borderLength: 30,
borderWidth: 10,
cutOutSize: MediaQuery.of(context).size.width * 0.8,
),
),
// Tampilkan animasi loading saat memproses QR
if (isProcessing)
Container(
width: double.infinity,
height: double.infinity,
color: Colors.black45,
child: const Center(
child: CircularProgressIndicator(
color: Colors.white,
),
),
),
],
),
),
Expanded(
flex: 1,
child: Container(
padding: const EdgeInsets.all(16),
width: double.infinity,
color: Colors.black,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Arahkan kamera ke QR Code penerima bantuan',
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'QR Code akan otomatis terbaca',
style: TextStyle(
color: Colors.grey[400],
fontSize: 12,
),
textAlign: TextAlign.center,
),
],
),
),
),
],
),
);
}
void _onQRViewCreated(QRViewController controller) {
this.controller = controller;
controller.scannedDataStream.listen((scanData) async {
if (!isScanning || isProcessing) return;
if (scanData.code != null) {
isScanning = false;
setState(() {
isProcessing = true;
});
try {
final qrHash = scanData.code!;
print('QR Hash yang terbaca: $qrHash');
final bool result = await detailController.verifikasiPenerimaByQrCode(
widget.penyaluranId, qrHash);
if (result) {
// Success - Kembali ke halaman sebelumnya dengan hasil true
Get.back(result: true);
} else {
// QR Code tidak valid atau tidak ditemukan
Get.snackbar(
'Gagal Memverifikasi',
'QR Code tidak valid atau tidak terdaftar pada penyaluran ini',
backgroundColor: Colors.red,
colorText: Colors.white,
duration: const Duration(seconds: 3),
);
// Lanjutkan pemindaian setelah delay
await Future.delayed(const Duration(seconds: 2));
isScanning = true;
}
} catch (e) {
print('Error pemindaian QR: $e');
Get.snackbar(
'Error',
'Terjadi kesalahan saat memproses QR code: ${e.toString()}',
backgroundColor: Colors.red,
colorText: Colors.white,
duration: const Duration(seconds: 3),
);
// Lanjutkan pemindaian setelah delay
await Future.delayed(const Duration(seconds: 2));
isScanning = true;
} finally {
if (mounted) {
setState(() {
isProcessing = false;
});
}
}
}
});
}
@override
void dispose() {
controller?.dispose();
super.dispose();
}
}