Perbarui dependensi dan tambahkan fungsionalitas laporan penyaluran. Tambahkan paket baru seperti file_picker, pdf, dan open_file ke dalam pubspec.yaml. Hapus model LaporanModel yang tidak digunakan dan ganti dengan LaporanPenyaluranModel. Modifikasi tampilan dan controller untuk mendukung pengelolaan laporan penyaluran, termasuk navigasi dan ekspor ke PDF. Perbarui rute aplikasi untuk mencakup halaman laporan penyaluran baru.

This commit is contained in:
Khafidh Fuadi
2025-03-20 05:19:04 +07:00
parent 3b12c7af86
commit 54c4660302
29 changed files with 4702 additions and 539 deletions

View File

@ -0,0 +1,11 @@
import 'package:get/get.dart';
import 'package:penyaluran_app/app/modules/laporan_penyaluran/controllers/laporan_penyaluran_controller.dart';
class LaporanPenyaluranBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<LaporanPenyaluranController>(
() => LaporanPenyaluranController(),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,623 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:penyaluran_app/app/modules/laporan_penyaluran/controllers/laporan_penyaluran_controller.dart';
import 'package:penyaluran_app/app/theme/app_theme.dart';
import 'package:penyaluran_app/app/utils/date_time_helper.dart';
import 'package:penyaluran_app/app/widgets/custom_app_bar.dart';
import 'package:penyaluran_app/app/widgets/section_header.dart';
import 'dart:io';
import 'package:image_picker/image_picker.dart';
import 'package:file_picker/file_picker.dart';
class LaporanPenyaluranCreateView extends GetView<LaporanPenyaluranController> {
const LaporanPenyaluranCreateView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final penyaluranId = Get.arguments as String;
// Dapatkan info penyaluran
WidgetsBinding.instance.addPostFrameCallback((_) {
controller.fetchPenyaluranDetail(penyaluranId);
controller.resetForm(); // Reset form setiap kali halaman dibuka
});
return Scaffold(
appBar: CustomAppBar(
title: 'Buat Laporan Penyaluran',
// subtitle: 'Isi form untuk membuat laporan penyaluran',
showBackButton: true,
),
body: Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Informasi penyaluran
if (controller.selectedPenyaluran.value != null) ...[
Card(
margin: EdgeInsets.zero,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionHeader(
title: 'Informasi Penyaluran',
// subtitle: 'Penyaluran yang akan dibuatkan laporan',
),
const SizedBox(height: 16),
_buildInfoItem(
'Nama Penyaluran',
controller.selectedPenyaluran.value!.nama ?? '-',
),
_buildInfoItem(
'Tanggal Penyaluran',
controller.selectedPenyaluran.value!
.tanggalPenyaluran !=
null
? DateTimeHelper.formatDateTime(controller
.selectedPenyaluran.value!.tanggalPenyaluran!)
: '-',
),
_buildInfoItem(
'Tanggal Selesai',
controller.selectedPenyaluran.value!.tanggalSelesai !=
null
? DateTimeHelper.formatDateTime(controller
.selectedPenyaluran.value!.tanggalSelesai!)
: '-',
),
_buildInfoItem(
'Jumlah Penerima',
'${controller.selectedPenyaluran.value!.jumlahPenerima ?? 0} orang',
),
],
),
),
),
const SizedBox(height: 24),
],
// Form laporan
Card(
margin: EdgeInsets.zero,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionHeader(
title: 'Form Laporan',
),
const SizedBox(height: 16),
// Judul laporan
_buildTextField(
controller: controller.judulController,
label: 'Judul Laporan',
hint: 'Masukkan judul laporan',
required: true,
),
],
),
),
),
const SizedBox(height: 24),
// Dokumentasi dan Berita Acara
Card(
margin: EdgeInsets.zero,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionHeader(
title: 'Dokumentasi & Berita Acara',
),
const SizedBox(height: 16),
// Upload Dokumentasi
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: TextSpan(
text: 'Dokumentasi',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.primaryColor,
),
),
),
const SizedBox(height: 8),
Obx(
() => controller.dokumentasiPath.isNotEmpty
? Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: Colors.grey.shade300),
),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius:
BorderRadius.circular(8),
child: Image.file(
File(controller
.dokumentasiPath.value),
width: 200,
height: 120,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) =>
Container(
width: 200,
height: 120,
color: Colors.grey.shade200,
child: const Center(
child: Text(
'Pratinjau tidak tersedia'),
),
),
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment:
MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: () {
controller
.dokumentasiPath.value = '';
},
icon: Icon(Icons.delete,
color: AppTheme.errorColor),
label: Text('Hapus',
style: TextStyle(
color:
AppTheme.errorColor)),
),
],
),
],
),
)
: OutlinedButton.icon(
onPressed: () =>
_pickDocumentationImage(context),
icon: const Icon(Icons.upload_file),
label:
const Text('Upload Foto Dokumentasi'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 16,
),
side: BorderSide(
color: AppTheme.primaryColor),
),
),
),
const SizedBox(height: 4),
Text(
'Format gambar: JPG, PNG, JPEG (maks. 5MB)',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
fontStyle: FontStyle.italic,
),
),
],
),
const SizedBox(height: 20),
// Upload Berita Acara
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: TextSpan(
text: 'Berita Acara',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.primaryColor,
),
),
),
const SizedBox(height: 8),
Obx(
() => controller.beritaAcaraPath.isNotEmpty
? Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: Colors.grey.shade300),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.primaryColor
.withOpacity(0.1),
borderRadius:
BorderRadius.circular(8),
),
child: Icon(
Icons.description,
color: AppTheme.primaryColor,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
'Dokumen Berita Acara',
style: TextStyle(
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
),
),
const SizedBox(height: 4),
Text(
controller.beritaAcaraPath.value
.split('/')
.last,
style: const TextStyle(
fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
IconButton(
onPressed: () {
controller.beritaAcaraPath.value =
'';
},
icon: Icon(Icons.delete,
color: AppTheme.errorColor),
),
],
),
)
: OutlinedButton.icon(
onPressed: () =>
_pickBeritaAcaraFile(context),
icon: const Icon(Icons.file_present),
label: const Text(
'Upload Dokumen Berita Acara'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 16,
),
side: BorderSide(
color: AppTheme.primaryColor),
),
),
),
const SizedBox(height: 4),
Text(
'Format file: PDF, DOC, DOCX (maks. 10MB)',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
fontStyle: FontStyle.italic,
),
),
],
),
],
),
),
),
const SizedBox(height: 48),
// Tombol simpan
Obx(() => SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: controller.isSaving.value
? null
: () {
if (controller.judulController.text.isEmpty) {
Get.snackbar(
'Perhatian',
'Judul laporan wajib diisi',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: AppTheme.warningColor,
colorText: Colors.white,
);
return;
}
controller.saveLaporan(penyaluranId);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: controller.isSaving.value
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 3,
),
)
: const Text(
'Simpan Laporan',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
)),
const SizedBox(height: 32),
],
),
);
}),
);
}
// Metode untuk memilih gambar dokumentasi
void _pickDocumentationImage(BuildContext context) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => Container(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Pilih Sumber Gambar',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildImageSourceOption(
context,
Icons.camera_alt,
'Kamera',
ImageSource.camera,
Colors.blue,
),
_buildImageSourceOption(
context,
Icons.photo_library,
'Galeri',
ImageSource.gallery,
AppTheme.primaryColor,
),
],
),
const SizedBox(height: 16),
],
),
),
);
}
// Widget untuk opsi sumber gambar
Widget _buildImageSourceOption(
BuildContext context,
IconData icon,
String label,
ImageSource source,
Color color,
) {
return InkWell(
onTap: () async {
Navigator.pop(context);
final ImagePicker picker = ImagePicker();
try {
final XFile? image = await picker.pickImage(
source: source,
imageQuality: 80,
);
if (image != null) {
controller.dokumentasiPath.value = image.path;
}
} catch (e) {
Get.snackbar(
'Error',
'Gagal memilih gambar: $e',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: AppTheme.errorColor,
colorText: Colors.white,
);
}
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 32),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Column(
children: [
Icon(icon, size: 48, color: color),
const SizedBox(height: 8),
Text(
label,
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
),
),
],
),
),
);
}
// Widget untuk memilih file berita acara
Future<void> _pickBeritaAcaraFile(BuildContext context) async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf', 'doc', 'docx'],
);
if (result != null) {
controller.beritaAcaraPath.value = result.files.single.path!;
}
} catch (e) {
Get.snackbar(
'Error',
'Gagal memilih file: $e',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: AppTheme.errorColor,
colorText: Colors.white,
);
}
}
// Widget untuk item informasi
Widget _buildInfoItem(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 150,
child: Text(
label,
style: TextStyle(
fontSize: 14,
color: AppTheme.primaryColor,
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
),
],
),
);
}
// Widget untuk input teks
Widget _buildTextField({
required TextEditingController controller,
required String label,
required String hint,
bool required = false,
int maxLines = 1,
bool isReadOnly = false,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: TextSpan(
text: label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.primaryColor,
),
children: required
? [
TextSpan(
text: ' *',
style: TextStyle(
color: AppTheme.errorColor,
fontWeight: FontWeight.bold,
),
),
]
: null,
),
),
const SizedBox(height: 8),
TextField(
controller: controller,
maxLines: maxLines,
readOnly: isReadOnly,
decoration: InputDecoration(
hintText: hint,
hintStyle: TextStyle(color: Colors.grey[400]),
filled: true,
fillColor: Colors.grey[50],
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: AppTheme.primaryColor, width: 2),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey[300]!),
),
contentPadding: const EdgeInsets.all(16),
),
),
],
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,700 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:penyaluran_app/app/modules/laporan_penyaluran/controllers/laporan_penyaluran_controller.dart';
import 'package:penyaluran_app/app/widgets/custom_app_bar.dart';
import 'package:penyaluran_app/app/widgets/section_header.dart';
import 'dart:io';
import 'package:image_picker/image_picker.dart';
import 'package:file_picker/file_picker.dart';
class LaporanPenyaluranEditView extends GetView<LaporanPenyaluranController> {
const LaporanPenyaluranEditView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final laporanId = Get.arguments as String;
// Dapatkan data laporan
WidgetsBinding.instance.addPostFrameCallback((_) {
controller.fetchLaporanDetail(laporanId).then((_) {
if (controller.selectedLaporan.value != null) {
controller.setFormForEdit(controller.selectedLaporan.value!);
}
});
});
return Scaffold(
appBar: CustomAppBar(
title: 'Edit Laporan Penyaluran',
// subtitle: 'Perbarui informasi laporan penyaluran',
showBackButton: true,
),
body: Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
if (controller.selectedLaporan.value == null) {
return const Center(
child: Text('Laporan tidak ditemukan'),
);
}
// Cek status laporan
if (controller.selectedLaporan.value!.status == 'FINAL') {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.lock,
size: 64,
color: Colors.orange,
),
const SizedBox(height: 16),
const Text(
'Laporan Telah Difinalisasi',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
const Text(
'Laporan yang sudah difinalisasi tidak dapat diedit lagi.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.grey,
fontSize: 16,
),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => Get.back(),
child: const Text('Kembali ke Detail Laporan'),
),
],
),
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Informasi penyaluran
if (controller.selectedPenyaluran.value != null) ...[
Card(
margin: EdgeInsets.zero,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionHeader(
title: 'Informasi Penyaluran',
// subtitle: 'Penyaluran yang terkait dengan laporan',
),
const SizedBox(height: 16),
_buildInfoItem(
'Nama Penyaluran',
controller.selectedPenyaluran.value!.nama ?? '-',
),
_buildInfoItem(
'Tanggal Penyaluran',
controller.selectedPenyaluran.value!
.tanggalPenyaluran !=
null
? '${controller.selectedPenyaluran.value!.tanggalPenyaluran!.day}/${controller.selectedPenyaluran.value!.tanggalPenyaluran!.month}/${controller.selectedPenyaluran.value!.tanggalPenyaluran!.year}'
: '-',
),
_buildInfoItem(
'Tanggal Selesai',
controller.selectedPenyaluran.value!.tanggalSelesai !=
null
? '${controller.selectedPenyaluran.value!.tanggalSelesai!.day}/${controller.selectedPenyaluran.value!.tanggalSelesai!.month}/${controller.selectedPenyaluran.value!.tanggalSelesai!.year}'
: '-',
),
_buildInfoItem(
'Jumlah Penerima',
'${controller.selectedPenyaluran.value!.jumlahPenerima ?? 0} orang',
),
],
),
),
),
const SizedBox(height: 24),
],
// Form laporan
Card(
margin: EdgeInsets.zero,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionHeader(
title: 'Form Laporan',
),
const SizedBox(height: 16),
// Judul laporan
_buildTextField(
controller: controller.judulController,
label: 'Judul Laporan',
hint: 'Masukkan judul laporan',
required: true,
),
],
),
),
),
const SizedBox(height: 24),
// Dokumentasi dan Berita Acara
Card(
margin: EdgeInsets.zero,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionHeader(
title: 'Dokumentasi & Berita Acara',
),
const SizedBox(height: 16),
// Upload Dokumentasi
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: TextSpan(
text: 'Dokumentasi',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.grey[800],
),
),
),
const SizedBox(height: 8),
// Dokumentasi yang sudah ada
if (controller
.selectedLaporan.value?.dokumentasiUrl !=
null &&
controller.dokumentasiPath.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.grey.shade300),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
controller.selectedLaporan.value!
.dokumentasiUrl!,
width: 200,
height: 120,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) =>
Container(
width: 200,
height: 120,
color: Colors.grey.shade200,
child: const Center(
child:
Text('Pratinjau tidak tersedia'),
),
),
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: () =>
_pickDocumentationImage(context),
icon: const Icon(Icons.edit,
color: Colors.blue),
label: const Text('Ganti',
style:
TextStyle(color: Colors.blue)),
),
],
),
],
),
)
// Dokumentasi yang baru dipilih
else if (controller.dokumentasiPath.isNotEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.grey.shade300),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(
File(controller.dokumentasiPath.value),
width: 200,
height: 120,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) =>
Container(
width: 200,
height: 120,
color: Colors.grey.shade200,
child: const Center(
child:
Text('Pratinjau tidak tersedia'),
),
),
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: () {
controller.dokumentasiPath.value = '';
},
icon: const Icon(Icons.delete,
color: Colors.red),
label: const Text('Hapus',
style:
TextStyle(color: Colors.red)),
),
],
),
],
),
)
// Tidak ada dokumentasi
else
OutlinedButton.icon(
onPressed: () => _pickDocumentationImage(context),
icon: const Icon(Icons.upload_file),
label: const Text('Upload Foto Dokumentasi'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 16,
),
side: BorderSide(color: Colors.blue.shade300),
),
),
const SizedBox(height: 4),
Text(
'Format gambar: JPG, PNG, JPEG (maks. 5MB)',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
fontStyle: FontStyle.italic,
),
),
],
),
const SizedBox(height: 20),
// Upload Berita Acara
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: TextSpan(
text: 'Berita Acara',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.grey[800],
),
),
),
const SizedBox(height: 8),
// Berita acara yang sudah ada
if (controller
.selectedLaporan.value?.beritaAcaraUrl !=
null &&
controller.beritaAcaraPath.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.grey.shade300),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.description,
size: 40,
color: Colors.blue.shade700),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
'Dokumen Berita Acara',
style: const TextStyle(
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
Uri.parse(controller
.selectedLaporan
.value!
.beritaAcaraUrl!)
.pathSegments
.last,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: () =>
_pickBeritaAcaraFile(context),
icon: const Icon(Icons.edit,
color: Colors.blue),
label: const Text('Ganti',
style:
TextStyle(color: Colors.blue)),
),
],
),
],
),
)
// Berita acara yang baru dipilih
else if (controller.beritaAcaraPath.isNotEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.grey.shade300),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.description,
size: 40,
color: Colors.blue.shade700),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
File(controller
.beritaAcaraPath.value)
.path
.split('/')
.last,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
'Dokumen berita acara',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: () {
controller.beritaAcaraPath.value = '';
},
icon: const Icon(Icons.delete,
color: Colors.red),
label: const Text('Hapus',
style:
TextStyle(color: Colors.red)),
),
],
),
],
),
)
// Tidak ada berita acara
else
OutlinedButton.icon(
onPressed: () => _pickBeritaAcaraFile(context),
icon: const Icon(Icons.upload_file),
label: const Text('Upload Dokumen Berita Acara'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 16,
),
side: BorderSide(color: Colors.blue.shade300),
),
),
const SizedBox(height: 4),
Text(
'Format dokumen: PDF, DOC, DOCX (maks. 10MB)',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
fontStyle: FontStyle.italic,
),
),
],
),
],
),
),
),
const SizedBox(height: 32),
// Tombol aksi
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Get.back(),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: const Text('Batal'),
),
),
const SizedBox(width: 16),
Expanded(
child: Obx(() => ElevatedButton(
onPressed: controller.isSaving.value
? null
: () => controller.updateLaporan(laporanId),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: controller.isSaving.value
? const SizedBox(
height: 20,
width: 20,
child:
CircularProgressIndicator(strokeWidth: 2),
)
: const Text(
'Simpan Perubahan',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
)),
),
],
),
],
),
);
}),
);
}
// Widget untuk item informasi
Widget _buildInfoItem(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
label,
style: TextStyle(
fontSize: 14,
color: Colors.grey[700],
),
),
),
const Text(' : '),
Expanded(
child: Text(
value,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
// Widget untuk input teks
Widget _buildTextField({
required TextEditingController controller,
required String label,
required String hint,
int maxLines = 1,
bool required = false,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: TextSpan(
text: label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.grey[800],
),
children: required
? const [
TextSpan(
text: ' *',
style: TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
),
),
]
: null,
),
),
const SizedBox(height: 8),
TextField(
controller: controller,
decoration: InputDecoration(
hintText: hint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
maxLines: maxLines,
),
],
);
}
// Metode untuk memilih gambar dokumentasi
Future<void> _pickDocumentationImage(BuildContext context) async {
try {
final ImagePicker picker = ImagePicker();
final XFile? image = await picker.pickImage(
source: ImageSource.gallery,
imageQuality: 80,
);
if (image != null) {
controller.dokumentasiPath.value = image.path;
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Terjadi kesalahan: $e'),
backgroundColor: Colors.red,
),
);
}
}
// Metode untuk memilih file berita acara
Future<void> _pickBeritaAcaraFile(BuildContext context) async {
try {
FilePickerResult? result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf', 'doc', 'docx'],
);
if (result != null) {
controller.beritaAcaraPath.value = result.files.single.path!;
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Terjadi kesalahan: $e'),
backgroundColor: Colors.red,
),
);
}
}
}

View File

@ -0,0 +1,410 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:penyaluran_app/app/data/models/laporan_penyaluran_model.dart';
import 'package:penyaluran_app/app/modules/laporan_penyaluran/controllers/laporan_penyaluran_controller.dart';
import 'package:penyaluran_app/app/theme/app_theme.dart';
import 'package:penyaluran_app/app/utils/date_time_helper.dart';
import 'package:penyaluran_app/app/widgets/custom_app_bar.dart';
import 'package:penyaluran_app/app/widgets/section_header.dart';
import 'package:penyaluran_app/app/widgets/status_badge.dart';
import 'package:intl/intl.dart';
class LaporanPenyaluranView extends GetView<LaporanPenyaluranController> {
const LaporanPenyaluranView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: CustomAppBar(
title: 'Laporan Penyaluran Bantuan',
showBackButton: true,
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Filter status
_buildStatusFilter(),
// Daftar laporan
Expanded(
child: Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
if (controller.daftarLaporan.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.note_alt_outlined,
size: 64, color: Colors.grey),
const SizedBox(height: 16),
const Text(
'Belum ada laporan penyaluran',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
const Text(
'Buat laporan baru untuk penyaluran yang telah selesai',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: () => _showPenyaluranDialog(context),
icon: const Icon(Icons.add),
label: const Text('Buat Laporan Baru'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24, vertical: 12),
),
),
],
),
);
}
// Filter berdasarkan status jika dipilih
final filteredLaporan = controller.filterStatus.value == 'SEMUA'
? controller.daftarLaporan
: controller.daftarLaporan
.where((laporan) =>
laporan.status == controller.filterStatus.value)
.toList();
if (filteredLaporan.isEmpty) {
return Center(
child: Text(
'Tidak ada laporan dengan status ${controller.filterStatus.value}'),
);
}
return RefreshIndicator(
onRefresh: () async {
await controller.fetchLaporan();
},
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: filteredLaporan.length,
itemBuilder: (context, index) {
final laporan = filteredLaporan[index];
return _buildLaporanCard(context, laporan);
},
),
);
}),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showPenyaluranDialog(context),
child: const Icon(Icons.add),
),
);
}
// Widget untuk filter status
Widget _buildStatusFilter() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
color: AppTheme.primaryColor.withOpacity(0.05),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionHeader(
title: 'Filter Status',
// subtitle: 'Tampilkan laporan berdasarkan status',
// showDivider: false,
),
const SizedBox(height: 8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildFilterChip('SEMUA'),
_buildFilterChip('DRAFT'),
_buildFilterChip('FINAL'),
],
),
),
],
),
);
}
// Chip untuk filter
Widget _buildFilterChip(String status) {
return Obx(() {
final isSelected = controller.filterStatus.value == status;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
selected: isSelected,
label: Text(status),
onSelected: (_) {
controller.filterStatus.value = status;
},
backgroundColor: Colors.white,
checkmarkColor: Colors.white,
selectedColor: AppTheme.primaryColor,
labelStyle: TextStyle(
color: isSelected ? Colors.white : Colors.black,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
),
);
});
}
// Widget untuk card laporan
Widget _buildLaporanCard(
BuildContext context, LaporanPenyaluranModel laporan) {
return Card(
margin: const EdgeInsets.only(bottom: 16),
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: InkWell(
onTap: () {
Get.toNamed('/laporan-penyaluran/detail', arguments: laporan.id);
},
borderRadius: BorderRadius.circular(12),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
gradient: AppTheme.primaryGradient,
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
laporan.judul,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: Colors.white,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
StatusBadge(status: laporan.status ?? 'DRAFT'),
],
),
const Divider(height: 24, color: Colors.white30),
Row(
children: [
Icon(
Icons.calendar_today,
size: 16,
color: Colors.white,
),
const SizedBox(width: 6),
Text(
'Tanggal: ${laporan.tanggalLaporan != null ? DateTimeHelper.formatDateTime(laporan.tanggalLaporan!) : '-'}',
style: TextStyle(
fontSize: 12,
color: Colors.white,
),
),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (laporan.status == 'FINAL')
Material(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () {
controller
.fetchPenyaluranDetail(
laporan.penyaluranBantuanId)
.then((_) {
if (controller.selectedPenyaluran.value != null) {
controller.exportToPdf(laporan,
controller.selectedPenyaluran.value!);
}
});
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6),
child: Row(
children: const [
Icon(
Icons.picture_as_pdf,
size: 16,
color: Colors.white,
),
SizedBox(width: 4),
Text(
'Ekspor PDF',
style: TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
),
const SizedBox(width: 8),
Material(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(8),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () {
Get.toNamed('/laporan-penyaluran/edit',
arguments: laporan.id);
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6),
child: Row(
children: const [
Icon(Icons.edit, color: Colors.orange, size: 16),
SizedBox(width: 4),
Text('Edit',
style: TextStyle(
color: Colors.orange,
fontWeight: FontWeight.bold)),
],
),
),
),
),
const SizedBox(width: 8),
Material(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () {
_showDeleteConfirmation(context, laporan.id!);
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6),
child: Row(
children: const [
Icon(Icons.delete, color: Colors.red, size: 16),
SizedBox(width: 4),
Text('Hapus',
style: TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold)),
],
),
),
),
),
],
),
],
),
),
),
),
);
}
// Dialog konfirmasi hapus laporan
void _showDeleteConfirmation(BuildContext context, String laporanId) {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Hapus Laporan'),
content: const Text('Apakah Anda yakin ingin menghapus laporan ini?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Batal'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
controller.deleteLaporan(laporanId);
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Hapus'),
),
],
);
},
);
}
// Dialog pilih penyaluran untuk laporan baru
void _showPenyaluranDialog(BuildContext context) {
if (controller.penyaluranTanpaLaporan.isEmpty) {
Get.snackbar(
'Info',
'Tidak ada penyaluran yang tersedia untuk dibuat laporan. Pastikan ada penyaluran dengan status SELESAI.',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.orange,
colorText: Colors.white,
);
return;
}
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Pilih Penyaluran'),
content: SizedBox(
width: double.maxFinite,
child: ListView.builder(
shrinkWrap: true,
itemCount: controller.penyaluranTanpaLaporan.length,
itemBuilder: (context, index) {
final penyaluran = controller.penyaluranTanpaLaporan[index];
return ListTile(
title: Text(
penyaluran.nama ??
'Penyaluran #${penyaluran.id?.substring(0, 8)}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
'Tanggal: ${penyaluran.tanggalSelesai != null ? DateFormat('dd/MM/yyyy').format(penyaluran.tanggalSelesai!) : '-'}',
),
onTap: () {
Navigator.of(context).pop();
// Arahkan ke halaman buat laporan dengan ID penyaluran
Get.toNamed('/laporan-penyaluran/create',
arguments: penyaluran.id);
},
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Batal'),
),
],
);
},
);
}
}