From 5aaeb58d2bcd52805375d0a8bb68e1051eb9e4fc Mon Sep 17 00:00:00 2001 From: Khafidh Fuadi Date: Sun, 30 Mar 2025 14:45:16 +0700 Subject: [PATCH] h-1 lebaran --- .../arm64-v8a/configure_fingerprint.bin | 24 +- .../armeabi-v7a/configure_fingerprint.bin | 24 +- .../626b5o2n/x86/configure_fingerprint.bin | 24 +- .../626b5o2n/x86_64/configure_fingerprint.bin | 24 +- android/app/src/main/AndroidManifest.xml | 21 +- .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 6466 -> 5217 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 4078 -> 3479 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 9013 -> 7249 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 14295 -> 11715 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 19519 -> 16771 bytes assets/images/logo-disalurkita.png | Bin 0 -> 42480 bytes assets/sql/README.md | 108 + assets/sql/batch_update_jadwal_status.sql | 80 + ios/Runner/Info.plist | 6 + .../data/models/lokasi_penyaluran_model.dart | 4 + .../data/models/penyaluran_bantuan_model.dart | 53 + lib/app/data/providers/auth_provider.dart | 8 + .../modules/about/bindings/about_binding.dart | 8 + lib/app/modules/about/views/about_view.dart | 454 ++++ .../auth/controllers/auth_controller.dart | 73 + .../auth/views/forgot_password_view.dart | 269 ++ lib/app/modules/auth/views/login_view.dart | 454 +++- .../auth/views/register_donatur_view.dart | 456 +++- .../donatur_dashboard_controller.dart | 68 +- .../donatur/views/donatur_dashboard_view.dart | 205 +- .../views/donatur_jadwal_detail_view.dart | 298 +- .../donatur/views/donatur_jadwal_view.dart | 51 +- .../donatur/views/donatur_laporan_view.dart | 945 +++++-- .../donatur/views/donatur_penitipan_view.dart | 2410 ++++++++++++----- .../views/donatur_riwayat_penitipan_view.dart | 82 +- .../donatur/views/donatur_skema_view.dart | 15 +- .../modules/donatur/views/donatur_view.dart | 193 +- .../laporan_penyaluran_controller.dart | 14 +- .../views/laporan_penyaluran_create_view.dart | 4 +- .../views/laporan_penyaluran_detail_view.dart | 1616 ++++++++--- .../views/laporan_penyaluran_view.dart | 12 +- .../penyaluran/detail_penyaluran_page.dart | 1 - .../bindings/jadwal_penyaluran_binding.dart | 20 + .../components/calendar_view_widget.dart | 4 +- .../components/jadwal_section_widget.dart | 2 +- .../detail_penyaluran_controller.dart | 20 +- .../jadwal_penyaluran_controller.dart | 521 +++- .../pelaksanaan_penyaluran_controller.dart | 4 +- .../controllers/penerima_controller.dart | 2 +- .../controllers/petugas_desa_controller.dart | 17 +- .../petugas_desa_dashboard_controller.dart | 41 +- .../views/daftar_penerima_view.dart | 45 +- .../petugas_desa/views/dashboard_view.dart | 99 +- .../views/detail_donatur_view.dart | 8 +- .../views/detail_penerima_view.dart | 24 +- .../views/detail_pengaduan_view.dart | 137 +- .../views/detail_penyaluran_page.dart | 82 +- .../views/konfirmasi_penerima_page.dart | 14 +- .../petugas_desa/views/pengaduan_view.dart | 6 +- .../petugas_desa/views/penitipan_view.dart | 49 +- .../petugas_desa/views/penyaluran_view.dart | 263 +- .../petugas_desa/views/petugas_desa_view.dart | 35 +- .../views/riwayat_pengaduan_view.dart | 8 +- .../views/riwayat_penitipan_view.dart | 124 +- .../views/riwayat_penyaluran_view.dart | 64 +- .../petugas_desa/views/riwayat_stok_view.dart | 107 +- .../petugas_desa/views/stok_bantuan_view.dart | 18 +- .../views/tambah_lokasi_penyaluran_view.dart | 233 ++ .../views/tambah_penyaluran_view.dart | 1786 +++++++----- .../modules/profile/views/profile_view.dart | 39 +- lib/app/modules/splash/views/splash_view.dart | 24 +- .../warga/views/detail_pengaduan_view.dart | 213 +- .../warga/views/form_pengaduan_view.dart | 6 +- .../warga/views/warga_dashboard_view.dart | 73 +- .../views/warga_detail_penerimaan_view.dart | 24 +- .../warga/views/warga_pengaduan_view.dart | 7 +- lib/app/modules/warga/views/warga_view.dart | 36 +- lib/app/routes/app_pages.dart | 20 +- lib/app/routes/app_routes.dart | 6 + lib/app/services/jadwal_update_service.dart | 216 ++ lib/app/services/notification_service.dart | 204 ++ lib/app/services/supabase_service.dart | 151 +- lib/app/utils/date_helper.dart | 43 - lib/app/utils/format_helper.dart | 9 +- lib/app/widgets/app_drawer.dart | 22 +- lib/app/widgets/bantuan_card.dart | 18 +- .../dialogs/detail_penitipan_dialog.dart | 50 +- .../widgets/dialogs/show_image_dialog.dart | 254 ++ lib/app/widgets/widgets.dart | 1 + lib/main.dart | 25 +- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 18 +- pubspec.yaml | 2 + temp.txt | Bin 42032 -> 42032 bytes .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 91 files changed, 9448 insertions(+), 3756 deletions(-) create mode 100644 assets/images/logo-disalurkita.png create mode 100644 assets/sql/README.md create mode 100644 assets/sql/batch_update_jadwal_status.sql create mode 100644 lib/app/modules/about/bindings/about_binding.dart create mode 100644 lib/app/modules/about/views/about_view.dart create mode 100644 lib/app/modules/auth/views/forgot_password_view.dart delete mode 100644 lib/app/modules/penyaluran/detail_penyaluran_page.dart create mode 100644 lib/app/modules/petugas_desa/bindings/jadwal_penyaluran_binding.dart create mode 100644 lib/app/modules/petugas_desa/views/tambah_lokasi_penyaluran_view.dart create mode 100644 lib/app/services/jadwal_update_service.dart create mode 100644 lib/app/services/notification_service.dart delete mode 100644 lib/app/utils/date_helper.dart create mode 100644 lib/app/widgets/dialogs/show_image_dialog.dart diff --git a/android/app/.cxx/Debug/626b5o2n/arm64-v8a/configure_fingerprint.bin b/android/app/.cxx/Debug/626b5o2n/arm64-v8a/configure_fingerprint.bin index 705724b..35d0246 100644 --- a/android/app/.cxx/Debug/626b5o2n/arm64-v8a/configure_fingerprint.bin +++ b/android/app/.cxx/Debug/626b5o2n/arm64-v8a/configure_fingerprint.bin @@ -2,27 +2,27 @@ C/C++ Structured LogO M KC:\dev\flutter\packages\flutter_tools\gradle\src\main\groovy\CMakeLists.txtC A -?com.android.build.gradle.internal.cxx.io.EncodedFileFingerPrint  Õ2 2 +?com.android.build.gradle.internal.cxx.io.EncodedFileFingerPrint  2 2  -}D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\additional_project_files.txt  Õ2  2~ +}D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\additional_project_files.txt  2  2~ | -zD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\android_gradle_build.json  Õ2 2 +zD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\android_gradle_build.json  2 2  -D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\android_gradle_build_mini.json  Õ2 2p +D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\android_gradle_build_mini.json  2 2p n -lD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\build.ninja  Õ2 2t +lD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\build.ninja  2 2t r -pD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\build.ninja.txt  Õ2y +pD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\build.ninja.txt  2y w -uD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\build_file_index.txt  Õ2 K 2z +uD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\build_file_index.txt  2 K 2z x -vD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\compile_commands.json  Õ2 ~ +vD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\compile_commands.json  2 ~ | -zD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\compile_commands.json.bin  Õ2 +zD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\compile_commands.json.bin  2   -D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\metadata_generation_command.txt  Õ2  2w +D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\metadata_generation_command.txt  2  2w u -sD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\prefab_config.json  Õ2  ( 2| +sD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\prefab_config.json  2  ( 2| z -xD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\symbol_folder_index.txt  Õ2  o 2 \ No newline at end of file +xD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\symbol_folder_index.txt  2  o 2 \ No newline at end of file diff --git a/android/app/.cxx/Debug/626b5o2n/armeabi-v7a/configure_fingerprint.bin b/android/app/.cxx/Debug/626b5o2n/armeabi-v7a/configure_fingerprint.bin index ef82628..a09f5bf 100644 --- a/android/app/.cxx/Debug/626b5o2n/armeabi-v7a/configure_fingerprint.bin +++ b/android/app/.cxx/Debug/626b5o2n/armeabi-v7a/configure_fingerprint.bin @@ -2,27 +2,27 @@ C/C++ Structured LogO M KC:\dev\flutter\packages\flutter_tools\gradle\src\main\groovy\CMakeLists.txtC A -?com.android.build.gradle.internal.cxx.io.EncodedFileFingerPrint  ȕ2 2 +?com.android.build.gradle.internal.cxx.io.EncodedFileFingerPrint  2 2  -D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\additional_project_files.txt  ȕ2  2 +D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\additional_project_files.txt  2  2 ~ -|D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\android_gradle_build.json  ȕ2 2 +|D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\android_gradle_build.json  2 2  -D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\android_gradle_build_mini.json  ȕ2 2r +D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\android_gradle_build_mini.json  2 2r p -nD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\build.ninja  ȕ2 2v +nD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\build.ninja  2 2v t -rD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\build.ninja.txt  ȕ2{ +rD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\build.ninja.txt  2{ y -wD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\build_file_index.txt  ȕ2 K 2| +wD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\build_file_index.txt  2 K 2| z -xD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\compile_commands.json  ȕ2  +xD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\compile_commands.json  2  ~ -|D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\compile_commands.json.bin  ȕ2 +|D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\compile_commands.json.bin  2   -D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\metadata_generation_command.txt  ȕ2  2y +D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\metadata_generation_command.txt  2  2y w -uD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\prefab_config.json  ȕ2  ( 2~ +uD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\prefab_config.json  2  ( 2~ | -zD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\symbol_folder_index.txt  ȕ2  q 2 \ No newline at end of file +zD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\symbol_folder_index.txt  2  q 2 \ No newline at end of file diff --git a/android/app/.cxx/Debug/626b5o2n/x86/configure_fingerprint.bin b/android/app/.cxx/Debug/626b5o2n/x86/configure_fingerprint.bin index 8c72b90..d341321 100644 --- a/android/app/.cxx/Debug/626b5o2n/x86/configure_fingerprint.bin +++ b/android/app/.cxx/Debug/626b5o2n/x86/configure_fingerprint.bin @@ -2,27 +2,27 @@ C/C++ Structured LogO M KC:\dev\flutter\packages\flutter_tools\gradle\src\main\groovy\CMakeLists.txtC A -?com.android.build.gradle.internal.cxx.io.EncodedFileFingerPrint  ͕2 2{ +?com.android.build.gradle.internal.cxx.io.EncodedFileFingerPrint  2 2{ y -wD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\additional_project_files.txt  ͕2  2x +wD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\additional_project_files.txt  2  2x v -tD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\android_gradle_build.json  ͕2 2} +tD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\android_gradle_build.json  2 2} { -yD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\android_gradle_build_mini.json  ͕2 2j +yD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\android_gradle_build_mini.json  2 2j h -fD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\build.ninja  ͕2 2n +fD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\build.ninja  2 2n l -jD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\build.ninja.txt  ͕2s +jD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\build.ninja.txt  2s q -oD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\build_file_index.txt  ͕2 K 2t +oD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\build_file_index.txt  2 K 2t r -pD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\compile_commands.json  ͕2 x +pD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\compile_commands.json  2 x v -tD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\compile_commands.json.bin  ͕2 +tD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\compile_commands.json.bin  2 ~ | -zD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\metadata_generation_command.txt  ͕2  2q +zD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\metadata_generation_command.txt  2  2q o -mD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\prefab_config.json  ͕2  ( 2v +mD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\prefab_config.json  2  ( 2v t -rD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\symbol_folder_index.txt  ͕2  i 2 \ No newline at end of file +rD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\symbol_folder_index.txt  2  i 2 \ No newline at end of file diff --git a/android/app/.cxx/Debug/626b5o2n/x86_64/configure_fingerprint.bin b/android/app/.cxx/Debug/626b5o2n/x86_64/configure_fingerprint.bin index 74afe9a..51aff7d 100644 --- a/android/app/.cxx/Debug/626b5o2n/x86_64/configure_fingerprint.bin +++ b/android/app/.cxx/Debug/626b5o2n/x86_64/configure_fingerprint.bin @@ -2,27 +2,27 @@ C/C++ Structured LogO M KC:\dev\flutter\packages\flutter_tools\gradle\src\main\groovy\CMakeLists.txtC A -?com.android.build.gradle.internal.cxx.io.EncodedFileFingerPrint  ϕ2 2~ +?com.android.build.gradle.internal.cxx.io.EncodedFileFingerPrint  Ұ2 2~ | -zD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\additional_project_files.txt  ϕ2  2{ +zD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\additional_project_files.txt  Ұ2  2{ y -wD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\android_gradle_build.json  ϕ2 2 +wD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\android_gradle_build.json  Ұ2 2 ~ -|D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\android_gradle_build_mini.json  ϕ2 2m +|D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\android_gradle_build_mini.json  Ұ2 2m k -iD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\build.ninja  ϕ2 2q +iD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\build.ninja  Ұ2 2q o -mD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\build.ninja.txt  ϕ2v +mD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\build.ninja.txt  Ұ2v t -rD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\build_file_index.txt  ϕ2 K 2w +rD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\build_file_index.txt  Ұ2 K 2w u -sD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\compile_commands.json  ϕ2 { +sD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\compile_commands.json  Ұ2 { y -wD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\compile_commands.json.bin  ϕ2 +wD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\compile_commands.json.bin  Ұ2   -}D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\metadata_generation_command.txt  ϕ2  2t +}D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\metadata_generation_command.txt  Ұ2  2t r -pD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\prefab_config.json  ϕ2  ( 2y +pD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\prefab_config.json  Ұ2  ( 2y w -uD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\symbol_folder_index.txt  ϕ2  l 2 \ No newline at end of file +uD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\symbol_folder_index.txt  Ұ2  l 2 \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 78264b8..1907d87 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,7 +6,7 @@ + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 56d485a7429a4a0ab78b819489bf52903e728aab..c1eb3af00ad765192e7eced53c78f8d7fb5f69ea 100644 GIT binary patch delta 5063 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 diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 804e91b552ece660f0c6e60cf7e86c1249012e82..024c64f755958210546bb272f6e318b2ceda26fa 100644 GIT binary patch delta 3335 zcmV+i4fyizAD0`De}5hXDI3GnL`?ty0~dN!SaechcOY$^9#&jA8Cx;8$(pR}ZF@W(8#~@lrtzk@6kR3h>VHWEvehQi>a#_b~ZL#JOYZM-UMr{imGVcp-;y`kkV#t`xg;mvF z%wUbM7^bQ#t|T*5H}4;A4k>Q7LC|@VEK^)~O2W7^?(6P=S8{Q15#GJ>8XmCTQKXsE zKmr(`27$9po_`=eA%+HI$2uV_hED_%$k;>u3S4tX3j@aQ(RbI)G7X_HcKB(?;ASht zz#aq<7B@*x#=?~>7Y8$G7kw_DgDFU?=dp5L1KMeX6~#k{1`0(Tkt1tSCEVGFa2d$T zK=Cwp#iGGm0Y?a^pwJpvMmD4{N)6dsMew`ucBeM9o_{Xr)<;3Dt4`f~76($rv!O^c z98{ERwQ#7yX8ma*2#KH$@JjJumVslkPzrUs!E|ynLZ%x$sILP}iBR!1+$Uhuz=x+I z^)+m|s(wZ)`Va{K{q-$1!Ruj;J{LTvp0{)I&jk}JbXPMsyqv1-Y;rUXojd3I9X1+_ zKI$+C%74vVNWDy#F%v=#cNFA;!ou{43Gmic`Px<8=p1!%&narS@N znt9;tBPVJbb%zAO0)=M{Ut(br!1d8RW0>*^v-xlNx|cOIk&E9L>N1^h&nHMd?}HKsaBGopz3&Wy69?h##u zjX0~@tdpXKe1a;9n4`C1GGIr;4v|5@8dR1l1#hdx(E_nz7YWM{3RY0ZS_47vzc3O_ z5Pt`UWgqsk_5|Fbrvd-#nL11DhYD$Qync6V*%fZiPGye)jm!J7hyDPRhA?UgD8^s_ z00^^5L_t(&-p!gxjGRXm$A4Ad()%*IduD9MmhIS1927$ABtpW1D2N1sf&@qi35i8P z95`_ypok0P&LI#`xWwWRBqAgT2#G8bQGbFRK-eaZ6XPYG8IOBi!?U(fWk zdlo15OrG?qzphtR-~YXORrOxI65Iq`^L4-C3~&&505}TV2}}S3KrE`$1{Q&7;5={= zcoTRBsP=YPaKxrDZMtR;cmW&$9s?c)jsQb|waM;6U>-OFyafCSco$gy$YzDuuzyw` zt=|oN8+aVpwP{tYPwxZ22c84Yb~82-WL<6#_+j9Q=-GLTfcMf>;MwRYZAOSSIX&PX z0DcO5`ZfXYrN03`#>|s-h3H$_1zrGO1D@vgX?-oa3j7fGEz#WQCxl!Fyajw8cxD^G z6MgfU2!qvwZJAby)Kx)74SxnCwtoS9H#LDLfu92kR*%H;TaR>KkEG9RGx!cwf$sq) zIt3jeWS`0pVQl`R0-gxV)4+qh3<0aOKLWe}9N%ulLIApJXEXFF@D*SV%qYkn(|KaM z!Ak+{KrvfsQEdmk%jEG0BVZISx&%0a+14kw3%u5_*zmYAU+2FwOJox^pMTsp!cf{p zuju(I#)UZpD|0{q-`Yy>LaaP8QnS*Uv(pud)i!%aQcN#4xm2q6Hr*Lv6##ZrcmVj) zR%&~(;WM+?q~QnT6E1s4Qb-}lB^-v*E(0l-Ak^fOPQT`l1HS;ycShhbwrH~w0!ppnHYP9(=60I&P-RZB!9$Ri{W&Pgkv$9jk9++-EBS{+J*6gkw6;w+*WDc4>je6 z&;HR2JF+o`(=JE%3=)KjYAXN@3CCit+GZ>pL&`O~dK5_8z#-tCEzv&E^g_H)VOs(f zX;uozC0xo)pFk;ufR-PUPq_WG-=TYeLlWa@4sC@18gOk%sn(|Hg@4Fs0tZS{ZTS?d zEk-kO7V19vq>Clj?QRG-CV~6ed?fXuDeg#i4kUT&@&eQ47R73te_mN+uG%IScNopa zs5CwDNoPIn6Jb8uF`+FZtzqv-id4+zQmICz>7$fp-)IKgl1wc$Q5qaeqBrjDkj*x1 zT=jN9t>u%6JB*}bjDO|gTrSl)JymAkXc{RX3^l@GBOxYiAh(s^LuFQ>LOwyz zIi62Ys0YRv- zq+qVvrdVxp_e5?Z0o0oF+z%DCmQT|&4ppc$wYI-5z-r4U2!B;yp4QL~LM$ne0$N^3 zwdJF=Mhe5XBA+6kbf|kFUZB{BY|Nq4s;El}cTB#C<#SoG;cme&;B& zOKt9&$YWa)rGGSWTatEdiq$qd2NKKsMIUP0K$$*a` z*D4%8FwEp&lJ~AJ@y3NBFP$y&jfZxViaES>d4XMnDSz(WJ;=pUgYjI##0LGTY-5i5 z3OCrR5M&c^_7w)nCyPui)VW^s2}8w&*)on$jAvagUoTT`cr;ob0!?YDMX_FE-*A$X z7fVd$V_YmX_`;DLG<`+W3y8Z8p@Mgdbso4Q&v43SJnM4u(gI7>IxkI^IXs@lwI$b< znoQ=`9Djvr37qS1vzm*g8h<%EL#634Igmst;~f^VG0x4_IX}C^T-^sDXm}w?YZ8v6 z-0=9vZ#BSi=`$C6VP};$iC4u zSLPdd!J0O7&icVqKbZhNOV3KARLEkjg&%}`=6~QAyN5F*97!RUV6owIsF2~vWDY-2 zOym?+5+}jP{HHLTeBLAw;yCX#@hL6hfME zrGHd3p*x1!nc1do^s`bP{VtaafzWz+b<GQ}LpV%;b1NMr<3qj4=sx#{Cr64#OhN|B1$%vIZ@V%Bn9+Y%%k zi&{It3l(wO^sBT+MvAC3eX?;IFVwh}V1Fz&O10nXV#4HFAL+|6zIA@em4+cLwg zHF$vml!@ESR+>1LB-EN(D|Lemi~F+5s_J;zT}l$EZ4>d$j*P@CaQY<-5R( zy&pBZrg?-gJLO6sZr)v6l8xKue^=Y?r&HEx*WGWYJR7%hZMmxd4zJHX@M45{dF9cV z^XCP7IyZ$(?MB>1wFuMey^Li6dQ9ng;3-UUVY{grVR^nE z)>R|W#ZZ`x$dmL*L~kKgStmOK8z=cBD@nes4*sWDN%Hjt-vM|7^^C*`On*}0w$-xOLPwb-vPc0?7mIFuK>RUevP?`zziqli4PxtGcZ&z zG&(dfIx;saFfckWFp06Ar2qf`7<5HgbZ}&5Ize@GXm4@=OK4$cX=G?1W_4j?X*sC4wOkmK~!kot(tj^UG;UxKfmAI-u`CZEZ80| z*kcUX0c@xVv0Z{FiwKP(Xw|4CrAkSI(oHH#>?%>zs8#cakDTt({K@oz~ zjnf(numc!~-Fob?4P%eT9*<{#>wWj$-{~Ls&3I<~-i&ED(tp)k?(f}mzGwTL-#Jh5 zAxH*}fC*SiJE2kks>hhGOb!Nh=6h62=EH< zGRSdIGPB_1?thMt2EaVQW4ZzO0`LH^3LuCS!)1#LWsCDAo1>!+hlV}+$6coD3e^C! z2%QCf1U%aSHA_nYS@f|Bgzo|#ZCoo`K1m6PqUdXmj=KEhREoU=NzRun6rh#{@^s_6 zcqJfOK2G`$fYx9+pw%|uuYo)GFr+{P`o|r9cr?RHCx6n6)il=9j=K-|8t}$aAeuf( zUj27Sq~PlHnsWJbAm1bo%>N%L5CcCypWr)(avU5=AS~&>XMwK*&jF?t2y5XN|MuJg z5>`aJQ?$8w@i9!a8{6*0viN04DOi~cxnp$|V-!b6T|%Q4bEzC~7f=P>225f5F2-Nq z-5S99oquC2Px^FZv|eeI9@9jBNv)QmT7f76+vb-Al5~Vm^!TJ4;hkZRAX*fJ2iy*n zK)ngbLJ+gktlv2XKryuW!3W*Hd%BX^-RTCYF3(33LaheX@<#$hE7*LM4_a|(B#AML zE_Z-!z#woKU@nMRDGJ+n0Wev2wnd_zJUX52+VjgpVeIS{165IKW-6 zIZH81rW>VTV^_ebdWJI<4~Yy&%jF)h75I4r$b0}Z+9ytxPXRY71vR5NI-TWbBVC** zWym`bS9x_T31c9(a~TK`f$=d^tqhhOUoFDbwG5&)2d3JvTniHlCTfWy2lNtee3cn1 zSbx1=8JnxD$AI6T)l$RUHKXa9&a!u;lM|&3Ytl8k+!}--q|&i98b3HRnr825j}2VF z@uwjOVs%>@lwM#47_Zs<^n4C1jgzntBSb+I9Xoc-+`Iw!n+DQ_ ze9#Jfqd8W}aU|?wOWQP^x&)2~ZUTTv6n{_kck}$Y6`ZQ)NDis5gD$iK0g+ms~(;MKbT=BwHqll?_=HmjK8H;D14u zAUk1^$T?VAc(c^Wv05AWRq$(oLMdTOdlg3smJ)np(O=6`H5TZ`HHXT9=J0rG-gg2R zOXKD}l&y)Rv5U>82Y?N80B}Ez!mOqCwIY#oP&zKoCPSCK(<{IjC>0y56}{OSX~)E$ z6pV%*Q;{2Unwy!F&^MLfMA1Vxihoc6)`T_wAh^gauFs0R=JUA{_ob>Vh@MrzuLIaX z7WmXs+ILenws%Q`O2Pi=Wqh{%eO6S*{6VKY=7bd$c-}^ zm{~CQz0wLdZO;Rw5zf|A>?0&G`fISY&k7*YJk)PG9u%Ma3CDMB`fW!c;u4RX_&K5G7Jq`No6svgYSeV7C9 zK)sGZXg_|Ao{iTsoWCARVlTH44kt4FDA~!w;}@YLLBEsY`;{!w9Di?60l3X7Y~S_A z0-GOVf}QQt_?E@BPRQ2_lQ;?v*Im4fGtYfQVi!^hCL)h@i4q&!aeuH~NTx6+2aus* z>`ED#EWtzx^Y$+w2;)6NX%bTBNOnDa*>&hSTd+a#zh0VJ1Y?2b32zyFbqBe8Z_vQ_ zj06IZtu(3QcEP zv86|oaTOp;M^2n7O$5~nW^|meT18rvnW7YXy0>w`Eua>u;AR17)v7pbvxzG`Lf~25 zL4WAMsA4~9BEV_178EdE752Sv*tSOV+JIrICTIm!V{@q7$#T!<;>jtx28Xeth^SJ* zPN(NFx5V&XZhr$Wb>BV*kOl-np~Yb4)J$3oO2rmv0@bw&+jo7VVQ37BlPbX}yxBCW zZH=3@Q4Jyd2!^JGl7Hzb2F2k@fme&m`0-c|9h2u+KRtkn;#km*11c77IMK=Dy$^FL zy$X$>#IW^)y?o}(e_||$^QmQQnmEp9PrXQ2b&P@B8h;{D)XO1qIRdeXn@)*Y4K2Va zMrN}P#Iaeb&o7V_P&PLGl^kfrlbaqP5&7&me-Klv5}F7%k>Eo5D!#S$bG)0o4lM>z zifna?dk*~rzZ#8j*YH7JUVRt8Hng9si)R>0FX!FP8#(KCHxnMfw~0Gu%Ub#$Le_yw zBeBd4vVZyOKq&!)kz^;2-}EO8rI&N}*?lC#D(`x0`Q9}Taj0$6tlukvy6rLT<+(UM z$g1inzt{f*03u~E?PjUjiN;~4DS|1RxZT$jfB>u8G>>k#dGam8WOeC)oAKL;L>o_S z{5(HdeHWRa%s_TEQ*LfS))A_1hPSV|pWfGwkbkaL=}4sz;y~Zqa|aV%9w`N_EHGpf z_bP8*c*h7_x7^|@_odjp#vWUq_yyhMd2>qxPn?9?seb*}BZWiv;3j6^4P*``B&GUy1rzhggtEshw z*?)VM2G24iEg@w^c*?4SbE!@Rk22mf@_7a2RN-$h?h@?@ZDu0C?1>`HEOefk2oCHdQdL^VX<~L(I0({yg zZgC$0?!3&r3Mzr{dVfGNC#2dh??Q+`cLu(3m%}Z~G}dSd{K|Y&F34<=(F|8@_Fu^I z!s&JfN-hc^(l%q+6(}(%5yS}fa!3n00vy%EhvZjU`f|f1f=Yy6GZ>j400!tuM}K@Q zGlf;W5SJ4(zm&%ABWm7dDWNAF@Z0Og`PSA!9#}m|(lQtUH78DjYP6XDzXFu3Mn7l* z_zdw-qA4X*#JwvgNvCZ_eS3DJQwpYP&_C>RW-R1f*&RbKAU#ZCz#7 zX6l@+x{UhqxkCa&b+V2$Z?g;nUw;S2)cT!cpil^Z3;b!b)kYL+QWc)RqYt~gi|0;v z@#5)rrUDB^oRwxQV=2MaLdrI@xglMNkd65o5BH9db6RQ>lrU0t`Qbo2FAn4x8jdiv zmhSUy5&ldmWX1vjL%^qL(pEu%qv8IpAy(!?wyrR2YO4~7q8w=gqc5G{8h>B{qwtNU z7HS+z*w$4hWiQr4RHPllt(|2yW~&TODu#+Skz7{Codv$E6c+&O2o;wPiX#Y51Ao;- ze#Yw#?@i{oW@-fOCb_jsxVfXkaMfY3>@Zq$s6-k?to^(j(cy)3coA(*h-Y2FCMYql zTbEV2?rxtKj;HyT!&wH4_J7=Q)zdVlIMP_Zu(kNFG#O+>qPVkrlqWVHCXsfb&<&n< ziSr_wy@HCl>CXU;jXFH@UXGuBkfIu@eF}by#`Nao?vA)OI{y0ZndWW`*{R7`VR zNfT$JZvx4=Ym6vvX`kdTHukeAKNY)&vL>F;d=2=v5sV)x zQX4kU-kjI#o8wACJb#QX0o!Su+Gcp3t0p-x-cHIAu1;5RRS>rqD`luwA*{!P5Q)p{ zI0mFqFq!yX7(l%q&l0rN>d06m&i8*0L`s3XeoJc$w6MfO4NJ^x{2Ebsmf_=t34WvJ zJhyd>)9KbhnRv%lDn3j%+9~tAVPdUnSy))9Ct2@A%9|5{yvTNs9Lw>GFe+X z?Hmvgk9eAnStC(++ORHDVQc#&x3o{vn<>-f)=AqTj*{5}OoYOZG{w-yh@#W0leEJa zh~vf}(pgYy9}C8;&4gbRKoby)4!VuRVmeyL+93tEPP-eBwj&&65CPvpEOZNROK z_bY%lU?wVkV2U^}j{$EI|J|k!D5mU)$G7xzvY6rzKXwsy>%CVj)Bgc-c`B3UV-;7E zX$%|{GBH#zF*-0XIxsgYFfckWFoq`a#*>;192GJ#R4_3*FfckWH!CnOIxsNzdr5ke z%M2ezGBH#zF*-0XIx#UTFfckWFb@=60RR918FWQhbW?9;ba!ELWdL_~cP?peYja~^ ZaAhuUa%Y?FJQ@H1002ovPDHLkV1ipTJU;*c diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 802249c09b88d8e7a10a21e4fb67b3619c326adb..251699aa5494417bd2086c7f6844424229099392 100644 GIT binary patch delta 7111 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 diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index a7bbc6e754be60305ead42660d6945bd9bd295b8..24771ddf9028460944e30ee03e14e5cc8c5a54a6 100644 GIT binary patch delta 11638 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 z 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 diff --git a/assets/images/logo-disalurkita.png b/assets/images/logo-disalurkita.png new file mode 100644 index 0000000000000000000000000000000000000000..003d99cca7cb969aeeeb6338824673f4a0719907 GIT binary patch literal 42480 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 z z0zz9(=}AvZ51xC5o4SC~y7ipY(au3XsO891^@6wc8f=>1$~RP0E;#y+69qp7;)P$` z_JU;#3!Jdt-zOwV8BlPk7*~D^Cwle4N%^Ky2Bt>%1K`16gNs zoZ$uJ?%=Q2Dfu8Z&A4C3r~Zv#az&v3DR}b#JkXLnJFmHUs8M#e*3T0O{G>m15plh~rtXyl0Ij+y4jOG3785%@UFbs4 zJ(mpt<%a8=%4|jf>-?w^6#t$^Es0xQBkmi4@8Z<5NT|*n%u_ixP4K6F{;k4p-3;E! z8UQ#ttj2b}Hhy~6JI3r%IsdvSO^EL*Wm)G=g6Q8MHo3yG8k;;eQ{hHR0S770VLL>e zcd(1excy9-L?Lr)4$X3|Crf2rX*O#<7umNvEWj-dvVTn*j@_xauCIKZbRQ=>z zg~Raz{@-8~K_g54tPuLE zr&jZal4Uy!gFDaeg#JDfS_>#vIPt*=yp00Usny9PH70vHzB+87#c7|Ml?+%S0PP$& zwZAmZh`|*S&`Ue6={Yz_-!!gOcECAYW%KjJ`va_9^8R^Fk!R&)15L-sgGA!gdKQzY1sht3Z+u3FsQ?vCP3WCOy^p827)^B@a_&p`!w|~#M zj;VjKqkPZ;d3L@jOL;U8f>j`iUq?Tb{?O@M?q?L3&-cNhvYij;2^muV1Ava8g5nBC zv;(qrBvW#4@2E3MP)LYBB2Ft7+R8UV78(4t_ZY1GZc!w;dDs)XvZ+7BU4LCnoZ5k> z!r}s72P=K^#n~60X*nHg;*o9p1$w(b9J257?E_6@uAU7&uD!bG2?8yY zz^6#;{!XmU4d#w@BoP+;$Q!+B?~-5M4)(34-c-*dQ2xajvN_C0>k;s)e4>)Fz5JEa z;_yH{(n)vg^?l>fRJMGTJ6`vvrl)bVp|eXgwpZLdwcQv=I;vk?1H^ntfPxf0^wjCh z7Cc2=54x${m?D1ZRKD_i4}DBn?l4>P^ak?|z4NJI^~~uVht|gP+0{{_>*U{lne#(2 zx7Q%2@@)2Aa1(O;c&_iAjWl?u+Iix4#+sja*1a}+LV^1)U_|>aBYH>g$gNaovfUG!NFdMzdy(-q#&sUL3{S|@obRt<9^0dM*gJ}aN79Imjvj&XiLp%2PF zD&N<$ImF6naH+iR8!o`ex+urt58~~?%(eq=HDH}1iRy8ey%gs#7AmV z7v2fWJyMyum8EOLB95M>6M;$EhnUqTXFcVTPqzO{OLI+PkhG0zvIWQo)|U{MyMa)R?mO_zR~}pADwTW)LFlq zf~*EwcvIWz?iw~Mh~!~opT;kArm7?Nd-aUAxzI%iQV-qk1lx?sn4N=&2t`7>X~gtd z5}8>eJ6VkCT+{8_J^HsiBMV=TUVDwr0PBFuC)PC!u+yX^3^=bCyq zsI^}1>+eZD(-Ee<`pj^ThPmn=(aM(=dAghC8hL=r7D3`!&?($levY%uTl+2N z%VwygcP1shIX%;JU1M%&@1_x$e%$t+#*$e)ay?*j2HtvUS)7I>cpC0t1`dWVaA=q;5hxoLr1blsVlpP4N!-0-tJ1lWI-9I8Rs6wSvI+ZZrqN{ zjUn_O32D%w4lLBnN>CTHEnL$OyZ@am&ZK;=+i7%U7CNWW57jAkP@r?S(mW#~(k4D( z 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 Seh9?lAbA-T=_)Cc!2bcuV8!?V literal 0 HcmV?d00001 diff --git a/assets/sql/README.md b/assets/sql/README.md new file mode 100644 index 0000000..fa59761 --- /dev/null +++ b/assets/sql/README.md @@ -0,0 +1,108 @@ +# Fungsi SQL untuk Supabase + +File-file SQL di direktori ini berisi fungsi-fungsi yang perlu dijalankan pada database Supabase untuk mendukung fitur-fitur aplikasi. + +## Cara Menginstal Fungsi SQL + +1. Login ke dashboard Supabase untuk project Anda +2. Buka bagian "SQL Editor" +3. Klik "New Query" +4. Copy dan paste isi file SQL yang ingin diinstal (misalnya `batch_update_jadwal_status.sql`) +5. Jalankan query dengan mengklik tombol "Run" + +## Daftar Fungsi SQL + +### batch_update_jadwal_status + +Fungsi ini digunakan untuk mengupdate status banyak jadwal sekaligus, yang lebih efisien daripada melakukan update satu per satu. + +**Sintaks Penggunaan:** + +```sql +SELECT batch_update_jadwal_status( + ARRAY[ + '{"id": "jadwal-id-1", "status": "AKTIF"}', + '{"id": "jadwal-id-2", "status": "BATALTERLAKSANA"}' + ]::jsonb[], + '2023-01-01T00:00:00Z' +); +``` + +**Parameter:** + +- `jadwal_updates`: Array dari objek JSON dengan properti `id` dan `status` +- `updated_timestamp` (opsional): Waktu update dalam format ISO 8601 + +**Status yang Valid:** +Berikut adalah nilai-nilai yang valid untuk kolom status (enum `StatusPenyaluranBantuan`): + +- `DIJADWALKAN` - Jadwal telah dibuat tapi belum aktif +- `AKTIF` - Jadwal sedang berlangsung +- `TERLAKSANA` - Jadwal telah berhasil dilaksanakan +- `BATALTERLAKSANA` - Jadwal tidak terlaksana atau dibatalkan + +**Contoh Response:** + +```json +{ + "success": true, + "updated_count": 2, + "success_ids": ["jadwal-id-1", "jadwal-id-2"], + "timestamp": "2023-01-01T00:00:00Z", + "errors": { + "count": 0, + "ids": [], + "messages": [] + } +} +``` + +## Cara Menguji Fungsi + +Setelah fungsi diinstal, Anda dapat mengujinya dengan menjalankan query berikut pada SQL Editor: + +```sql +-- Pastikan status yang digunakan sesuai dengan enum StatusPenyaluranBantuan +SELECT batch_update_jadwal_status( + ARRAY[ + '{"id": "534cb328-1fd9-4945-8642-c99b8e1acb2d", "status": "DIJADWALKAN"}' + ]::jsonb[] +); +``` + +Ganti `534cb328-1fd9-4945-8642-c99b8e1acb2d` dengan ID jadwal yang valid dari tabel `penyaluran_bantuan` Anda. + +## Membuat Enum di Database (Jika Belum Ada) + +Jika enum `StatusPenyaluranBantuan` belum ada di database, Anda dapat membuatnya dengan query berikut: + +```sql +CREATE TYPE "StatusPenyaluranBantuan" AS ENUM ( + 'DIJADWALKAN', + 'AKTIF', + 'TERLAKSANA', + 'BATALTERLAKSANA' +); +``` + +## Troubleshooting + +Jika muncul error: + +- Periksa apakah ID jadwal valid dan ada di tabel `penyaluran_bantuan` +- Pastikan format UUID benar (harus berupa UUID valid, bukan string biasa) +- Periksa apakah nilai status valid dan sesuai dengan tipe enum `StatusPenyaluranBantuan` +- Pastikan tabel `penyaluran_bantuan` memiliki kolom `status` dengan tipe data enum `StatusPenyaluranBantuan` dan kolom `updated_at` + +Error umum: + +1. `column "status" is of type "StatusPenyaluranBantuan" but expression is of type text` - Ini terjadi karena kolom status memiliki tipe enum, bukan teks biasa. Fungsi sudah menyertakan cast ke enum. +2. `operator does not exist: uuid = text` - Ini terjadi jika ID tidak dikonversi ke UUID. Fungsi sudah menyertakan cast ke UUID. + +## Menambahkan Nilai Baru ke Enum + +Jika perlu menambahkan nilai enum baru di masa depan, gunakan SQL berikut: + +```sql +ALTER TYPE "StatusPenyaluranBantuan" ADD VALUE 'NILAI_BARU'; +``` diff --git a/assets/sql/batch_update_jadwal_status.sql b/assets/sql/batch_update_jadwal_status.sql new file mode 100644 index 0000000..15a6fd4 --- /dev/null +++ b/assets/sql/batch_update_jadwal_status.sql @@ -0,0 +1,80 @@ +-- Fungsi untuk memperbarui status banyak jadwal sekaligus +-- Penggunaan: +-- SELECT batch_update_jadwal_status( +-- ARRAY[ +-- '{"id": "jadwal-id-1", "status": "AKTIF"}', +-- '{"id": "jadwal-id-2", "status": "BATALTERLAKSANA"}' +-- ]::jsonb[], +-- '2023-01-01T00:00:00Z' +-- ); + +CREATE OR REPLACE FUNCTION public.batch_update_jadwal_status( + jadwal_updates jsonb[], + updated_timestamp text DEFAULT NOW() +) RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + jadwal_item jsonb; + jadwal_id text; + new_status text; + updated_count int := 0; + success_ids text[] := '{}'; + result jsonb; + error_ids text[] := '{}'; + error_messages text[] := '{}'; +BEGIN + -- Loop melalui setiap item dalam array + FOREACH jadwal_item IN ARRAY jadwal_updates + LOOP + -- Ekstrak ID dan status dari item JSON + jadwal_id := jadwal_item->>'id'; + new_status := jadwal_item->>'status'; + + -- Konversi ID string ke UUID secara eksplisit dan status ke enum + BEGIN + -- Update jadwal penyaluran dengan cast eksplisit ke UUID dan StatusPenyaluranBantuan + UPDATE public.penyaluran_bantuan + SET + status = new_status::public."StatusPenyaluranBantuan", + updated_at = updated_timestamp + WHERE id = jadwal_id::uuid; + + -- Jika berhasil diperbarui + IF FOUND THEN + updated_count := updated_count + 1; + success_ids := array_append(success_ids, jadwal_id); + END IF; + + EXCEPTION + WHEN invalid_text_representation THEN + -- Log error jika konversi UUID gagal + RAISE NOTICE 'Invalid UUID format: %', jadwal_id; + error_ids := array_append(error_ids, jadwal_id); + error_messages := array_append(error_messages, 'Invalid UUID format'); + WHEN others THEN + -- Tangkap error lainnya + RAISE NOTICE 'Error updating status for jadwal ID %: %', jadwal_id, SQLERRM; + error_ids := array_append(error_ids, jadwal_id); + error_messages := array_append(error_messages, SQLERRM); + END; + END LOOP; + + -- Buat hasil dalam format JSON + result := jsonb_build_object( + 'success', updated_count > 0, + 'updated_count', updated_count, + 'success_ids', success_ids, + 'timestamp', updated_timestamp, + 'errors', jsonb_build_object( + 'count', array_length(error_ids, 1), + 'ids', error_ids, + 'messages', error_messages + ) + ); + + RETURN result; +END; +$$; \ No newline at end of file diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index edb7912..1ad3550 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -51,5 +51,11 @@ Aplikasi memerlukan akses galeri untuk memilih foto bukti serah terima NSMicrophoneUsageDescription Aplikasi memerlukan akses mikrofon untuk merekam video + LSApplicationQueriesSchemes + + http + https + file + diff --git a/lib/app/data/models/lokasi_penyaluran_model.dart b/lib/app/data/models/lokasi_penyaluran_model.dart index ceaaeef..4275bd4 100644 --- a/lib/app/data/models/lokasi_penyaluran_model.dart +++ b/lib/app/data/models/lokasi_penyaluran_model.dart @@ -12,6 +12,7 @@ class LokasiPenyaluranModel { final double? latitude; final double? longitude; final String? petugasDesaId; // Referensi ke PetugasDesa + final bool isLokasiTitip; // Field baru untuk menentukan lokasi penitipan final DateTime createdAt; final DateTime? updatedAt; @@ -27,6 +28,7 @@ class LokasiPenyaluranModel { this.latitude, this.longitude, this.petugasDesaId, + this.isLokasiTitip = false, // Nilai default false required this.createdAt, this.updatedAt, }); @@ -49,6 +51,7 @@ class LokasiPenyaluranModel { latitude: json["latitude"]?.toDouble(), longitude: json["longitude"]?.toDouble(), petugasDesaId: json["petugas_desa_id"], + isLokasiTitip: json["is_lokasi_titip"] ?? false, createdAt: DateTime.parse(json["created_at"]), updatedAt: json["updated_at"] == null ? null @@ -67,6 +70,7 @@ class LokasiPenyaluranModel { "latitude": latitude, "longitude": longitude, "petugas_desa_id": petugasDesaId, + "is_lokasi_titip": isLokasiTitip, "created_at": createdAt.toIso8601String(), "updated_at": updatedAt?.toIso8601String(), }; diff --git a/lib/app/data/models/penyaluran_bantuan_model.dart b/lib/app/data/models/penyaluran_bantuan_model.dart index ce487a7..736ca9f 100644 --- a/lib/app/data/models/penyaluran_bantuan_model.dart +++ b/lib/app/data/models/penyaluran_bantuan_model.dart @@ -71,6 +71,14 @@ class PenyaluranBantuanModel { return null; } + // Mendapatkan foto petugas dari relasi petugas + String? get fotoPetugas { + if (petugas != null && petugas!['foto_profil'] != null) { + return petugas!['foto_profil']; + } + return null; + } + factory PenyaluranBantuanModel.fromRawJson(String str) => PenyaluranBantuanModel.fromJson(json.decode(str)); @@ -126,4 +134,49 @@ class PenyaluranBantuanModel { "created_at": createdAt?.toUtc().toIso8601String(), "updated_at": updatedAt?.toUtc().toIso8601String(), }; + + // Metode copyWith untuk membuat salinan objek dengan perubahan tertentu + PenyaluranBantuanModel copyWith({ + String? id, + String? nama, + String? deskripsi, + String? petugasId, + String? skemaId, + String? lokasiPenyaluranId, + String? kategoriBantuanId, + int? jumlahPenerima, + DateTime? tanggalPenyaluran, + String? status, + String? alasanPembatalan, + DateTime? tanggalPembatalan, + DateTime? tanggalSelesai, + DateTime? createdAt, + DateTime? updatedAt, + Map? lokasiPenyaluran, + Map? kategori, + Map? petugas, + int? jumlahBantuan, + }) { + return PenyaluranBantuanModel( + id: id ?? this.id, + nama: nama ?? this.nama, + deskripsi: deskripsi ?? this.deskripsi, + petugasId: petugasId ?? this.petugasId, + skemaId: skemaId ?? this.skemaId, + lokasiPenyaluranId: lokasiPenyaluranId ?? this.lokasiPenyaluranId, + kategoriBantuanId: kategoriBantuanId ?? this.kategoriBantuanId, + jumlahPenerima: jumlahPenerima ?? this.jumlahPenerima, + tanggalPenyaluran: tanggalPenyaluran ?? this.tanggalPenyaluran, + status: status ?? this.status, + alasanPembatalan: alasanPembatalan ?? this.alasanPembatalan, + tanggalPembatalan: tanggalPembatalan ?? this.tanggalPembatalan, + tanggalSelesai: tanggalSelesai ?? this.tanggalSelesai, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + lokasiPenyaluran: lokasiPenyaluran ?? this.lokasiPenyaluran, + kategori: kategori ?? this.kategori, + petugas: petugas ?? this.petugas, + jumlahBantuan: jumlahBantuan ?? this.jumlahBantuan, + ); + } } diff --git a/lib/app/data/providers/auth_provider.dart b/lib/app/data/providers/auth_provider.dart index 002a8e5..0207fa8 100644 --- a/lib/app/data/providers/auth_provider.dart +++ b/lib/app/data/providers/auth_provider.dart @@ -457,4 +457,12 @@ class AuthProvider { Future markNotificationAsRead(int notificationId) async { await _supabaseService.markNotificationAsRead(notificationId); } + + // Metode untuk reset password + Future resetPasswordForEmail(String email, {String? redirectTo}) async { + await _supabaseService.client.auth.resetPasswordForEmail( + email, + redirectTo: redirectTo, + ); + } } diff --git a/lib/app/modules/about/bindings/about_binding.dart b/lib/app/modules/about/bindings/about_binding.dart new file mode 100644 index 0000000..4c1271d --- /dev/null +++ b/lib/app/modules/about/bindings/about_binding.dart @@ -0,0 +1,8 @@ +import 'package:get/get.dart'; + +class AboutBinding extends Bindings { + @override + void dependencies() { + // Tidak perlu controller khusus untuk halaman Tentang Kami + } +} diff --git a/lib/app/modules/about/views/about_view.dart b/lib/app/modules/about/views/about_view.dart new file mode 100644 index 0000000..bf4c6fe --- /dev/null +++ b/lib/app/modules/about/views/about_view.dart @@ -0,0 +1,454 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:penyaluran_app/app/theme/app_theme.dart'; + +class AboutView extends StatelessWidget { + const AboutView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Tentang Kami'), + centerTitle: true, + elevation: 0, + ), + body: SingleChildScrollView( + child: Column( + children: [ + // Header dengan logo dan nama brand + Container( + width: double.infinity, + decoration: BoxDecoration( + gradient: AppTheme.primaryGradient, + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(30), + bottomRight: Radius.circular(30), + ), + ), + padding: const EdgeInsets.fromLTRB(20, 20, 20, 40), + child: Column( + children: [ + Container( + width: 120, + height: 120, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: Center( + child: Image.asset( + 'assets/images/logo-disalurkita.png', + width: 100, + height: 100, + ), + ), + ), + const SizedBox(height: 20), + const Text( + 'DisalurKita', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 8), + const Text( + 'Salurkan dengan Pasti, Pantau dengan Bukti', + style: TextStyle( + fontSize: 16, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 20), + _buildVersionInfo(), + ], + ), + ), + + // Konten utama + Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSection( + icon: Icons.info_outline, + title: 'Tentang DisalurKita', + content: + 'DisalurKita adalah platform penyaluran bantuan digital yang mengedepankan transparansi, akuntabilitas, dan kemudahan pengelolaan bantuan. Aplikasi ini memudahkan koordinasi antara petugas desa, donatur, dan warga dalam proses penyaluran bantuan sosial.', + ), + + _buildSection( + icon: Icons.visibility_outlined, + title: 'Visi Kami', + content: + 'Menjadi platform terdepan dalam penyaluran bantuan sosial yang transparan, akuntabel, dan berdampak nyata bagi masyarakat Indonesia.', + ), + + _buildSection( + icon: Icons.location_on_outlined, + title: 'Misi Kami', + content: + '• Memastikan setiap bantuan diterima oleh yang berhak\n• Meningkatkan transparansi dalam proses penyaluran\n• Memberikan kemudahan akses informasi bagi semua pihak\n• Membangun kepercayaan antara donatur dan penerima bantuan', + ), + + _buildSection( + icon: Icons.star_outline, + title: 'Nilai-nilai Kami', + content: + '• Transparansi: Keterbukaan dalam setiap proses\n• Akuntabilitas: Pertanggungjawaban yang jelas\n• Inklusivitas: Melibatkan semua pihak\n• Efisiensi: Penyaluran bantuan tepat sasaran\n• Inovasi: Terus berinovasi untuk solusi terbaik', + ), + + _buildSection( + icon: Icons.people_outline, + title: 'Tim Kami', + content: + 'DisalurKita dikembangkan oleh tim yang berdedikasi untuk menciptakan solusi inovatif dalam penyaluran bantuan sosial di Indonesia. Tim kami terdiri dari para profesional di bidang teknologi dan pengembangan sosial.', + ), + + // Layanan + _buildTeamSection(), + + // Hubungi Kami + _buildContactSection(), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildVersionInfo() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(20), + ), + child: const Text( + 'Versi 1.0.0', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ); + } + + Widget _buildSection({ + required IconData icon, + required String title, + required String content, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + icon, + color: AppTheme.primaryColor, + size: 24, + ), + ), + const SizedBox(width: 12), + Text( + title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppTheme.primaryColor, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + content, + style: TextStyle( + fontSize: 15, + color: Colors.grey[700], + height: 1.5, + ), + ), + ], + ), + ); + } + + Widget _buildTeamSection() { + return Padding( + padding: const EdgeInsets.only(bottom: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Icons.settings_outlined, + color: AppTheme.primaryColor, + size: 24, + ), + ), + const SizedBox(width: 12), + const Text( + 'Layanan Kami', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppTheme.primaryColor, + ), + ), + ], + ), + const SizedBox(height: 16), + + // Layanan Grid + GridView.count( + crossAxisCount: 3, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisSpacing: 16, + mainAxisSpacing: 16, + childAspectRatio: 0.8, + children: [ + _buildServiceItem( + icon: Icons.volunteer_activism_outlined, + title: 'Penitipan Bantuan', + ), + _buildServiceItem( + icon: Icons.inventory_2_outlined, + title: 'Pengelolaan Stok', + ), + _buildServiceItem( + icon: Icons.local_shipping_outlined, + title: 'Penyaluran Bantuan', + ), + _buildServiceItem( + icon: Icons.people_outline, + title: 'Manajemen Penerima', + ), + _buildServiceItem( + icon: Icons.assignment_outlined, + title: 'Laporan Transparan', + ), + _buildServiceItem( + icon: Icons.campaign_outlined, + title: 'Pengaduan', + ), + ], + ), + ], + ), + ); + } + + Widget _buildServiceItem({ + required IconData icon, + required String title, + }) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.1), + spreadRadius: 1, + blurRadius: 5, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon( + icon, + color: AppTheme.primaryColor, + size: 28, + ), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + title, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + + Widget _buildContactSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Icons.contact_mail_outlined, + color: AppTheme.primaryColor, + size: 24, + ), + ), + const SizedBox(width: 12), + const Text( + 'Hubungi Kami', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppTheme.primaryColor, + ), + ), + ], + ), + const SizedBox(height: 16), + + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [Colors.blue.shade50, Colors.white], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.blue.withOpacity(0.1), + spreadRadius: 1, + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + _buildContactItem( + icon: Icons.email_outlined, + title: 'Email', + content: 'info@disalurkita.id', + ), + const Divider(height: 24), + _buildContactItem( + icon: Icons.phone_outlined, + title: 'Telepon', + content: '+62 8123 4567 890', + ), + const Divider(height: 24), + _buildContactItem( + icon: Icons.location_on_outlined, + title: 'Alamat', + content: 'Jl. Transparansi No. 123, Jakarta Pusat, Indonesia', + ), + ], + ), + ), + const SizedBox(height: 20), + + // Footer + Center( + child: Text( + '© ${DateTime.now().year} DisalurKita. Seluruh hak cipta dilindungi.', + style: TextStyle( + fontSize: 12, + color: Colors.grey, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 20), + ], + ); + } + + Widget _buildContactItem({ + required IconData icon, + required String title, + required String content, + }) { + return Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon( + icon, + color: AppTheme.primaryColor, + size: 20, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.grey[800], + ), + ), + const SizedBox(height: 4), + Text( + content, + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + overflow: TextOverflow.ellipsis, + maxLines: 2, + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/app/modules/auth/controllers/auth_controller.dart b/lib/app/modules/auth/controllers/auth_controller.dart index c437449..f6a82ef 100644 --- a/lib/app/modules/auth/controllers/auth_controller.dart +++ b/lib/app/modules/auth/controllers/auth_controller.dart @@ -50,6 +50,10 @@ class AuthController extends GetxController { final RxBool isLoading = false.obs; final RxBool isWargaProfileComplete = false.obs; + // Variable untuk mengontrol visibility password + final RxBool isPasswordHidden = true.obs; + final RxBool isConfirmPasswordHidden = true.obs; + // Flag untuk menandai apakah sudah melakukan pengambilan data profil final RxBool _hasLoadedProfile = false.obs; @@ -376,6 +380,65 @@ class AuthController extends GetxController { return null; } + // Metode untuk reset password + Future resetPassword(String email) async { + if (email.isEmpty) { + Get.snackbar( + 'Error', + 'Email tidak boleh kosong', + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + return; + } + + if (!GetUtils.isEmail(email)) { + Get.snackbar( + 'Error', + 'Email tidak valid', + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + return; + } + + try { + isLoading.value = true; + + // Menggunakan Supabase untuk reset password + await _authProvider.resetPasswordForEmail( + email, + redirectTo: 'penyaluranapp://reset-password', + ); + + Get.snackbar( + 'Sukses', + 'Instruksi reset password telah dikirim ke email Anda', + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.green, + colorText: Colors.white, + ); + + // Kembali ke halaman login + Future.delayed(const Duration(seconds: 3), () { + Get.offNamed(Routes.login); + }); + } catch (e) { + print('Error saat reset password: $e'); + Get.snackbar( + 'Error', + 'Terjadi kesalahan saat mengirim reset password. Silakan coba lagi nanti.', + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } finally { + isLoading.value = false; + } + } + // Metode untuk refresh data user setelah update profil Future refreshUserData() async { try { @@ -543,4 +606,14 @@ class AuthController extends GetxController { noHpController.clear(); jenisController.clear(); } + + // Metode untuk toggle visibility password + void togglePasswordVisibility() { + isPasswordHidden.value = !isPasswordHidden.value; + } + + // Metode untuk toggle visibility konfirmasi password + void toggleConfirmPasswordVisibility() { + isConfirmPasswordHidden.value = !isConfirmPasswordHidden.value; + } } diff --git a/lib/app/modules/auth/views/forgot_password_view.dart b/lib/app/modules/auth/views/forgot_password_view.dart new file mode 100644 index 0000000..1310e6b --- /dev/null +++ b/lib/app/modules/auth/views/forgot_password_view.dart @@ -0,0 +1,269 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:flutter_spinkit/flutter_spinkit.dart'; +import 'package:penyaluran_app/app/modules/auth/controllers/auth_controller.dart'; +import 'package:penyaluran_app/app/routes/app_pages.dart'; + +class ForgotPasswordView extends GetView { + const ForgotPasswordView({super.key}); + + @override + Widget build(BuildContext context) { + final TextEditingController emailController = TextEditingController(); + final GlobalKey formKey = GlobalKey(); + + return Scaffold( + appBar: AppBar( + title: const Text( + 'Lupa Password', + style: TextStyle(color: Color(0xFF1565C0)), + ), + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const Icon( + Icons.arrow_back, + color: Color(0xFF1565C0), + ), + onPressed: () => Get.back(), + ), + ), + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFE3F2FD), Colors.white], + ), + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: SingleChildScrollView( + child: Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 40), + // Logo + Center( + child: Container( + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.blue.withOpacity(0.2), + spreadRadius: 5, + blurRadius: 10, + ), + ], + ), + padding: const EdgeInsets.all(15), + child: const Icon( + Icons.lock_reset, + size: 70, + color: Colors.blue, + ), + ), + ), + const SizedBox(height: 25), + // Judul + const Center( + child: Text( + 'Reset Password', + style: TextStyle( + fontSize: 30, + fontWeight: FontWeight.bold, + color: Color(0xFF1565C0), + letterSpacing: 1.2, + ), + ), + ), + const SizedBox(height: 10), + const Center( + child: Text( + 'Masukkan email Anda untuk menerima instruksi reset password', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + color: Color(0xFF546E7A), + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(height: 40), + + // Email Field + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + spreadRadius: 1, + ), + ], + ), + child: TextFormField( + controller: emailController, + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + hintText: 'Masukkan email Anda', + labelText: 'Email', + prefixIcon: + const Icon(Icons.email, color: Color(0xFF1565C0)), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(15), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(15), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(15), + borderSide: const BorderSide( + color: Color(0xFF1565C0), width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(15), + borderSide: + const BorderSide(color: Colors.red, width: 1.5), + ), + fillColor: Colors.white, + filled: true, + ), + validator: controller.validateEmail, + ), + ), + const SizedBox(height: 30), + + // Reset Button + Obx(() => Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + boxShadow: [ + BoxShadow( + color: const Color(0xFF1565C0).withOpacity(0.3), + blurRadius: 10, + spreadRadius: 1, + offset: const Offset(0, 4), + ), + ], + ), + child: ElevatedButton( + onPressed: controller.isLoading.value + ? null + : () { + if (formKey.currentState!.validate()) { + controller.resetPassword( + emailController.text.trim()); + } + }, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 15), + backgroundColor: const Color(0xFF1565C0), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15), + ), + elevation: 0, + ), + child: controller.isLoading.value + ? const SpinKitThreeBounce( + color: Colors.white, + size: 24, + ) + : const Text( + 'KIRIM INSTRUKSI RESET', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + letterSpacing: 1, + ), + ), + ), + )), + const SizedBox(height: 30), + + // Kembali ke halaman login + TextButton( + onPressed: () => Get.offNamed(Routes.login), + style: TextButton.styleFrom( + foregroundColor: const Color(0xFF1565C0), + ), + child: const Text( + 'Kembali ke Halaman Login', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16, + ), + ), + ), + + const SizedBox(height: 40), + + // Informasi Tambahan + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF1F8E9), + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: const Color(0xFFAED581), width: 1), + ), + child: Column( + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: const BoxDecoration( + color: Color(0xFFAED581), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.info_outline, + color: Color(0xFF33691E), + size: 24, + ), + ), + const SizedBox(width: 12), + const Expanded( + child: Text( + 'Informasi Penting', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFF33691E), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + const Text( + 'Petunjuk reset password akan dikirim ke email Anda. Silakan periksa kotak masuk atau folder spam setelah permintaan reset password.', + style: TextStyle( + fontSize: 14, + color: Color(0xFF424242), + height: 1.5, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/app/modules/auth/views/login_view.dart b/lib/app/modules/auth/views/login_view.dart index f7c2510..368ba70 100644 --- a/lib/app/modules/auth/views/login_view.dart +++ b/lib/app/modules/auth/views/login_view.dart @@ -10,140 +10,360 @@ class LoginView extends GetView { @override Widget build(BuildContext context) { return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(20.0), - child: SingleChildScrollView( - child: Form( - key: controller.loginFormKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 50), - // Logo atau Judul - const Center( - child: Text( - 'Penyaluran App', - style: TextStyle( - fontSize: 28, - fontWeight: FontWeight.bold, - color: Colors.blue, + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFE3F2FD), Colors.white], + ), + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: SingleChildScrollView( + child: Form( + key: controller.loginFormKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Logo + Center( + child: Image.asset( + 'assets/images/logo-disalurkita.png', + width: 250, + height: 250, ), ), - ), - const SizedBox(height: 10), - const Center( - child: Text( - 'Masuk ke akun Anda', - style: TextStyle( - fontSize: 16, - color: Colors.grey, + + const Center( + child: Text( + 'Masuk ke akun Anda', + style: TextStyle( + fontSize: 16, + color: Color(0xFF546E7A), + fontWeight: FontWeight.w500, + ), ), ), - ), - const SizedBox(height: 50), + const SizedBox(height: 20), - // Email Field - TextFormField( - controller: controller.emailController, - keyboardType: TextInputType.emailAddress, - decoration: InputDecoration( - labelText: 'Email', - prefixIcon: const Icon(Icons.email), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), + // Email Field + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + spreadRadius: 1, + ), + ], + ), + child: TextFormField( + controller: controller.emailController, + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + hintText: 'Masukkan email Anda', + labelText: 'Email', + prefixIcon: + const Icon(Icons.email, color: Color(0xFF1565C0)), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(15), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(15), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(15), + borderSide: const BorderSide( + color: Color(0xFF1565C0), width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(15), + borderSide: + const BorderSide(color: Colors.red, width: 1.5), + ), + fillColor: Colors.white, + filled: true, + ), + validator: controller.validateEmail, ), ), - validator: controller.validateEmail, - ), - const SizedBox(height: 20), + const SizedBox(height: 20), - // Password Field - TextFormField( - controller: controller.passwordController, - obscureText: true, - decoration: InputDecoration( - labelText: 'Password', - prefixIcon: const Icon(Icons.lock), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), + // Password Field + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + spreadRadius: 1, + ), + ], ), + child: Obx(() => TextFormField( + controller: controller.passwordController, + obscureText: controller.isPasswordHidden.value, + decoration: InputDecoration( + hintText: 'Masukkan password Anda', + labelText: 'Password', + prefixIcon: const Icon(Icons.lock, + color: Color(0xFF1565C0)), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(15), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(15), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(15), + borderSide: const BorderSide( + color: Color(0xFF1565C0), width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(15), + borderSide: const BorderSide( + color: Colors.red, width: 1.5), + ), + fillColor: Colors.white, + filled: true, + suffixIcon: IconButton( + onPressed: () { + controller.isPasswordHidden.value = + !controller.isPasswordHidden.value; + }, + icon: Icon( + !controller.isPasswordHidden.value + ? Icons.visibility + : Icons.visibility_off, + color: const Color(0xFF78909C), + ), + splashRadius: 20, + ), + ), + validator: controller.validatePassword, + )), ), - validator: controller.validatePassword, - ), - const SizedBox(height: 10), + const SizedBox(height: 10), - // Forgot Password - Align( - alignment: Alignment.centerRight, - child: TextButton( - onPressed: () { - // Implementasi lupa password - }, - child: const Text('Lupa Password?'), - ), - ), - const SizedBox(height: 20), - - // Login Button - Obx(() => ElevatedButton( - onPressed: controller.isLoading.value - ? null - : controller.login, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 15), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + // Forgot Password + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () => Get.toNamed(Routes.forgotPassword), + style: TextButton.styleFrom( + foregroundColor: const Color(0xFF1565C0), + ), + child: const Text( + 'Lupa Password?', + style: TextStyle( + fontWeight: FontWeight.w600, ), ), - child: controller.isLoading.value - ? const SpinKitThreeBounce( - color: Colors.white, - size: 24, - ) - : const Text( - 'MASUK', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 20), + + // Login Button + Obx(() => Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + boxShadow: [ + BoxShadow( + color: const Color(0xFF1565C0).withOpacity(0.3), + blurRadius: 10, + spreadRadius: 1, + offset: const Offset(0, 4), + ), + ], + ), + child: ElevatedButton( + onPressed: controller.isLoading.value + ? null + : controller.login, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 15), + backgroundColor: const Color(0xFF1565C0), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15), + ), + elevation: 0, + ), + child: controller.isLoading.value + ? const SpinKitThreeBounce( + color: Colors.white, + size: 24, + ) + : const Text( + 'MASUK', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + letterSpacing: 1, + ), + ), + ), + )), + const SizedBox(height: 30), + + // Divider + Row( + children: [ + Expanded( + child: Container( + height: 1, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.grey.withOpacity(0.1), + Colors.grey.withOpacity(0.5), + ], + begin: Alignment.centerRight, + end: Alignment.centerLeft, + ), + ), + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + 'ATAU', + style: TextStyle( + color: Color(0xFF546E7A), + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ), + Expanded( + child: Container( + height: 1, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.grey.withOpacity(0.1), + Colors.grey.withOpacity(0.5), + ], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 30), + + // Register Donatur Button + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + spreadRadius: 1, + ), + ], + ), + child: OutlinedButton( + onPressed: () => Get.toNamed(Routes.registerDonatur), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 15), + foregroundColor: const Color(0xFF1565C0), + side: const BorderSide( + color: Color(0xFF1565C0), width: 1.5), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15), + ), + ), + child: const Text( + 'DAFTAR SEBAGAI DONATUR', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + letterSpacing: 1, + ), + ), + ), + ), + + const SizedBox(height: 40), + + // Informasi Pendaftaran Warga + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFFFF8E1), + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: const Color(0xFFFFCC80), width: 1), + ), + child: Column( + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: const BoxDecoration( + color: Color(0xFFFFCC80), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.info_outline, + color: Color(0xFFE65100), + size: 24, ), ), - )), - const SizedBox(height: 20), - - // Divider - const Row( - children: [ - Expanded(child: Divider()), - Padding( - padding: EdgeInsets.symmetric(horizontal: 16.0), - child: - Text('ATAU', style: TextStyle(color: Colors.grey)), - ), - Expanded(child: Divider()), - ], - ), - const SizedBox(height: 20), - - // Register Donatur Button - OutlinedButton( - onPressed: () => Get.toNamed(Routes.registerDonatur), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 15), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - side: const BorderSide(color: Colors.blue), - ), - child: const Text( - 'DAFTAR SEBAGAI DONATUR', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, + const SizedBox(width: 12), + const Expanded( + child: Text( + 'Informasi Penting', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFFE65100), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + const Text( + 'Pendaftaran warga hanya dapat dilakukan melalui aplikasi verifikasi data warga. Silahkan hubungi petugas atau kunjungi kantor untuk informasi lebih lanjut.', + style: TextStyle( + fontSize: 14, + color: Color(0xFF424242), + height: 1.5, + ), + ), + ], ), ), - ), - ], + const SizedBox(height: 30), + + // Footer + Center( + child: Text( + '© ${DateTime.now().year} DisalurKita', + style: TextStyle( + fontSize: 12, + color: Color(0xFF90A4AE), + ), + ), + ), + ], + ), ), ), ), diff --git a/lib/app/modules/auth/views/register_donatur_view.dart b/lib/app/modules/auth/views/register_donatur_view.dart index e23caaf..f3aee8e 100644 --- a/lib/app/modules/auth/views/register_donatur_view.dart +++ b/lib/app/modules/auth/views/register_donatur_view.dart @@ -11,8 +11,16 @@ class RegisterDonaturView extends GetView { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('Daftar Sebagai Donatur'), + title: const Text('Daftar Donatur'), + centerTitle: true, + backgroundColor: Colors.blue, + foregroundColor: Colors.white, elevation: 0, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + bottom: Radius.circular(15), + ), + ), ), body: SafeArea( child: Padding( @@ -23,29 +31,60 @@ class RegisterDonaturView extends GetView { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const SizedBox(height: 20), - // Logo atau Judul - const Center( - child: Text( - 'Daftar Donatur', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Colors.blue, - ), - ), - ), const SizedBox(height: 10), - const Center( - child: Text( - 'Isi data untuk mendaftar sebagai donatur', - style: TextStyle( - fontSize: 16, - color: Colors.grey, - ), + // Header dengan icon dan judul + Container( + padding: const EdgeInsets.all(15), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(15), + ), + child: Column( + children: [ + Image.asset( + 'assets/images/logo-disalurkita.png', + width: 120, + height: 120, + ), + const Text( + 'Daftar Sebagai Donatur', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.blue, + ), + ), + const SizedBox(height: 4), + const Text( + 'Bergabunglah dengan kami untuk membantu mereka yang membutuhkan', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + color: Colors.blueGrey, + ), + ), + ], ), ), - const SizedBox(height: 30), + const SizedBox(height: 20), + // Step indicator + const Row( + children: [ + Icon(Icons.person_add, color: Colors.blue), + SizedBox(width: 10), + Text( + 'Informasi Akun', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.blue, + ), + ), + ], + ), + const SizedBox(height: 15), + const Divider(), + const SizedBox(height: 10), // Nama Lengkap TextFormField( @@ -53,9 +92,22 @@ class RegisterDonaturView extends GetView { keyboardType: TextInputType.name, decoration: InputDecoration( labelText: 'Nama Lengkap', - prefixIcon: const Icon(Icons.person), - border: OutlineInputBorder( + hintText: 'Masukkan nama lengkap Anda', + prefixIcon: const Icon(Icons.person, color: Colors.blue), + filled: true, + fillColor: Colors.grey.shade100, + enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: + const BorderSide(color: Colors.blue, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: Colors.red), ), ), validator: controller.validateDonaturNama, @@ -68,9 +120,22 @@ class RegisterDonaturView extends GetView { keyboardType: TextInputType.emailAddress, decoration: InputDecoration( labelText: 'Email', - prefixIcon: const Icon(Icons.email), - border: OutlineInputBorder( + hintText: 'contoh@email.com', + prefixIcon: const Icon(Icons.email, color: Colors.blue), + filled: true, + fillColor: Colors.grey.shade100, + enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: + const BorderSide(color: Colors.blue, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: Colors.red), ), ), validator: controller.validateEmail, @@ -78,34 +143,101 @@ class RegisterDonaturView extends GetView { const SizedBox(height: 15), // Password - TextFormField( - controller: controller.passwordController, - obscureText: true, - decoration: InputDecoration( - labelText: 'Password', - prefixIcon: const Icon(Icons.lock), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - validator: controller.validatePassword, - ), + Obx(() => TextFormField( + controller: controller.passwordController, + obscureText: controller.isPasswordHidden.value, + decoration: InputDecoration( + labelText: 'Password', + hintText: 'Minimal 8 karakter', + prefixIcon: + const Icon(Icons.lock, color: Colors.blue), + suffixIcon: IconButton( + icon: Icon( + controller.isPasswordHidden.value + ? Icons.visibility_off + : Icons.visibility, + color: Colors.blue, + ), + onPressed: () => + controller.togglePasswordVisibility(), + ), + filled: true, + fillColor: Colors.grey.shade100, + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: + const BorderSide(color: Colors.blue, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: Colors.red), + ), + ), + validator: controller.validatePassword, + )), const SizedBox(height: 15), // Confirm Password - TextFormField( - controller: controller.confirmPasswordController, - obscureText: true, - decoration: InputDecoration( - labelText: 'Konfirmasi Password', - prefixIcon: const Icon(Icons.lock_outline), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), + Obx(() => TextFormField( + controller: controller.confirmPasswordController, + obscureText: controller.isConfirmPasswordHidden.value, + decoration: InputDecoration( + labelText: 'Konfirmasi Password', + hintText: 'Masukkan password yang sama', + prefixIcon: const Icon(Icons.lock_outline, + color: Colors.blue), + suffixIcon: IconButton( + icon: Icon( + controller.isConfirmPasswordHidden.value + ? Icons.visibility_off + : Icons.visibility, + color: Colors.blue, + ), + onPressed: () => + controller.toggleConfirmPasswordVisibility(), + ), + filled: true, + fillColor: Colors.grey.shade100, + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: + const BorderSide(color: Colors.blue, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: Colors.red), + ), + ), + validator: controller.validateConfirmPassword, + )), + const SizedBox(height: 15), + + // Section heading + const Row( + children: [ + Icon(Icons.person_pin_circle, color: Colors.blue), + SizedBox(width: 10), + Text( + 'Informasi Profil', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.blue, + ), ), - ), - validator: controller.validateConfirmPassword, + ], ), const SizedBox(height: 15), + const Divider(), + const SizedBox(height: 10), // No HP TextFormField( @@ -113,9 +245,22 @@ class RegisterDonaturView extends GetView { keyboardType: TextInputType.phone, decoration: InputDecoration( labelText: 'Nomor HP', - prefixIcon: const Icon(Icons.phone), - border: OutlineInputBorder( + hintText: 'Masukkan nomor HP aktif', + prefixIcon: const Icon(Icons.phone, color: Colors.blue), + filled: true, + fillColor: Colors.grey.shade100, + enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: + const BorderSide(color: Colors.blue, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: Colors.red), ), ), validator: controller.validateDonaturNoHp, @@ -128,10 +273,23 @@ class RegisterDonaturView extends GetView { keyboardType: TextInputType.streetAddress, maxLines: 2, decoration: InputDecoration( - labelText: 'Alamat', - prefixIcon: const Icon(Icons.home), - border: OutlineInputBorder( + labelText: 'Alamat Lengkap', + hintText: 'Masukkan alamat lengkap Anda', + prefixIcon: const Icon(Icons.home, color: Colors.blue), + filled: true, + fillColor: Colors.grey.shade100, + enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: + const BorderSide(color: Colors.blue, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: Colors.red), ), ), validator: controller.validateDonaturAlamat, @@ -139,70 +297,160 @@ class RegisterDonaturView extends GetView { const SizedBox(height: 15), // Jenis Donatur (Dropdown) - DropdownButtonFormField( - value: controller.jenisController.text.isEmpty - ? 'Individu' - : controller.jenisController.text, - decoration: InputDecoration( - labelText: 'Jenis Donatur', - prefixIcon: const Icon(Icons.category), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - ), + Container( + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.grey.shade300), + ), + child: DropdownButtonFormField( + value: controller.jenisController.text.isEmpty + ? 'Individu' + : controller.jenisController.text, + decoration: InputDecoration( + labelText: 'Jenis Donatur', + prefixIcon: + const Icon(Icons.category, color: Colors.blue), + border: InputBorder.none, + contentPadding: + const EdgeInsets.symmetric(horizontal: 10), + ), + items: const [ + DropdownMenuItem( + value: 'Individu', child: Text('Individu')), + DropdownMenuItem( + value: 'Organisasi', child: Text('Organisasi')), + DropdownMenuItem( + value: 'Perusahaan', child: Text('Perusahaan')), + DropdownMenuItem( + value: 'Lainnya', child: Text('Lainnya')), + ], + onChanged: (value) { + controller.jenisController.text = value ?? 'Individu'; + }, ), - items: const [ - DropdownMenuItem( - value: 'Individu', child: Text('Individu')), - DropdownMenuItem( - value: 'Organisasi', child: Text('Organisasi')), - DropdownMenuItem( - value: 'Perusahaan', child: Text('Perusahaan')), - DropdownMenuItem( - value: 'Lainnya', child: Text('Lainnya')), - ], - onChanged: (value) { - controller.jenisController.text = value ?? 'Individu'; - }, ), - const SizedBox(height: 15), - // Register Button - Obx(() => ElevatedButton( - onPressed: controller.isLoading.value - ? null - : controller.registerDonatur, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 15), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - child: controller.isLoading.value - ? const SpinKitThreeBounce( - color: Colors.white, - size: 24, - ) - : const Text( - 'DAFTAR', + const SizedBox(height: 25), + + // Catatan Informasi + Container( + padding: const EdgeInsets.all(15), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.blue.shade200), + ), + child: Row( + children: [ + const Icon(Icons.info_outline, color: Colors.blue), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Text( + 'Informasi', style: TextStyle( - fontSize: 16, fontWeight: FontWeight.bold, + color: Colors.blue, ), ), + SizedBox(height: 5), + Text( + 'Data Anda akan terverifikasi dan terlindungi. Kami menjaga privasi dan keamanan data Anda.', + style: TextStyle( + fontSize: 14, + color: Colors.blueGrey, + ), + ), + ], + ), + ), + ], + ), + ), + + const SizedBox(height: 25), + + // Register Button + Obx(() => Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.blue.withOpacity(0.3), + spreadRadius: 1, + blurRadius: 3, + offset: const Offset(0, 2), + ), + ], + ), + child: ElevatedButton( + onPressed: controller.isLoading.value + ? null + : controller.registerDonatur, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 15), + backgroundColor: Colors.blue, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + elevation: 0, + ), + child: controller.isLoading.value + ? const SpinKitThreeBounce( + color: Colors.white, + size: 24, + ) + : const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.how_to_reg, color: Colors.white), + SizedBox(width: 10), + Text( + 'DAFTAR SEKARANG', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ], + ), + ), )), const SizedBox(height: 20), // Login Link - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('Sudah punya akun?'), - TextButton( - onPressed: () => Get.offAllNamed(Routes.login), - child: const Text('Masuk'), - ), - ], + Container( + padding: const EdgeInsets.all(15), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.grey.shade200), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'Sudah punya akun?', + style: TextStyle(color: Colors.grey), + ), + TextButton( + onPressed: () => Get.offAllNamed(Routes.login), + child: const Text( + 'Masuk', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.blue, + ), + ), + ), + ], + ), ), + const SizedBox(height: 20), ], ), ), diff --git a/lib/app/modules/donatur/controllers/donatur_dashboard_controller.dart b/lib/app/modules/donatur/controllers/donatur_dashboard_controller.dart index 172b716..5379c1f 100644 --- a/lib/app/modules/donatur/controllers/donatur_dashboard_controller.dart +++ b/lib/app/modules/donatur/controllers/donatur_dashboard_controller.dart @@ -7,6 +7,7 @@ import 'package:penyaluran_app/app/data/models/penyaluran_bantuan_model.dart'; import 'package:penyaluran_app/app/data/models/laporan_penyaluran_model.dart'; import 'package:penyaluran_app/app/data/models/user_model.dart'; import 'package:penyaluran_app/app/data/models/stok_bantuan_model.dart'; +import 'package:penyaluran_app/app/data/models/lokasi_penyaluran_model.dart'; import 'package:penyaluran_app/app/modules/auth/controllers/auth_controller.dart'; import 'package:penyaluran_app/app/services/supabase_service.dart'; import 'package:penyaluran_app/app/routes/app_pages.dart'; @@ -45,6 +46,10 @@ class DonaturDashboardController extends GetxController { // Data untuk stok bantuan yang tersedia final RxList stokBantuan = [].obs; + // Data untuk lokasi penyaluran + final RxList lokasiPenyaluran = + [].obs; + // Indikator loading final RxBool isLoading = false.obs; @@ -199,6 +204,9 @@ class DonaturDashboardController extends GetxController { // Ambil data stok bantuan await fetchStokBantuan(); + // Ambil data lokasi penyaluran + await fetchLokasiPenyaluran(); + // Ambil data notifikasi await fetchNotifikasi(); } catch (e) { @@ -233,7 +241,7 @@ class DonaturDashboardController extends GetxController { .from('penyaluran_bantuan') .select( '*, lokasi_penyaluran:lokasi_penyaluran_id(*), kategori:kategori_bantuan_id(*), petugas:petugas_id(*)') - .order('tanggal_penyaluran', ascending: true); + .order('tanggal_penyaluran', ascending: false); // Konversi ke model lalu filter di sisi client final allJadwal = response @@ -243,9 +251,7 @@ class DonaturDashboardController extends GetxController { // Filter jadwal yang tanggalnya lebih besar dari hari ini jadwalPenyaluran.value = allJadwal - .where((jadwal) => - jadwal.tanggalPenyaluran != null && - jadwal.tanggalPenyaluran!.isAfter(now)) + .where((jadwal) => jadwal.tanggalPenyaluran != null) .toList(); } catch (e) { print('Error fetching jadwal penyaluran: $e'); @@ -306,6 +312,23 @@ class DonaturDashboardController extends GetxController { } } + // Ambil data lokasi penyaluran + Future fetchLokasiPenyaluran() async { + try { + final response = await _supabaseService.client + .from('lokasi_penyaluran') + .select() + .eq('is_lokasi_titip', true) + .order('nama'); + + lokasiPenyaluran.value = (response as List) + .map((data) => LokasiPenyaluranModel.fromJson(data)) + .toList(); + } catch (e) { + print('Error fetching lokasi penyaluran: $e'); + } + } + // Ambil data notifikasi Future fetchNotifikasi() async { try { @@ -386,6 +409,7 @@ class DonaturDashboardController extends GetxController { double jumlah, String deskripsi, String? skemaBantuanId, + String? lokasiPenyaluranId, ) async { try { isLoading.value = true; @@ -426,15 +450,25 @@ class DonaturDashboardController extends GetxController { 'tanggal_penitipan': DateTime.now().toIso8601String(), 'foto_bantuan': fotoBantuanUrls, 'is_uang': selectedStokBantuan.isUang ?? false, + 'skema_bantuan_id': skemaBantuanId, + 'lokasi_penyaluran_id': lokasiPenyaluranId, }; - // Tambahkan skema bantuan jika ada - if (skemaBantuanId != null && skemaBantuanId.isNotEmpty) { - data['skema_bantuan_id'] = skemaBantuanId; - } - // Simpan ke database - await _supabaseService.client.from('penitipan_bantuan').insert(data); + final response = await _supabaseService.client + .from('penitipan_bantuan') + .insert(data) + .select('id') + .single(); + + // Tampilkan pesan sukses + Get.snackbar( + 'Berhasil', + 'Penitipan bantuan berhasil diinput', + backgroundColor: Colors.green, + colorText: Colors.white, + duration: const Duration(seconds: 3), + ); // Reset foto bantuan setelah berhasil disimpan resetFotoBantuan(); @@ -442,19 +476,13 @@ class DonaturDashboardController extends GetxController { // Ambil data penitipan bantuan yang baru await fetchPenitipanBantuan(); - // Tampilkan pesan sukses - Get.snackbar( - 'Berhasil', - 'Penitipan bantuan berhasil dikirim dan akan diproses oleh petugas desa', - backgroundColor: Colors.green, - colorText: Colors.white, - duration: const Duration(seconds: 3), - ); + // Kembali ke halaman utama + Get.back(); } catch (e) { print('Error creating penitipan bantuan: $e'); Get.snackbar( 'Gagal', - 'Terjadi kesalahan saat mengirim penitipan bantuan: $e', + 'Terjadi kesalahan: $e', backgroundColor: Colors.red, colorText: Colors.white, duration: const Duration(seconds: 3), @@ -475,7 +503,7 @@ class DonaturDashboardController extends GetxController { .eq('id', lokasiId) .single(); - if (response != null && response['nama'] != null) { + if (response['nama'] != null) { return response['nama'] as String; } return null; diff --git a/lib/app/modules/donatur/views/donatur_dashboard_view.dart b/lib/app/modules/donatur/views/donatur_dashboard_view.dart index 2353085..87997f3 100644 --- a/lib/app/modules/donatur/views/donatur_dashboard_view.dart +++ b/lib/app/modules/donatur/views/donatur_dashboard_view.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:intl/intl.dart'; import 'package:penyaluran_app/app/modules/donatur/controllers/donatur_dashboard_controller.dart'; import 'package:penyaluran_app/app/routes/app_pages.dart'; +import 'package:penyaluran_app/app/utils/format_helper.dart'; import 'package:penyaluran_app/app/widgets/section_header.dart'; class DonaturDashboardView extends GetView { @@ -36,13 +36,57 @@ class DonaturDashboardView extends GetView { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Header DisalurKita dengan logo dan slogan + Container( + padding: const EdgeInsets.all(16), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15), + boxShadow: [ + BoxShadow( + color: Colors.blue.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + Image.asset( + 'assets/images/logo-disalurkita.png', + width: 50, + height: 50, + ), + const SizedBox(width: 15), + const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'DisalurKita', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Color(0xFF1565C0), + ), + ), + SizedBox(height: 5), + Text( + 'Salurkan dengan Pasti, Pantau dengan Bukti', + style: TextStyle( + fontSize: 12, + color: Colors.grey, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ], + ), + ), _buildWelcomeSection(), const SizedBox(height: 24), _buildStatisticSection(), - const SizedBox(height: 24), - _buildUpcomingEvents(), - const SizedBox(height: 24), - _buildRecentPenitipan(), ], ), ), @@ -101,14 +145,24 @@ class DonaturDashboardView extends GetView { child: CircleAvatar( radius: 30, backgroundColor: Colors.blue.shade100, - backgroundImage: controller.profilePhotoUrl != null + backgroundImage: controller.profilePhotoUrl != null && + controller.profilePhotoUrl!.isNotEmpty ? NetworkImage(controller.profilePhotoUrl!) : null, - child: controller.profilePhotoUrl == null - ? Icon( - Icons.person, - color: Colors.blue.shade700, - size: 30, + child: (controller.profilePhotoUrl == null || + controller.profilePhotoUrl!.isEmpty) + ? Text( + controller.nama.isNotEmpty + ? controller.nama + .toString() + .substring(0, 1) + .toUpperCase() + : '?', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.blue.shade700, + fontSize: 24, + ), ) : null, ), @@ -263,7 +317,7 @@ class DonaturDashboardView extends GetView { child: _buildStatCard( title: 'Diterima', value: - '${controller.penitipanBantuan.where((p) => p.status == 'DITERIMA').length}', + '${controller.penitipanBantuan.where((p) => p.status == 'TERVERIFIKASI').length}', icon: Icons.check_circle_outline, color: Colors.green, ), @@ -284,125 +338,6 @@ class DonaturDashboardView extends GetView { ); } - Widget _buildUpcomingEvents() { - final upcomingEvents = controller.jadwalPenyaluran - .where((event) => - event.tanggalPenyaluran != null && - event.tanggalPenyaluran!.isAfter(DateTime.now())) - .take(3) - .toList(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SectionHeader( - title: 'Jadwal Penyaluran', - ), - Text( - 'Jadwal penyaluran bantuan terdekat', - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade600, - ), - ), - ], - ), - TextButton( - onPressed: () { - // Navigasi ke tab jadwal penyaluran - controller.activeTabIndex.value = 2; - }, - child: Text( - 'Lihat Semua', - style: TextStyle(color: Colors.blue.shade700), - ), - ), - ], - ), - const SizedBox(height: 8), - if (upcomingEvents.isEmpty) - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(10), - ), - child: const Center( - child: Text( - 'Tidak ada jadwal penyaluran dalam waktu dekat', - style: TextStyle(color: Colors.grey), - ), - ), - ) - else - ...upcomingEvents.map((event) => _buildEventCard(event)), - ], - ); - } - - Widget _buildRecentPenitipan() { - final recentPenitipan = controller.penitipanBantuan.take(3).toList(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SectionHeader( - title: 'Bantuan Terakhir', - ), - Text( - 'Riwayat penitipan bantuan terakhir', - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade600, - ), - ), - ], - ), - TextButton( - onPressed: () { - // Navigasi ke tab riwayat penitipan - controller.activeTabIndex.value = 3; - }, - child: Text( - 'Lihat Semua', - style: TextStyle(color: Colors.blue.shade700), - ), - ), - ], - ), - const SizedBox(height: 8), - if (recentPenitipan.isEmpty) - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(10), - ), - child: const Center( - child: Text( - 'Belum ada riwayat penitipan bantuan', - style: TextStyle(color: Colors.grey), - ), - ), - ) - else - ...recentPenitipan.map((penitipan) => _buildPenitipanCard(penitipan)), - ], - ); - } - Widget _buildInfoRow({ required IconData icon, required Color iconColor, @@ -545,7 +480,7 @@ class DonaturDashboardView extends GetView { Widget _buildEventCard(dynamic event) { final formattedDate = event.tanggalPenyaluran != null - ? DateFormat('dd MMMM yyyy', 'id_ID').format(event.tanggalPenyaluran!) + ? FormatHelper.formatDateTime(event.tanggalPenyaluran!) : 'Tanggal tidak tersedia'; return Container( @@ -588,7 +523,8 @@ class DonaturDashboardView extends GetView { ), Text( event.tanggalPenyaluran != null - ? DateFormat('dd').format(event.tanggalPenyaluran!) + ? FormatHelper.formatDateTime( + event.tanggalPenyaluran!) : '--', style: TextStyle( fontSize: 14, @@ -640,8 +576,7 @@ class DonaturDashboardView extends GetView { Widget _buildPenitipanCard(dynamic penitipan) { final formattedDate = penitipan.tanggalPenitipan != null - ? DateFormat('dd MMMM yyyy', 'id_ID') - .format(penitipan.tanggalPenitipan!) + ? FormatHelper.formatDateTime(penitipan.tanggalPenitipan!) : 'Tanggal tidak tersedia'; Color statusColor; diff --git a/lib/app/modules/donatur/views/donatur_jadwal_detail_view.dart b/lib/app/modules/donatur/views/donatur_jadwal_detail_view.dart index d4c43a0..669c899 100644 --- a/lib/app/modules/donatur/views/donatur_jadwal_detail_view.dart +++ b/lib/app/modules/donatur/views/donatur_jadwal_detail_view.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:intl/intl.dart'; import 'package:penyaluran_app/app/modules/donatur/controllers/donatur_dashboard_controller.dart'; import 'package:penyaluran_app/app/data/models/penyaluran_bantuan_model.dart'; import 'package:penyaluran_app/app/widgets/section_header.dart'; +import 'package:penyaluran_app/app/utils/format_helper.dart'; class DonaturJadwalDetailView extends GetView { - const DonaturJadwalDetailView({Key? key}) : super(key: key); + const DonaturJadwalDetailView({super.key}); @override DonaturDashboardController get controller { @@ -35,7 +35,6 @@ class DonaturJadwalDetailView extends GetView { _buildDetailSection(jadwal), _buildPelaksanaSection(jadwal), _buildStatusSection(jadwal), - _buildActionSection(jadwal), ], ), ), @@ -126,8 +125,7 @@ class DonaturJadwalDetailView extends GetView { ), const SizedBox(width: 8), Text( - DateFormat('EEEE, dd MMMM yyyy', 'id_ID') - .format(jadwal.tanggalPenyaluran!), + FormatHelper.formatDateIndonesian(jadwal.tanggalPenyaluran), style: const TextStyle( fontSize: 16, color: Colors.white, @@ -204,11 +202,26 @@ class DonaturJadwalDetailView extends GetView { CircleAvatar( radius: 25, backgroundColor: Colors.blue.shade100, - child: Icon( - Icons.person, - color: Colors.blue.shade700, - size: 30, - ), + backgroundImage: jadwal.fotoPetugas != null && + jadwal.fotoPetugas.toString().isNotEmpty + ? NetworkImage(jadwal.fotoPetugas as String) + : null, + child: (jadwal.fotoPetugas == null || + jadwal.fotoPetugas.toString().isEmpty) + ? Text( + jadwal.namaPetugas != null + ? jadwal.namaPetugas + .toString() + .substring(0, 1) + .toUpperCase() + : '?', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.blue.shade700, + fontSize: 20, + ), + ) + : null, ), const SizedBox(width: 16), Expanded( @@ -254,50 +267,87 @@ class DonaturJadwalDetailView extends GetView { children: [ const SectionHeader(title: 'Status Penyaluran'), const SizedBox(height: 16), - _buildStatusTimeline(jadwal), + _buildStatusCard(jadwal), ], ), ); } - Widget _buildStatusTimeline(PenyaluranBantuanModel jadwal) { + Widget _buildStatusCard(PenyaluranBantuanModel jadwal) { final status = jadwal.status; - final bool isCompleted = status == 'SELESAI'; - final bool isCancelled = status == 'DIBATALKAN'; - final bool isInProgress = status == 'DALAM_PROSES'; + final bool isCompleted = status == 'TERLAKSANA'; + final bool isCancelled = status == 'BATALTERLAKSANA'; + final bool isInProgress = status == 'AKTIF'; + final bool isScheduled = status == 'Dijadwalkan'; + + Color statusColor = Colors.blue; + IconData statusIcon = Icons.schedule; + String statusText = 'Dijadwalkan'; + + if (isCompleted) { + statusColor = Colors.green; + statusIcon = Icons.check_circle; + statusText = 'Terlaksana'; + } else if (isCancelled) { + statusColor = Colors.red; + statusIcon = Icons.cancel; + statusText = 'Batal Terlaksana'; + } else if (isInProgress) { + statusColor = Colors.blue; + statusIcon = Icons.sync; + statusText = 'Aktif'; + } else if (isScheduled) { + statusColor = Colors.orange; + statusIcon = Icons.schedule; + statusText = 'Dijadwalkan'; + } return Column( children: [ - _buildTimelineItem( - title: 'Dijadwalkan', - date: jadwal.createdAt != null - ? DateFormat('dd MMM yyyy', 'id_ID').format(jadwal.createdAt!) - : '-', - isCompleted: true, - isFirst: true, - ), - _buildTimelineItem( - title: 'Dalam Proses', - date: isInProgress || isCompleted - ? jadwal.tanggalPenyaluran != null - ? DateFormat('dd MMM yyyy', 'id_ID') - .format(jadwal.tanggalPenyaluran!) - : '-' - : '-', - isCompleted: isInProgress || isCompleted, - isCancelled: isCancelled, - ), - _buildTimelineItem( - title: 'Selesai', - date: isCompleted - ? jadwal.tanggalSelesai != null - ? DateFormat('dd MMM yyyy', 'id_ID') - .format(jadwal.tanggalSelesai!) - : '-' - : '-', - isCompleted: isCompleted, - isCancelled: isCancelled, - isLast: true, + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: statusColor.withOpacity(0.3)), + ), + child: Column( + children: [ + Row( + children: [ + Icon(statusIcon, color: statusColor, size: 28), + const SizedBox(width: 12), + Text( + statusText, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: statusColor, + ), + ), + ], + ), + const SizedBox(height: 16), + _buildStatusDetailItem( + title: 'Tanggal Dijadwalkan', + value: FormatHelper.formatDateIndonesian(jadwal.createdAt), + ), + const SizedBox(height: 8), + _buildStatusDetailItem( + title: 'Tanggal Penyaluran', + value: + FormatHelper.formatDateIndonesian(jadwal.tanggalPenyaluran), + ), + if (isCompleted) ...[ + const SizedBox(height: 8), + _buildStatusDetailItem( + title: 'Tanggal Selesai', + value: + FormatHelper.formatDateIndonesian(jadwal.tanggalSelesai), + ), + ], + ], + ), ), if (isCancelled) ...[ const SizedBox(height: 16), @@ -333,7 +383,7 @@ class DonaturJadwalDetailView extends GetView { const SizedBox(height: 8), if (jadwal.tanggalPembatalan != null) Text( - 'Dibatalkan pada: ${DateFormat('dd MMMM yyyy', 'id_ID').format(jadwal.tanggalPembatalan!)}', + 'Dibatalkan pada: ${FormatHelper.formatDateIndonesian(jadwal.tanggalPembatalan)}', style: TextStyle( fontSize: 14, color: Colors.red.shade700, @@ -347,159 +397,29 @@ class DonaturJadwalDetailView extends GetView { ); } - Widget _buildTimelineItem({ - required String title, - required String date, - required bool isCompleted, - bool isFirst = false, - bool isLast = false, - bool isCancelled = false, - }) { + Widget _buildStatusDetailItem( + {required String title, required String value}) { return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - SizedBox( - width: 20, - child: Column( - children: [ - if (!isFirst) - Container( - width: 2, - height: 20, - color: isCompleted - ? Colors.green - : isCancelled - ? Colors.red - : Colors.grey.shade300, - ), - Container( - width: 20, - height: 20, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isCompleted - ? Colors.green - : isCancelled - ? Colors.red - : Colors.grey.shade300, - border: Border.all( - color: isCompleted - ? Colors.green - : isCancelled - ? Colors.red - : Colors.grey.shade300, - width: 2, - ), - ), - child: isCompleted - ? const Icon(Icons.check, size: 12, color: Colors.white) - : isCancelled - ? const Icon(Icons.close, size: 12, color: Colors.white) - : null, - ), - if (!isLast) - Container( - width: 2, - height: 20, - color: isCompleted && !isCancelled - ? Colors.green - : Colors.grey.shade300, - ), - ], + Text( + title, + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade700, ), ), - const SizedBox(width: 12), - Expanded( - child: Container( - margin: const EdgeInsets.only(bottom: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: isCompleted - ? Colors.black - : isCancelled - ? Colors.red - : Colors.grey, - ), - ), - Text( - date, - style: TextStyle( - fontSize: 14, - color: isCompleted - ? Colors.grey.shade700 - : isCancelled - ? Colors.red.shade300 - : Colors.grey.shade400, - ), - ), - ], - ), + Text( + value, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, ), ), ], ); } - Widget _buildActionSection(PenyaluranBantuanModel jadwal) { - if (jadwal.status == 'DIBATALKAN') { - return const SizedBox.shrink(); - } - - return Container( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SectionHeader(title: 'Tindakan'), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: () => _hubungiPetugas(jadwal), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 20, vertical: 12), - backgroundColor: Colors.green, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - icon: const Icon(Icons.chat_outlined), - label: const Text('Hubungi Petugas'), - ), - ), - if (jadwal.status == 'SELESAI') ...[ - const SizedBox(width: 12), - Expanded( - child: OutlinedButton.icon( - onPressed: () => _lihatLaporan(jadwal), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 20, vertical: 12), - foregroundColor: Colors.blue, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - icon: const Icon(Icons.description_outlined), - label: const Text('Lihat Laporan'), - ), - ), - ], - ], - ), - ], - ), - ); - } - Widget _buildInfoItem({ required IconData icon, required String title, diff --git a/lib/app/modules/donatur/views/donatur_jadwal_view.dart b/lib/app/modules/donatur/views/donatur_jadwal_view.dart index fbb4cc1..685b42e 100644 --- a/lib/app/modules/donatur/views/donatur_jadwal_view.dart +++ b/lib/app/modules/donatur/views/donatur_jadwal_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:intl/intl.dart'; import 'package:penyaluran_app/app/modules/donatur/controllers/donatur_dashboard_controller.dart'; +import 'package:penyaluran_app/app/utils/format_helper.dart'; import 'package:penyaluran_app/app/widgets/section_header.dart'; class DonaturJadwalView extends GetView { @@ -97,7 +98,7 @@ class DonaturJadwalView extends GetView { for (var jadwal in controller.jadwalPenyaluran) { if (jadwal.tanggalPenyaluran != null) { String monthYear = - DateFormat('MMMM yyyy', 'id_ID').format(jadwal.tanggalPenyaluran!); + FormatHelper.formatDate(jadwal.tanggalPenyaluran!, format: 'MMMM'); if (!groupedJadwal.containsKey(monthYear)) { groupedJadwal[monthYear] = []; @@ -110,9 +111,14 @@ class DonaturJadwalView extends GetView { // Urutkan kunci (bulan) secara kronologis List sortedMonths = groupedJadwal.keys.toList() ..sort((a, b) { - DateTime dateA = DateFormat('MMMM yyyy', 'id_ID').parse(a); - DateTime dateB = DateFormat('MMMM yyyy', 'id_ID').parse(b); - return dateA.compareTo(dateB); + try { + DateTime dateA = DateFormat('MMMM yyyy', 'id_ID').parse(a); + DateTime dateB = DateFormat('MMMM yyyy', 'id_ID').parse(b); + return dateA.compareTo(dateB); + } catch (e) { + // Fallback sorting jika parse error + return a.compareTo(b); + } }); return ListView( @@ -158,28 +164,27 @@ class DonaturJadwalView extends GetView { Widget _buildJadwalCard(dynamic jadwal) { final formattedDate = jadwal.tanggalPenyaluran != null - ? DateFormat('EEEE, dd MMMM yyyy', 'id_ID') - .format(jadwal.tanggalPenyaluran!) + ? FormatHelper.formatDateTime(jadwal.tanggalPenyaluran!) : 'Tanggal tidak tersedia'; - String statusText = 'Akan Datang'; + String statusText = 'Dijadwalkan'; Color statusColor = Colors.blue; switch (jadwal.status) { - case 'SELESAI': - statusText = 'Selesai'; + case 'TERLAKSANA': + statusText = 'Terlaksana'; statusColor = Colors.green; break; - case 'DIBATALKAN': - statusText = 'Dibatalkan'; + case 'BATALTERLAKSANA': + statusText = 'Batal Terlaksana'; statusColor = Colors.red; break; - case 'DALAM_PROSES': - statusText = 'Dalam Proses'; - statusColor = Colors.orange; + case 'AKTIF': + statusText = 'Aktif'; + statusColor = Colors.blue; break; default: - statusText = 'Akan Datang'; + statusText = 'Dijadwalkan'; statusColor = Colors.blue; } @@ -248,8 +253,9 @@ class DonaturJadwalView extends GetView { ), child: Text( jadwal.tanggalPenyaluran != null - ? DateFormat('MMM', 'id_ID') - .format(jadwal.tanggalPenyaluran!) + ? FormatHelper.formatDate( + jadwal.tanggalPenyaluran!, + format: 'MMM') .toUpperCase() : 'TBD', style: const TextStyle( @@ -265,8 +271,9 @@ class DonaturJadwalView extends GetView { child: Center( child: Text( jadwal.tanggalPenyaluran != null - ? DateFormat('dd') - .format(jadwal.tanggalPenyaluran!) + ? FormatHelper.formatDate( + jadwal.tanggalPenyaluran!, + format: 'dd') : '-', style: TextStyle( fontSize: 24, @@ -459,11 +466,11 @@ class DonaturJadwalView extends GetView { IconData _getStatusIcon(String? status) { switch (status) { - case 'SELESAI': + case 'TERLAKSANA': return Icons.check_circle; - case 'DIBATALKAN': + case 'BATALTERLAKSANA': return Icons.cancel; - case 'DALAM_PROSES': + case 'AKTIF': return Icons.timelapse; default: return Icons.event_available; diff --git a/lib/app/modules/donatur/views/donatur_laporan_view.dart b/lib/app/modules/donatur/views/donatur_laporan_view.dart index 647eb09..ce5e711 100644 --- a/lib/app/modules/donatur/views/donatur_laporan_view.dart +++ b/lib/app/modules/donatur/views/donatur_laporan_view.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:intl/intl.dart'; +import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import 'package:penyaluran_app/app/modules/donatur/controllers/donatur_dashboard_controller.dart'; +import 'package:penyaluran_app/app/utils/format_helper.dart'; import 'package:penyaluran_app/app/widgets/section_header.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:path/path.dart' as path; class DonaturLaporanView extends GetView { const DonaturLaporanView({super.key}); @@ -20,20 +23,59 @@ class DonaturLaporanView extends GetView { @override Widget build(BuildContext context) { return Scaffold( - body: Obx(() { - if (controller.isLoading.value) { - return const Center(child: CircularProgressIndicator()); - } + body: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.blue.shade50, + Colors.white, + ], + ), + ), + child: Obx(() { + if (controller.isLoading.value) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 60, + height: 60, + child: CircularProgressIndicator( + valueColor: + AlwaysStoppedAnimation(Colors.blue.shade600), + strokeWidth: 3, + ), + ), + const SizedBox(height: 24), + Text( + 'Memuat data laporan...', + style: TextStyle( + fontSize: 16, + color: Colors.blue.shade800, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } - return RefreshIndicator( - onRefresh: () async { - await controller.fetchLaporanPenyaluran(); - }, - child: controller.laporanPenyaluran.isEmpty - ? _buildEmptyState() - : _buildLaporanList(), - ); - }), + return RefreshIndicator( + onRefresh: () async { + await controller.fetchLaporanPenyaluran(); + }, + color: Colors.blue, + backgroundColor: Colors.white, + strokeWidth: 3, + child: controller.laporanPenyaluran.isEmpty + ? _buildEmptyState() + : _buildLaporanListWithHeader(), + ); + }), + ), ); } @@ -41,56 +83,189 @@ class DonaturLaporanView extends GetView { return Center( child: SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.assignment_outlined, - size: 80, - color: Colors.grey.shade400, - ), - const SizedBox(height: 16), - const Text( - 'Belum Ada Laporan Penyaluran', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, + child: Container( + padding: const EdgeInsets.all(24), + margin: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 15, + offset: const Offset(0, 5), ), - ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Text( - 'Laporan penyaluran bantuan belum tersedia', + ], + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.blue.shade50, + shape: BoxShape.circle, + ), + child: Icon( + Icons.assignment_outlined, + size: 60, + color: Colors.blue.shade400, + ), + ), + const SizedBox(height: 24), + const Text( + 'Belum Ada Laporan Penyaluran', style: TextStyle( - fontSize: 14, - color: Colors.grey.shade600, - ), - textAlign: TextAlign.center, - ), - ), - const SizedBox(height: 24), - ElevatedButton.icon( - onPressed: () => controller.fetchLaporanPenyaluran(), - icon: const Icon(Icons.refresh), - label: const Text('Muat Ulang'), - style: ElevatedButton.styleFrom( - foregroundColor: Colors.white, - backgroundColor: Colors.blue, - padding: - const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + fontSize: 20, + fontWeight: FontWeight.bold, ), ), - ), - ], + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + 'Laporan penyaluran bantuan belum tersedia saat ini', + style: TextStyle( + fontSize: 16, + color: Colors.grey.shade600, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 32), + ElevatedButton.icon( + onPressed: () => controller.fetchLaporanPenyaluran(), + icon: const Icon(Icons.refresh), + label: const Text('Muat Ulang'), + style: ElevatedButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: Colors.blue, + padding: + const EdgeInsets.symmetric(horizontal: 32, vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 2, + ), + ), + ], + ), ), ), ); } - Widget _buildLaporanList() { + Widget _buildLaporanListWithHeader() { + return CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverAppBar( + expandedHeight: 120.0, + floating: true, + pinned: true, + snap: true, + backgroundColor: Colors.transparent, + elevation: 0, + flexibleSpace: FlexibleSpaceBar( + titlePadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + title: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.8), + borderRadius: BorderRadius.circular(20), + ), + child: const Text( + 'Laporan Penyaluran', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + background: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.blue.shade400, + Colors.blue.shade100, + ], + ), + ), + child: Stack( + children: [ + Positioned( + right: -20, + top: -20, + child: CircleAvatar( + radius: 80, + backgroundColor: Colors.white.withOpacity(0.1), + ), + ), + Positioned( + left: -40, + bottom: -20, + child: CircleAvatar( + radius: 60, + backgroundColor: Colors.white.withOpacity(0.1), + ), + ), + Align( + alignment: Alignment.center, + child: Icon( + Icons.assignment_outlined, + size: 40, + color: Colors.white.withOpacity(0.7), + ), + ), + ], + ), + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + sliver: SliverToBoxAdapter( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.blue.shade100), + ), + child: Row( + children: [ + Icon( + Icons.info_outline, + color: Colors.blue.shade700, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Daftar laporan hasil penyaluran bantuan', + style: TextStyle( + fontSize: 14, + color: Colors.blue.shade800, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ), + _buildAnimatedLaporanList(), + ], + ); + } + + Widget _buildAnimatedLaporanList() { // Urutkan laporan berdasarkan tanggal, yang terbaru di atas final sortedLaporan = controller.laporanPenyaluran.toList() ..sort((a, b) { @@ -100,230 +275,396 @@ class DonaturLaporanView extends GetView { return b.tanggalLaporan!.compareTo(a.tanggalLaporan!); }); - return ListView( - padding: const EdgeInsets.all(16), - children: [ - const SectionHeader(title: 'Laporan Penyaluran Bantuan'), - Text( - 'Daftar laporan hasil penyaluran bantuan', - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade600, - ), + return SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final laporan = sortedLaporan[index]; + return AnimationConfiguration.staggeredList( + position: index, + duration: const Duration(milliseconds: 375), + child: SlideAnimation( + verticalOffset: 50.0, + child: FadeInAnimation( + child: Padding( + padding: const EdgeInsets.only(bottom: 4.0, top: 4.0), + child: _buildLaporanCard(laporan), + ), + ), + ), + ); + }, + childCount: sortedLaporan.length, ), - const SizedBox(height: 16), - ...sortedLaporan.map((laporan) => _buildLaporanCard(laporan)), - ], + ), ); } Widget _buildLaporanCard(dynamic laporan) { final formattedDate = laporan.tanggalLaporan != null - ? DateFormat('dd MMMM yyyy', 'id_ID').format(laporan.tanggalLaporan!) + ? FormatHelper.formatDateTime(laporan.tanggalLaporan!) : 'Tanggal tidak tersedia'; return Container( margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, 5), + color: Colors.black.withOpacity(0.06), + blurRadius: 12, + offset: const Offset(0, 6), ), ], ), child: Card( elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(16), ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Bagian atas dengan gambar header jika ada + if (laporan.dokumentasiUrl != null && + laporan.dokumentasiUrl!.isNotEmpty) + ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + child: Image.network( + laporan.dokumentasiUrl!, + height: 150, + width: double.infinity, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + height: 100, + color: Colors.blue.shade50, + alignment: Alignment.center, + child: Icon( + Icons.image_not_supported, + color: Colors.blue.shade300, + size: 40, + ), + ); + }, + ), + ), + + Padding( + padding: const EdgeInsets.all(16), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 60, - height: 60, - alignment: Alignment.center, - decoration: BoxDecoration( - color: Colors.blue.shade50, - borderRadius: BorderRadius.circular(10), - ), - child: laporan.dokumentasiUrl != null && - laporan.dokumentasiUrl!.isNotEmpty - ? ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Image.network( - laporan.dokumentasiUrl!, - width: 60, - height: 60, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Icon( - Icons.assignment, - color: Colors.blue.shade700, - size: 30, - ); - }, - ), - ) - : Icon( + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (laporan.dokumentasiUrl == null || + laporan.dokumentasiUrl!.isEmpty) + Container( + width: 60, + height: 60, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( Icons.assignment, color: Colors.blue.shade700, size: 30, ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - laporan.judul, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, ), - const SizedBox(height: 4), - Row( + if (laporan.dokumentasiUrl == null || + laporan.dokumentasiUrl!.isEmpty) + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - Icons.calendar_today, - size: 14, - color: Colors.grey.shade600, - ), - const SizedBox(width: 4), Text( - formattedDate, - style: TextStyle( - fontSize: 13, - color: Colors.grey.shade700, + laporan.judul, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.green.shade50, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: Colors.green.shade200, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.check_circle, + size: 14, + color: Colors.green.shade700, + ), + const SizedBox(width: 4), + Text( + 'Selesai', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Colors.green.shade700, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Icon( + Icons.calendar_today, + size: 14, + color: Colors.grey.shade600, + ), + const SizedBox(width: 4), + Text( + formattedDate, + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade700, + ), + ), + ], ), ], ), - const SizedBox(height: 8), - Row( - children: [ - if (laporan.beritaAcaraUrl != null && - laporan.beritaAcaraUrl!.isNotEmpty) - ElevatedButton.icon( - onPressed: () { - // Implementasi untuk membuka berita acara - _openDocument(laporan.beritaAcaraUrl!); - }, - icon: const Icon(Icons.description, size: 16), - label: const Text('Berita Acara'), - style: ElevatedButton.styleFrom( - foregroundColor: Colors.white, - backgroundColor: Colors.blue, - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 8), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - minimumSize: const Size(30, 30), - tapTargetSize: - MaterialTapTargetSize.shrinkWrap, - textStyle: const TextStyle(fontSize: 12), - ), + ), + ], + ), + Row( + children: [ + if (laporan.beritaAcaraUrl != null && + laporan.beritaAcaraUrl!.isNotEmpty) + Expanded( + child: OutlinedButton.icon( + onPressed: () { + _openDocument(laporan.beritaAcaraUrl!); + }, + icon: const Icon(Icons.description, size: 18), + label: const Text('Berita Acara'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.blue.shade700, + side: BorderSide(color: Colors.blue.shade300), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), ), - if (laporan.dokumentasiUrl != null && - laporan.dokumentasiUrl!.isNotEmpty) ...[ - const SizedBox(width: 8), - ElevatedButton.icon( - onPressed: () { - // Implementasi untuk melihat dokumentasi - _viewDocumentation(laporan.dokumentasiUrl!); - }, - icon: const Icon(Icons.photo_library, size: 16), - label: const Text('Dokumentasi'), - style: ElevatedButton.styleFrom( - foregroundColor: Colors.white, - backgroundColor: Colors.green, - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 8), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - minimumSize: const Size(30, 30), - tapTargetSize: - MaterialTapTargetSize.shrinkWrap, - textStyle: const TextStyle(fontSize: 12), - ), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + if (laporan.dokumentasiUrl != null && + laporan.dokumentasiUrl!.isNotEmpty) ...[ + if (laporan.beritaAcaraUrl != null && + laporan.beritaAcaraUrl!.isNotEmpty) + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: () { + _viewDocumentation(laporan.dokumentasiUrl!); + }, + icon: const Icon(Icons.photo_library, size: 18), + label: const Text('Lihat Foto'), + style: ElevatedButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: Colors.blue.shade600, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), ), - ], - ], + padding: const EdgeInsets.symmetric(vertical: 12), + elevation: 0, + ), + ), ), ], - ), + ], ), ], ), - ], - ), + ), + ], ), ), ); } + // Fungsi helper untuk menentukan jenis file + LaunchMode _determineLaunchMode(String url) { + final extension = path.extension(url).toLowerCase(); + + // Jika PDF atau dokumen, buka di aplikasi eksternal + if (['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx'] + .contains(extension)) { + return LaunchMode.externalApplication; + } + + // Jika gambar, buka di aplikasi eksternal + if (['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'] + .contains(extension)) { + return LaunchMode.externalApplication; + } + + // URL web, buka di browser + if (url.startsWith('http://') || url.startsWith('https://')) { + return LaunchMode.externalApplication; + } + + // Default ke aplikasi eksternal + return LaunchMode.externalApplication; + } + + // Fungsi untuk membuka URL dengan handling error + Future _launchUrl(String url, + {String successMessage = 'Berhasil membuka dokumen'}) async { + try { + final Uri uri = Uri.parse(url); + final LaunchMode mode = _determineLaunchMode(url); + + if (await canLaunchUrl(uri)) { + final bool success = await launchUrl(uri, mode: mode); + + if (success) { + Get.snackbar( + 'Berhasil', + successMessage, + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.green.shade50, + colorText: Colors.green.shade800, + margin: const EdgeInsets.all(16), + borderRadius: 10, + icon: Icon( + Icons.check_circle_outline, + color: Colors.green.shade800, + ), + ); + } else { + _showErrorSnackbar( + 'Tidak dapat membuka file. Format mungkin tidak didukung.'); + } + } else { + _showErrorSnackbar('Tidak dapat membuka URL: $url'); + } + } catch (e) { + _showErrorSnackbar('Error: ${e.toString()}'); + } + } + + // Fungsi untuk menampilkan error snackbar + void _showErrorSnackbar(String message) { + Get.snackbar( + 'Gagal', + message, + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red.shade50, + colorText: Colors.red.shade800, + margin: const EdgeInsets.all(16), + borderRadius: 10, + icon: Icon( + Icons.error_outline, + color: Colors.red.shade800, + ), + ); + } + void _openDocument(String url) { Get.dialog( Dialog( shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(20), ), - child: Padding( - padding: const EdgeInsets.all(16.0), + child: Container( + padding: const EdgeInsets.all(24.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: Colors.white, + ), child: Column( mainAxisSize: MainAxisSize.min, children: [ + Container( + width: 70, + height: 70, + decoration: BoxDecoration( + color: Colors.blue.shade50, + shape: BoxShape.circle, + ), + child: Icon( + Icons.description_outlined, + color: Colors.blue.shade700, + size: 40, + ), + ), + const SizedBox(height: 20), const Text( 'Buka Dokumen', style: TextStyle( - fontSize: 18, + fontSize: 20, fontWeight: FontWeight.bold, ), ), - const SizedBox(height: 16), + const SizedBox(height: 12), const Text( - 'Anda akan membuka dokumen berita acara. Lanjutkan?', + 'Anda akan membuka dokumen berita acara penyaluran bantuan. Lanjutkan?', textAlign: TextAlign.center, + style: TextStyle( + fontSize: 15, + height: 1.4, + ), ), - const SizedBox(height: 24), + const SizedBox(height: 28), Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - TextButton( - onPressed: () => Get.back(), - child: const Text('Batal'), - ), - ElevatedButton( - onPressed: () { - Get.back(); - // Implementasi untuk membuka URL dokumen - // Misalnya menggunakan package url_launcher - // launch(url); - Get.snackbar( - 'Info', - 'Membuka dokumen: $url', - snackPosition: SnackPosition.BOTTOM, - ); - }, - style: ElevatedButton.styleFrom( - foregroundColor: Colors.white, - backgroundColor: Colors.blue, + Expanded( + child: OutlinedButton( + onPressed: () => Get.back(), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.grey.shade700, + side: BorderSide(color: Colors.grey.shade300), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text('Batal'), + ), + ), + const SizedBox(width: 16), + Expanded( + child: ElevatedButton( + onPressed: () { + Get.back(); + _launchUrl(url, + successMessage: 'Membuka dokumen berita acara'); + }, + style: ElevatedButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: Colors.blue.shade600, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 0, + ), + child: const Text('Buka'), ), - child: const Text('Buka'), ), ], ), @@ -341,66 +682,160 @@ class DonaturLaporanView extends GetView { borderRadius: BorderRadius.circular(16), ), insetPadding: const EdgeInsets.all(16), + backgroundColor: Colors.transparent, child: Column( mainAxisSize: MainAxisSize.min, children: [ - Padding( + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(16), + ), + ), padding: const EdgeInsets.all(16.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text( - 'Dokumentasi', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), + Row( + children: [ + Icon( + Icons.photo_library, + color: Colors.blue.shade600, + size: 24, + ), + const SizedBox(width: 12), + const Text( + 'Dokumentasi Penyaluran', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Get.back(), + Container( + decoration: BoxDecoration( + color: Colors.grey.shade200, + shape: BoxShape.circle, + ), + child: IconButton( + icon: const Icon(Icons.close), + onPressed: () => Get.back(), + color: Colors.black87, + iconSize: 20, + ), ), ], ), ), Container( + color: Colors.black, constraints: BoxConstraints( - maxHeight: Get.height * 0.6, + maxHeight: Get.height * 0.7, maxWidth: Get.width, ), - child: Image.network( - url, - fit: BoxFit.contain, - errorBuilder: (context, error, stackTrace) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.error_outline, - size: 50, - color: Colors.red.shade300, + child: InteractiveViewer( + minScale: 0.5, + maxScale: 3.0, + child: Image.network( + url, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + size: 60, + color: Colors.red.shade300, + ), + const SizedBox(height: 16), + const Text( + 'Gagal memuat gambar', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 8), + Text( + 'Harap periksa koneksi internet Anda dan coba lagi', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade300, + ), + textAlign: TextAlign.center, + ), + ], ), - const SizedBox(height: 16), - const Text('Gagal memuat gambar'), - ], - ), - ); - }, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return Center( - child: CircularProgressIndicator( - value: loadingProgress.expectedTotalBytes != null - ? loadingProgress.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null, - ), - ); - }, + ), + ); + }, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + color: Colors.white, + strokeWidth: 3, + ), + const SizedBox(height: 16), + const Text( + 'Memuat dokumentasi...', + style: TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + ], + ), + ), + ); + }, + ), + ), + ), + Container( + width: double.infinity, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical( + bottom: Radius.circular(16), + ), + ), + padding: const EdgeInsets.all(16), + child: ElevatedButton.icon( + onPressed: () { + Get.back(); + _launchUrl(url, + successMessage: 'Membuka gambar di aplikasi eksternal'); + }, + icon: const Icon(Icons.open_in_new, size: 20), + label: const Text('Buka di Aplikasi Galeri'), + style: ElevatedButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: Colors.blue.shade600, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 0, + ), ), ), - const SizedBox(height: 16), ], ), ), diff --git a/lib/app/modules/donatur/views/donatur_penitipan_view.dart b/lib/app/modules/donatur/views/donatur_penitipan_view.dart index 87a2d1d..0d7d70c 100644 --- a/lib/app/modules/donatur/views/donatur_penitipan_view.dart +++ b/lib/app/modules/donatur/views/donatur_penitipan_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:penyaluran_app/app/data/models/skema_bantuan_model.dart'; +import 'package:penyaluran_app/app/data/models/stok_bantuan_model.dart'; import 'package:penyaluran_app/app/modules/donatur/controllers/donatur_dashboard_controller.dart'; import 'package:penyaluran_app/app/widgets/section_header.dart'; import 'dart:io'; @@ -11,9 +12,13 @@ class DonaturPenitipanView extends GetView { @override DonaturDashboardController get controller { if (!Get.isRegistered( - tag: 'donatur_dashboard')) { - return Get.put(DonaturDashboardController(), - tag: 'donatur_dashboard', permanent: true); + tag: 'donatur_dashboard', + )) { + return Get.put( + DonaturDashboardController(), + tag: 'donatur_dashboard', + permanent: true, + ); } return Get.find(tag: 'donatur_dashboard'); } @@ -38,9 +43,12 @@ class _FormPenitipanBantuanState extends State { final GlobalKey formKey = GlobalKey(); String? selectedStokBantuanId; String? selectedSkemaBantuanId; + String? selectedLokasiPenyaluranId; final TextEditingController jumlahController = TextEditingController(); final TextEditingController deskripsiController = TextEditingController(); + bool _isProsedurinfoExpanded = false; + @override void initState() { super.initState(); @@ -75,651 +83,44 @@ class _FormPenitipanBantuanState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SectionHeader(title: 'Formulir Penitipan Bantuan'), - Text( - 'Isi formulir berikut untuk melakukan penitipan bantuan', - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade600, - ), - ), + // Bagian Header dengan prosedur singkat + _buildHeader(), + const SizedBox(height: 8), + + // Prosedur Penitipan Bantuan + _buildProsedurPenitipan(), const SizedBox(height: 24), // Pilih metode penitipan - Text( - 'Metode Penitipan', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.grey.shade800, - ), - ), - const SizedBox(height: 8), - - // Tab pilihan metode - Container( - decoration: BoxDecoration( - color: Colors.grey.shade200, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Expanded( - child: InkWell( - onTap: () { - setState(() { - selectedSkemaBantuanId = null; - }); - }, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - color: selectedSkemaBantuanId == null - ? Colors.blue - : Colors.transparent, - borderRadius: BorderRadius.circular(8), - ), - alignment: Alignment.center, - child: Text( - 'Bantuan Manual', - style: TextStyle( - fontWeight: FontWeight.bold, - color: selectedSkemaBantuanId == null - ? Colors.white - : Colors.grey.shade800, - ), - ), - ), - ), - ), - Expanded( - child: InkWell( - onTap: () { - setState(() { - // Reset stok bantuan saat memilih skema - selectedStokBantuanId = null; - selectedSkemaBantuanId = ''; - }); - }, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - color: selectedSkemaBantuanId != null - ? Colors.blue - : Colors.transparent, - borderRadius: BorderRadius.circular(8), - ), - alignment: Alignment.center, - child: Text( - 'Dari Skema Bantuan', - style: TextStyle( - fontWeight: FontWeight.bold, - color: selectedSkemaBantuanId != null - ? Colors.white - : Colors.grey.shade800, - ), - ), - ), - ), - ), - ], - ), - ), + _buildMetodePenitipanSection(), const SizedBox(height: 24), // Form berdasarkan pilihan if (selectedSkemaBantuanId != null) ...[ - // Form untuk skema bantuan - Text( - 'Pilih Skema Bantuan', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.grey.shade800, - ), - ), - const SizedBox(height: 8), - DropdownButtonFormField( - decoration: InputDecoration( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - hintText: 'Pilih skema bantuan', - ), - value: selectedSkemaBantuanId == '' - ? null - : selectedSkemaBantuanId, - items: controller.skemaBantuan.map((skema) { - return DropdownMenuItem( - value: skema.id, - child: Text(skema.nama ?? 'Tidak ada nama'), - ); - }).toList(), - onChanged: (value) { - setState(() { - selectedSkemaBantuanId = value; - // Jika skema dipilih, isi otomatis stok bantuan sesuai dengan skema - if (value != null) { - final selectedSkema = - controller.skemaBantuan.firstWhere( - (skema) => skema.id == value, - orElse: () => SkemaBantuanModel(), - ); - selectedStokBantuanId = selectedSkema.stokBantuanId; - - // Isi otomatis jumlah jika ada - if (selectedSkema.jumlahDiterimaPerOrang != null) { - jumlahController.text = - selectedSkema.jumlahDiterimaPerOrang.toString(); - } - } - }); - }, - validator: (value) { - if (selectedSkemaBantuanId != null && - (value == null || value.isEmpty)) { - return 'Skema bantuan harus dipilih'; - } - return null; - }, - ), - const SizedBox(height: 8), - - // Tampilkan informasi stok bantuan dari skema yang dipilih - Obx(() { - // Hanya tampilkan jika skema dipilih - if (selectedSkemaBantuanId == null || - selectedSkemaBantuanId!.isEmpty) { - return const SizedBox.shrink(); - } - - // Cari skema bantuan yang dipilih - SkemaBantuanModel? selectedSkema; - try { - selectedSkema = controller.skemaBantuan.firstWhere( - (skema) => skema.id == selectedSkemaBantuanId, - ); - } catch (_) { - return const SizedBox.shrink(); - } - - // Pastikan skema dan stok bantuan ada - if (selectedSkema.stokBantuanId == null) { - return const SizedBox.shrink(); - } - - // Cari stok bantuan yang sesuai - var stokBantuanFound = false; - var stokNama = 'Tidak diketahui'; - var stokTotal = 0.0; - var stokSatuan = 'item'; - - for (var stok in controller.stokBantuan) { - if (stok.id == selectedSkema.stokBantuanId) { - stokBantuanFound = true; - stokNama = stok.nama ?? 'Tidak diketahui'; - stokTotal = stok.totalStok ?? 0; - stokSatuan = stok.satuan ?? 'item'; - break; - } - } - - if (!stokBantuanFound) { - return const SizedBox.shrink(); - } - - // Tampilkan informasi stok bantuan - return Column( - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.blue.shade50, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.blue.shade200), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.info_outline, - color: Colors.blue.shade700, size: 18), - const SizedBox(width: 8), - Text( - 'Informasi Stok Bantuan', - style: TextStyle( - fontWeight: FontWeight.bold, - color: Colors.blue.shade700, - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - 'Jenis Bantuan: $stokNama', - style: const TextStyle(fontSize: 14), - ), - const SizedBox(height: 4), - Text( - 'Stok Tersedia: $stokTotal $stokSatuan', - style: const TextStyle(fontSize: 14), - ), - ], - ), - ), - const SizedBox(height: 16) - ], - ); - }), - - const SizedBox(height: 16), + _buildFormSkemaBantuan(), ] else ...[ - // Form untuk bantuan manual - Text( - 'Jenis Bantuan', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.grey.shade800, - ), - ), - const SizedBox(height: 8), - DropdownButtonFormField( - decoration: InputDecoration( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - hintText: 'Pilih jenis bantuan', - ), - value: selectedStokBantuanId, - items: controller.getAvailableStokBantuan().map((stok) { - return DropdownMenuItem( - value: stok.id, - child: Text( - '${stok.nama ?? 'Tidak ada nama'} (Stok: ${stok.totalStok ?? 0} ${stok.satuan ?? 'item'})'), - ); - }).toList(), - onChanged: (value) { - setState(() { - selectedStokBantuanId = value; - }); - }, - validator: (value) { - if (value == null || value.isEmpty) { - return 'Jenis bantuan harus dipilih'; - } - return null; - }, - ), - const SizedBox(height: 16), + _buildFormBantuanManual(), ], // Jumlah bantuan - Text( - 'Jumlah Bantuan', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.grey.shade800, - ), - ), - const SizedBox(height: 8), - TextFormField( - controller: jumlahController, - keyboardType: TextInputType.number, - decoration: InputDecoration( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - hintText: 'Masukkan jumlah bantuan', - contentPadding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Jumlah harus diisi'; - } - if (double.tryParse(value) == null) { - return 'Jumlah harus berupa angka'; - } - if (double.parse(value) <= 0) { - return 'Jumlah harus lebih dari 0'; - } - return null; - }, - ), + _buildJumlahBantuan(), + const SizedBox(height: 16), + + // Lokasi Penitipan + _buildLokasiPenitipan(), const SizedBox(height: 16), // Deskripsi bantuan - Text( - 'Deskripsi Bantuan', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.grey.shade800, - ), - ), - const SizedBox(height: 8), - TextFormField( - controller: deskripsiController, - maxLines: 3, - decoration: InputDecoration( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - hintText: 'Deskripsi bantuan yang dititipkan', - contentPadding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Deskripsi harus diisi'; - } - return null; - }, - ), + _buildDeskripsiBantuan(), const SizedBox(height: 16), // Foto bantuan - Text( - 'Foto Bantuan', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.grey.shade800, - ), - ), - const SizedBox(height: 8), - - // Widget untuk foto bantuan - Obx(() => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Tampilkan foto yang sudah dipilih - if (controller.fotoBantuanPaths.isNotEmpty) ...[ - SizedBox( - height: 120, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: controller.fotoBantuanPaths.length + - 1, // +1 untuk tombol tambah - itemBuilder: (context, index) { - if (index == controller.fotoBantuanPaths.length) { - // Tombol tambah foto - return GestureDetector( - onTap: _showPilihSumberFoto, - child: Container( - width: 120, - margin: const EdgeInsets.only(right: 8), - decoration: BoxDecoration( - color: Colors.grey.shade200, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Colors.grey.shade400), - ), - child: Column( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - Icon( - Icons.add_a_photo, - size: 32, - color: Colors.grey.shade600, - ), - const SizedBox(height: 4), - Text( - 'Tambah Foto', - style: TextStyle( - color: Colors.grey.shade600, - fontSize: 12, - ), - ), - ], - ), - ), - ); - } - - // Tampilkan foto yang sudah dipilih - return Stack( - children: [ - Container( - width: 120, - height: 120, - margin: const EdgeInsets.only(right: 8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Colors.grey.shade400), - image: DecorationImage( - image: FileImage(File(controller - .fotoBantuanPaths[index])), - fit: BoxFit.cover, - ), - ), - ), - Positioned( - top: 4, - right: 12, - child: GestureDetector( - onTap: () { - controller.removeFotoBantuan(index); - }, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.7), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.close, - size: 18, - color: Colors.red, - ), - ), - ), - ), - ], - ); - }, - ), - ), - ] else ...[ - // Tampilkan placeholder untuk upload foto - GestureDetector( - onTap: _showPilihSumberFoto, - child: Container( - height: 120, - width: double.infinity, - decoration: BoxDecoration( - color: Colors.grey.shade200, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey.shade400), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.add_a_photo, - size: 40, - color: Colors.grey.shade600, - ), - const SizedBox(height: 8), - Text( - 'Tambah Foto Bantuan', - style: TextStyle( - color: Colors.grey.shade600, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text( - 'Upload minimal 1 foto bantuan', - style: TextStyle( - color: Colors.grey.shade500, - fontSize: 12, - ), - ), - ], - ), - ), - ), - ], - ], - )), - + _buildFotoBantuan(), const SizedBox(height: 24), // Tombol kirim - ElevatedButton.icon( - onPressed: () { - if (formKey.currentState!.validate()) { - // Validasi foto bantuan - if (controller.fotoBantuanPaths.isEmpty) { - Get.snackbar( - 'Peringatan', - 'Harap upload setidaknya 1 foto bantuan', - backgroundColor: Colors.amber, - colorText: Colors.white, - duration: const Duration(seconds: 3), - ); - return; - } - - // Tampilkan konfirmasi sebelum mengirim - Get.dialog( - AlertDialog( - title: const Text('Konfirmasi Penitipan Bantuan'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Apakah data yang Anda masukkan sudah benar?'), - const SizedBox(height: 12), - const Text( - 'Penitipan bantuan akan diproses oleh petugas desa.'), - ], - ), - actions: [ - TextButton( - onPressed: () => Get.back(), - child: const Text('Batal'), - ), - ElevatedButton( - onPressed: () { - Get.back(); - // Panggil fungsi untuk membuat penitipan bantuan - controller.createPenitipanBantuan( - selectedStokBantuanId, - double.parse(jumlahController.text), - deskripsiController.text, - selectedSkemaBantuanId, - ); - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.green, - ), - child: const Text('Kirim'), - ), - ], - ), - ); - } - }, - icon: const Icon(Icons.send), - label: const Text('Kirim Penitipan Bantuan'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.green, - foregroundColor: Colors.white, - minimumSize: const Size(double.infinity, 45), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - + _buildSubmitButton(), const SizedBox(height: 24), - const Divider(), - const SizedBox(height: 16), - - // Informasi kontak petugas - Text( - 'Hubungi Petugas Desa', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.grey.shade800, - ), - ), - const SizedBox(height: 8), - Text( - 'Untuk penitipan bantuan secara langsung, silahkan hubungi petugas desa terdekat atau kunjungi kantor desa terdekat.', - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade600, - ), - ), - const SizedBox(height: 16), - OutlinedButton.icon( - onPressed: () { - // Implementasi untuk membuka kontak petugas desa - Get.dialog( - AlertDialog( - title: const Text('Informasi Kontak Petugas Desa'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildContactInfo( - icon: Icons.phone, - title: 'Telepon', - content: '0812-3456-7890', - ), - const SizedBox(height: 16), - _buildContactInfo( - icon: Icons.email, - title: 'Email', - content: 'petugas@desa.id', - ), - const SizedBox(height: 16), - _buildContactInfo( - icon: Icons.location_on, - title: 'Alamat', - content: - 'Jl. Desa Sejahtera No. 123, Kecamatan Makmur', - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Get.back(), - child: const Text('Tutup'), - ), - ], - ), - ); - }, - icon: const Icon(Icons.contact_phone), - label: const Text('Lihat Kontak Petugas Desa'), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.blue, - side: BorderSide(color: Colors.blue.shade300), - minimumSize: const Size(double.infinity, 45), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), ], ), ), @@ -727,41 +128,1745 @@ class _FormPenitipanBantuanState extends State { }); } + Widget _buildHeader() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.volunteer_activism, + color: Colors.blue.shade700, + size: 28, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Formulir Penitipan Bantuan', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + 'Isi formulir untuk mencatat bantuan yang telah Anda titipkan', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), + ], + ), + ), + ], + ), + ], + ); + } + + Widget _buildProsedurPenitipan() { + return Card( + elevation: 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: Colors.blue.shade100), + ), + color: Colors.blue.shade50, + child: Column( + children: [ + // Header dengan tombol toggle + InkWell( + onTap: () { + setState(() { + _isProsedurinfoExpanded = !_isProsedurinfoExpanded; + }); + }, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.blue.shade100, + shape: BoxShape.circle, + ), + child: Icon( + Icons.info_outline, + color: Colors.blue.shade800, + size: 20, + ), + ), + const SizedBox(width: 12), + const Expanded( + child: Text( + 'Prosedur Penitipan Bantuan', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + Icon( + _isProsedurinfoExpanded + ? Icons.keyboard_arrow_up + : Icons.keyboard_arrow_down, + color: Colors.blue.shade800, + ), + ], + ), + ), + ), + + // Konten prosedur yang bisa di-expand + AnimatedCrossFade( + duration: const Duration(milliseconds: 300), + crossFadeState: _isProsedurinfoExpanded + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + firstChild: const SizedBox(height: 0), + secondChild: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + children: [ + const Divider(), + const SizedBox(height: 8), + + // Langkah-langkah prosedur + _buildProsedurStep( + number: '1', + title: 'Penitipan Bantuan', + description: + 'Titipkan bantuan Anda langsung ke lokasi penyaluran yang tersedia', + icon: Icons.local_shipping, + ), + + _buildProsedurStep( + number: '2', + title: 'Ambil Bukti Foto', + description: + 'Ambil foto sebagai bukti bantuan yang telah dititipkan', + icon: Icons.camera_alt, + ), + + _buildProsedurStep( + number: '3', + title: 'Isi Formulir', + description: + 'Lengkapi formulir ini untuk mencatat penitipan bantuan Anda', + icon: Icons.edit_document, + ), + + _buildProsedurStep( + number: '4', + title: 'Verifikasi Petugas', + description: + 'Petugas desa akan memverifikasi penitipan bantuan Anda', + icon: Icons.verified_user, + isLast: true, + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildProsedurStep({ + required String number, + required String title, + required String description, + required IconData icon, + bool isLast = false, + }) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Nomor dalam lingkaran + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: Colors.blue.shade700, + shape: BoxShape.circle, + ), + child: Center( + child: Text( + number, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 18, color: Colors.blue.shade700), + const SizedBox(width: 8), + Text( + title, + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.blue.shade900, + fontSize: 15, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + description, + style: const TextStyle(fontSize: 14), + ), + if (!isLast) ...[ + SizedBox( + height: 30, + child: Padding( + padding: const EdgeInsets.only(left: 13), + child: VerticalDivider( + color: Colors.blue.shade300, + thickness: 1, + width: 1, + ), + ), + ), + ] else + const SizedBox(height: 8), + ], + ), + ), + ], + ); + } + + // Bagian lainnya akan dikembangkan dalam edit selanjutnya + + Widget _buildMetodePenitipanSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.category_outlined, color: Colors.grey.shade700), + const SizedBox(width: 8), + Text( + 'Metode Penitipan', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.grey.shade800, + ), + ), + ], + ), + const SizedBox(height: 12), + + // Tab pilihan metode yang lebih menarik + Container( + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Expanded( + child: InkWell( + onTap: () { + setState(() { + // Reset semua data saat berpindah ke bantuan manual + selectedSkemaBantuanId = null; + selectedStokBantuanId = null; + jumlahController.clear(); + }); + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: selectedSkemaBantuanId == null + ? Colors.blue.shade600 + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + alignment: Alignment.center, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.inventory_2_outlined, + size: 18, + color: selectedSkemaBantuanId == null + ? Colors.white + : Colors.grey.shade800, + ), + const SizedBox(width: 8), + Text( + 'Bantuan Manual', + style: TextStyle( + fontWeight: FontWeight.bold, + color: selectedSkemaBantuanId == null + ? Colors.white + : Colors.grey.shade800, + ), + ), + ], + ), + ), + ), + ), + Expanded( + child: InkWell( + onTap: () { + setState(() { + // Reset semua data saat berpindah ke skema bantuan + selectedStokBantuanId = null; + selectedSkemaBantuanId = ''; + jumlahController.clear(); + }); + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: selectedSkemaBantuanId != null + ? Colors.blue.shade600 + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + alignment: Alignment.center, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.schema_outlined, + size: 18, + color: selectedSkemaBantuanId != null + ? Colors.white + : Colors.grey.shade800, + ), + const SizedBox(width: 8), + Text( + 'Dari Skema Bantuan', + style: TextStyle( + fontWeight: FontWeight.bold, + color: selectedSkemaBantuanId != null + ? Colors.white + : Colors.grey.shade800, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildFormSkemaBantuan() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.schema, color: Colors.grey.shade700), + const SizedBox(width: 8), + Text( + 'Pilih Skema Bantuan', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.grey.shade800, + ), + ), + ], + ), + const SizedBox(height: 12), + Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(12), + ), + child: DropdownButtonFormField( + decoration: InputDecoration( + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + hintText: 'Pilih skema bantuan', + prefixIcon: Icon(Icons.category, color: Colors.blue.shade600), + ), + value: selectedSkemaBantuanId == '' ? null : selectedSkemaBantuanId, + items: controller.skemaBantuan.map((skema) { + return DropdownMenuItem( + value: skema.id, + child: Text(skema.nama ?? 'Tidak ada nama'), + ); + }).toList(), + onChanged: (value) { + setState(() { + selectedSkemaBantuanId = value; + // Jika skema dipilih, isi otomatis stok bantuan sesuai dengan skema + if (value != null) { + final selectedSkema = controller.skemaBantuan.firstWhere( + (skema) => skema.id == value, + orElse: () => SkemaBantuanModel(), + ); + selectedStokBantuanId = selectedSkema.stokBantuanId; + + // Isi otomatis jumlah jika ada + if (selectedSkema.jumlahDiterimaPerOrang != null) { + jumlahController.text = + selectedSkema.jumlahDiterimaPerOrang.toString(); + } + } + }); + }, + validator: (value) { + if (selectedSkemaBantuanId != null && + (value == null || value.isEmpty)) { + return 'Skema bantuan harus dipilih'; + } + return null; + }, + dropdownColor: Colors.white, + icon: Icon(Icons.arrow_drop_down, color: Colors.blue.shade600), + isExpanded: true, + ), + ), + const SizedBox(height: 12), + + // Tampilkan informasi stok bantuan dari skema yang dipilih + Builder( + builder: (context) { + // Hanya tampilkan jika skema dipilih + if (selectedSkemaBantuanId == null || + selectedSkemaBantuanId!.isEmpty) { + return const SizedBox.shrink(); + } + + // Cari skema bantuan yang dipilih + SkemaBantuanModel? selectedSkema; + try { + selectedSkema = controller.skemaBantuan.firstWhere( + (skema) => skema.id == selectedSkemaBantuanId, + ); + } catch (_) { + return const SizedBox.shrink(); + } + + // Pastikan skema dan stok bantuan ada + if (selectedSkema == null || selectedSkema.stokBantuanId == null) { + return const SizedBox.shrink(); + } + + // Menggunakan Obx hanya untuk data yang reaktif + return Obx(() { + // Cari stok bantuan yang sesuai + StokBantuanModel? selectedStok; + try { + if (selectedSkema?.stokBantuanId != null) { + for (var stok in controller.stokBantuan) { + if (stok.id == selectedSkema!.stokBantuanId) { + selectedStok = stok; + break; + } + } + } + } catch (_) { + return const SizedBox.shrink(); + } + + if (selectedStok == null) { + return const SizedBox.shrink(); + } + + final stokNama = selectedStok.nama ?? 'Tidak diketahui'; + final stokTotal = selectedStok.totalStok ?? 0.0; + final stokSatuan = selectedStok.satuan ?? 'item'; + final stokDeskripsi = selectedStok.deskripsi ?? ''; + final isUang = selectedStok.isUang ?? false; + String kategoriNama = ''; + if (selectedStok.kategoriBantuan != null) { + kategoriNama = selectedStok.kategoriBantuan!['nama'] ?? ''; + } + + // Format stok total jika berbentuk uang + String formattedStokTotal; + if (isUang) { + formattedStokTotal = 'Rp ${_formatCurrency(stokTotal)}'; + } else { + formattedStokTotal = '$stokTotal $stokSatuan'; + } + + // Tampilkan informasi stok bantuan dengan desain yang lebih menarik + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isUang ? Colors.green.shade50 : Colors.blue.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isUang + ? Colors.green.shade200 + : Colors.blue.shade200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + isUang ? Icons.monetization_on : Icons.inventory_2, + color: isUang + ? Colors.green.shade700 + : Colors.blue.shade700, + size: 20, + ), + const SizedBox(width: 8), + Text( + 'Informasi Stok Bantuan', + style: TextStyle( + fontWeight: FontWeight.bold, + color: isUang + ? Colors.green.shade700 + : Colors.blue.shade700, + fontSize: 15, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildInfoItem( + icon: isUang ? Icons.attach_money : Icons.category, + label: 'Jenis Bantuan', + value: stokNama, + iconColor: isUang + ? Colors.green.shade600 + : Colors.blue.shade600, + ), + ), + Expanded( + child: _buildInfoItem( + icon: isUang + ? Icons.account_balance_wallet + : Icons.inventory, + label: 'Stok Tersedia', + value: formattedStokTotal, + iconColor: isUang + ? Colors.green.shade600 + : Colors.blue.shade600, + ), + ), + ], + ), + if (kategoriNama.isNotEmpty) ...[ + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _buildInfoItem( + icon: Icons.category_outlined, + label: 'Kategori', + value: kategoriNama, + iconColor: isUang + ? Colors.green.shade600 + : Colors.blue.shade600, + ), + ), + ], + ), + ], + if (stokDeskripsi.isNotEmpty) ...[ + const SizedBox(height: 8), + const Divider(color: Colors.white70), + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.description, + size: 16, + color: isUang + ? Colors.green.shade700 + : Colors.blue.shade700), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Deskripsi', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade700, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + stokDeskripsi, + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade800, + ), + ), + ], + ), + ), + ], + ), + ], + ], + ), + ); + }); + }, + ), + + const SizedBox(height: 16), + ], + ); + } + + Widget _buildInfoItem( + {required IconData icon, + required String label, + required String value, + Color? iconColor}) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, size: 18, color: iconColor ?? Colors.blue.shade600), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 12, + color: Colors.grey, + ), + ), + const SizedBox(height: 2), + Text( + value, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildFormBantuanManual() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.inventory_2, color: Colors.grey.shade700), + const SizedBox(width: 8), + Text( + 'Jenis Bantuan', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.grey.shade800, + ), + ), + ], + ), + const SizedBox(height: 12), + Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(12), + ), + child: DropdownButtonFormField( + decoration: InputDecoration( + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + hintText: 'Pilih jenis bantuan', + prefixIcon: Icon(Icons.category, color: Colors.blue.shade600), + ), + value: selectedStokBantuanId, + items: controller.getAvailableStokBantuan().map((stok) { + String displayText; + if (stok.isUang ?? false) { + displayText = + '${stok.nama ?? 'Tidak ada nama'} (Saldo: Rp ${_formatCurrency(stok.totalStok ?? 0)})'; + } else { + displayText = + '${stok.nama ?? 'Tidak ada nama'} (Stok: ${stok.totalStok ?? 0} ${stok.satuan ?? 'item'})'; + } + return DropdownMenuItem( + value: stok.id, + child: Text(displayText), + ); + }).toList(), + onChanged: (value) { + setState(() { + selectedStokBantuanId = value; + }); + }, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Jenis bantuan harus dipilih'; + } + return null; + }, + dropdownColor: Colors.white, + icon: Icon(Icons.arrow_drop_down, color: Colors.blue.shade600), + isExpanded: true, + ), + ), + const SizedBox(height: 16), + + // Menampilkan informasi stok bantuan yang dipilih + Builder( + builder: (context) { + if (selectedStokBantuanId == null) { + return const SizedBox.shrink(); + } + + return Obx(() { + // Cari stok bantuan yang sesuai + StokBantuanModel? selectedStok; + try { + selectedStok = controller.stokBantuan.firstWhere( + (stok) => stok.id == selectedStokBantuanId, + orElse: () => StokBantuanModel(), + ); + } catch (_) { + return const SizedBox.shrink(); + } + + if (selectedStok.id == null) { + return const SizedBox.shrink(); + } + + final stokNama = selectedStok.nama ?? 'Tidak diketahui'; + final stokTotal = selectedStok.totalStok ?? 0.0; + final stokSatuan = selectedStok.satuan ?? 'item'; + final stokDeskripsi = selectedStok.deskripsi ?? ''; + final isUang = selectedStok.isUang ?? false; + String kategoriNama = ''; + if (selectedStok.kategoriBantuan != null) { + kategoriNama = selectedStok.kategoriBantuan!['nama'] ?? ''; + } + + // Format stok total jika berbentuk uang + String formattedStokTotal; + if (isUang) { + formattedStokTotal = 'Rp ${_formatCurrency(stokTotal)}'; + } else { + formattedStokTotal = '$stokTotal $stokSatuan'; + } + + // Tampilkan informasi stok bantuan dengan desain yang lebih menarik + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isUang ? Colors.green.shade50 : Colors.blue.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: + isUang ? Colors.green.shade200 : Colors.blue.shade200, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + isUang ? Icons.monetization_on : Icons.inventory_2, + color: isUang + ? Colors.green.shade700 + : Colors.blue.shade700, + size: 20, + ), + const SizedBox(width: 8), + Text( + 'Informasi Stok Bantuan', + style: TextStyle( + fontWeight: FontWeight.bold, + color: isUang + ? Colors.green.shade700 + : Colors.blue.shade700, + fontSize: 15, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildInfoItem( + icon: isUang ? Icons.attach_money : Icons.category, + label: 'Jenis Bantuan', + value: stokNama, + iconColor: isUang + ? Colors.green.shade600 + : Colors.blue.shade600, + ), + ), + Expanded( + child: _buildInfoItem( + icon: isUang + ? Icons.account_balance_wallet + : Icons.inventory, + label: 'Stok Tersedia', + value: formattedStokTotal, + iconColor: isUang + ? Colors.green.shade600 + : Colors.blue.shade600, + ), + ), + ], + ), + if (kategoriNama.isNotEmpty) ...[ + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _buildInfoItem( + icon: Icons.category_outlined, + label: 'Kategori', + value: kategoriNama, + iconColor: isUang + ? Colors.green.shade600 + : Colors.blue.shade600, + ), + ), + ], + ), + ], + if (stokDeskripsi.isNotEmpty) ...[ + const SizedBox(height: 8), + const Divider(color: Colors.white70), + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.description, + size: 16, + color: isUang + ? Colors.green.shade700 + : Colors.blue.shade700), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Deskripsi', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade700, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + stokDeskripsi, + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade800, + ), + ), + ], + ), + ), + ], + ), + ], + ], + ), + ); + }); + }, + ), + ], + ); + } + + Widget _buildJumlahBantuan() { + final isUang = _isSelectedStokUang(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + // Tampilkan ikon uang jika stok bantuan berbentuk uang + isUang ? Icons.attach_money : Icons.numbers, + color: Colors.grey.shade700), + const SizedBox(width: 8), + Text( + 'Jumlah Bantuan', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.grey.shade800, + ), + ), + ], + ), + const SizedBox(height: 12), + Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(12), + ), + child: TextFormField( + controller: jumlahController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + border: InputBorder.none, + hintText: + isUang ? 'Masukkan jumlah uang' : 'Masukkan jumlah bantuan', + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + prefixIcon: Icon( + isUang ? Icons.monetization_on : Icons.shopping_bag, + color: Colors.blue.shade600), + // Tambahkan prefix teks "Rp" jika berbentuk uang + prefixText: isUang ? 'Rp ' : null, + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Jumlah harus diisi'; + } + + String numericOnly = value; + if (isUang) { + numericOnly = value.replaceAll('.', ''); + } + + if (double.tryParse(numericOnly) == null) { + return 'Jumlah harus berupa angka'; + } + if (double.parse(numericOnly) <= 0) { + return 'Jumlah harus lebih dari 0'; + } + return null; + }, + onChanged: (value) { + if (isUang && value.isNotEmpty) { + // Format input sebagai currency jika stok berbentuk uang + final numericValue = + value.replaceAll('.', '').replaceAll('Rp ', ''); + if (double.tryParse(numericValue) != null) { + final formattedValue = + _formatCurrency(double.parse(numericValue)); + + // Hindari infinite loop dengan mengecek apakah nilai sudah berubah + if (formattedValue != value) { + jumlahController.value = TextEditingValue( + text: formattedValue, + selection: TextSelection.collapsed( + offset: formattedValue.length), + ); + } + } + } + }, + ), + ), + if (isUang) ...[ + const SizedBox(height: 8), + Text( + 'Masukkan jumlah dalam Rupiah tanpa desimal', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + fontStyle: FontStyle.italic, + ), + ), + ], + ], + ); + } + + // Helper function untuk mengecek apakah stok bantuan yang dipilih berbentuk uang + bool _isSelectedStokUang() { + if (selectedStokBantuanId == null) return false; + + try { + for (var stok in controller.stokBantuan) { + if (stok.id == selectedStokBantuanId) { + return stok.isUang ?? false; + } + } + return false; + } catch (_) { + return false; + } + } + + // Helper function untuk memformat angka sebagai currency + String _formatCurrency(double value) { + // Format ke currency dengan pemisah ribuan + final formatted = value.toInt().toString().replaceAllMapped( + RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), + (Match match) => '${match[1]}.', + ); + return formatted; + } + + Widget _buildLokasiPenitipan() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.location_on, color: Colors.grey.shade700), + const SizedBox(width: 8), + Text( + 'Lokasi Penitipan', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.grey.shade800, + ), + ), + ], + ), + const SizedBox(height: 12), + Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(12), + ), + child: DropdownButtonFormField( + decoration: InputDecoration( + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + hintText: 'Pilih lokasi penitipan', + prefixIcon: Icon(Icons.place, color: Colors.blue.shade600), + ), + value: selectedLokasiPenyaluranId, + items: controller.lokasiPenyaluran.map((lokasi) { + String alamatLengkap = [ + lokasi.alamat, + lokasi.desa, + lokasi.kecamatan, + lokasi.kabupaten, + ].where((s) => s != null && s.isNotEmpty).join(', '); + + return DropdownMenuItem( + value: lokasi.id, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + lokasi.nama, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + if (alamatLengkap.isNotEmpty) + Text( + alamatLengkap, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + }).toList(), + onChanged: (value) { + setState(() { + selectedLokasiPenyaluranId = value; + }); + }, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Lokasi penitipan harus dipilih'; + } + return null; + }, + dropdownColor: Colors.white, + icon: Icon(Icons.arrow_drop_down, color: Colors.blue.shade600), + isExpanded: true, + ), + ), + ], + ); + } + + Widget _buildDeskripsiBantuan() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.description, color: Colors.grey.shade700), + const SizedBox(width: 8), + Text( + 'Deskripsi Bantuan', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.grey.shade800, + ), + ), + ], + ), + const SizedBox(height: 12), + Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(12), + ), + child: TextFormField( + controller: deskripsiController, + maxLines: 3, + decoration: InputDecoration( + border: InputBorder.none, + hintText: 'Deskripsi bantuan yang dititipkan', + contentPadding: const EdgeInsets.all(16), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Deskripsi harus diisi'; + } + return null; + }, + ), + ), + ], + ); + } + + Widget _buildFotoBantuan() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.photo_camera, color: Colors.grey.shade700), + const SizedBox(width: 8), + Text( + 'Foto Bantuan', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.grey.shade800, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Upload minimal 1 foto sebagai bukti bantuan yang dititipkan', + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 12), + + // Widget untuk foto bantuan dengan desain lebih menarik + Obx(() { + return controller.fotoBantuanPaths.isNotEmpty + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Tampilkan foto yang sudah dipilih + SizedBox( + height: 140, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: controller.fotoBantuanPaths.length + + 1, // +1 untuk tombol tambah + itemBuilder: (context, index) { + if (index == controller.fotoBantuanPaths.length) { + // Tombol tambah foto + return GestureDetector( + onTap: _showPilihSumberFoto, + child: Container( + width: 120, + margin: const EdgeInsets.only(right: 12), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.blue.shade200, + width: 1.5, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.blue.shade50, + shape: BoxShape.circle, + ), + child: Icon( + Icons.add_a_photo, + size: 28, + color: Colors.blue.shade600, + ), + ), + const SizedBox(height: 8), + Text( + 'Tambah Foto', + style: TextStyle( + color: Colors.blue.shade600, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ], + ), + ), + ); + } + + // Tampilkan foto yang sudah dipilih + return Container( + width: 120, + height: 140, + margin: const EdgeInsets.only(right: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.grey.shade300, + ), + ), + child: Stack( + children: [ + // Foto + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.file( + File(controller.fotoBantuanPaths[index]), + width: 120, + height: 140, + fit: BoxFit.cover, + ), + ), + // Tombol hapus + Positioned( + top: 8, + right: 8, + child: GestureDetector( + onTap: () { + controller.removeFotoBantuan(index); + }, + child: Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black26, + blurRadius: 4, + ), + ], + ), + child: const Icon( + Icons.close, + size: 18, + color: Colors.red, + ), + ), + ), + ), + ], + ), + ); + }, + ), + ), + ], + ) + : GestureDetector( + onTap: _showPilihSumberFoto, + child: Container( + height: 140, + width: double.infinity, + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.blue.shade200, + width: 1.5, + style: BorderStyle.solid, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.blue.shade50, + shape: BoxShape.circle, + ), + child: Icon( + Icons.add_a_photo, + size: 36, + color: Colors.blue.shade600, + ), + ), + const SizedBox(height: 12), + Text( + 'Tambah Foto Bantuan', + style: TextStyle( + color: Colors.blue.shade700, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 4), + Text( + 'Klik untuk mengambil foto bukti bantuan', + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 13, + ), + ), + ], + ), + ), + ); + }), + ], + ); + } + + Widget _buildSubmitButton() { + return ElevatedButton.icon( + onPressed: () { + if (formKey.currentState!.validate()) { + // Validasi foto bantuan + if (controller.fotoBantuanPaths.isEmpty) { + Get.snackbar( + 'Peringatan', + 'Harap upload setidaknya 1 foto bantuan', + backgroundColor: Colors.amber, + colorText: Colors.white, + duration: const Duration(seconds: 3), + margin: const EdgeInsets.all(8), + borderRadius: 8, + icon: + const Icon(Icons.warning_amber_rounded, color: Colors.white), + ); + return; + } + + // Tampilkan konfirmasi sebelum mengirim + _showKonfirmasiPenitipan(); + } + }, + icon: const Icon(Icons.send), + label: const Text('Kirim Penitipan Bantuan'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green.shade600, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 2, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ); + } + + // Fungsi untuk menampilkan dialog konfirmasi + void _showKonfirmasiPenitipan() { + // Dapatkan informasi terkait penitipan untuk ditampilkan di dialog + String jenisBantuan = ''; + String lokasiPenitipan = ''; + String jumlahBantuan = jumlahController.text; + bool isUangBantuan = false; + String tipeBantuan = 'Donasi Langsung'; + String skemaBantuanNama = ''; + + // Ambil nama stok bantuan terpilih + if (selectedStokBantuanId != null) { + try { + var stok = controller.stokBantuan + .firstWhere((s) => s.id == selectedStokBantuanId); + jenisBantuan = stok.nama ?? 'Tidak diketahui'; + isUangBantuan = stok.isUang ?? false; + + // Format jumlah untuk bantuan uang + if (isUangBantuan && jumlahBantuan.isNotEmpty) { + // Pastikan sudah format dengan benar + if (!jumlahBantuan.startsWith('Rp ')) { + jumlahBantuan = + 'Rp ${_formatCurrency(double.parse(jumlahBantuan.replaceAll('.', '')))}'; + } + } + } catch (_) { + jenisBantuan = 'Tidak diketahui'; + } + } + + // Ambil nama skema bantuan jika dipilih + if (selectedSkemaBantuanId != null && selectedSkemaBantuanId!.isNotEmpty) { + try { + var skema = controller.skemaBantuan + .firstWhere((s) => s.id == selectedSkemaBantuanId); + skemaBantuanNama = skema.nama ?? 'Tidak diketahui'; + tipeBantuan = 'Bantuan dari Skema: $skemaBantuanNama'; + } catch (_) { + tipeBantuan = 'Bantuan dari Skema'; + } + } + + // Ambil nama lokasi penitipan terpilih + if (selectedLokasiPenyaluranId != null) { + try { + var lokasi = controller.lokasiPenyaluran + .firstWhere((l) => l.id == selectedLokasiPenyaluranId); + lokasiPenitipan = lokasi.nama; + } catch (_) { + lokasiPenitipan = 'Tidak diketahui'; + } + } + + Get.dialog( + Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.green.shade50, + shape: BoxShape.circle, + ), + child: Icon( + Icons.check_circle_outline, + color: Colors.green.shade700, + size: 36, + ), + ), + const SizedBox(height: 16), + const Text( + 'Konfirmasi Penitipan Bantuan', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + const Text( + 'Pastikan data yang Anda masukkan sudah benar', + style: TextStyle( + fontSize: 14, + color: Colors.grey, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + + // Detail info penitipan + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade200), + ), + child: Column( + children: [ + if (selectedSkemaBantuanId != null && + selectedSkemaBantuanId!.isNotEmpty) ...[ + _buildDetailRow( + icon: Icons.schema, + label: 'Metode Penitipan', + value: tipeBantuan, + iconColor: Colors.purple.shade600, + ), + const Divider(), + ], + _buildDetailRow( + icon: isUangBantuan + ? Icons.monetization_on + : Icons.inventory_2, + label: 'Jenis Bantuan', + value: jenisBantuan, + iconColor: isUangBantuan + ? Colors.green.shade600 + : Colors.blue.shade600, + ), + const Divider(), + _buildDetailRow( + icon: isUangBantuan + ? Icons.attach_money + : Icons.shopping_bag, + label: 'Jumlah', + value: jumlahBantuan + (isUangBantuan ? '' : ' item'), + iconColor: isUangBantuan + ? Colors.green.shade600 + : Colors.blue.shade600, + ), + const Divider(), + _buildDetailRow( + icon: Icons.location_on, + label: 'Lokasi Penitipan', + value: lokasiPenitipan, + iconColor: Colors.orange.shade600, + ), + ], + ), + ), + const SizedBox(height: 24), + + // Tombol aksi + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Get.back(), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.grey.shade700, + side: BorderSide(color: Colors.grey.shade300), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: const Text('Batal'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: () { + Get.back(); + // Pastikan jumlah diproses dengan benar + double jumlahNumerik = isUangBantuan + ? double.parse(jumlahController.text + .replaceAll('.', '') + .replaceAll('Rp ', '')) + : double.parse(jumlahController.text); + + // Panggil fungsi untuk membuat penitipan bantuan + controller.createPenitipanBantuan( + selectedStokBantuanId, + jumlahNumerik, + deskripsiController.text, + selectedSkemaBantuanId, + selectedLokasiPenyaluranId, + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green.shade600, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: const Text('Kirim'), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildDetailRow({ + required IconData icon, + required String label, + required String value, + Color? iconColor, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(8), + ), + child: + Icon(icon, color: iconColor ?? Colors.blue.shade700, size: 18), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + Text( + value, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ], + ), + ); + } + // Fungsi untuk memilih foto void _showPilihSumberFoto() { Get.bottomSheet( Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16), decoration: const BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ + Container( + height: 5, + width: 40, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(5), + ), + margin: const EdgeInsets.only(bottom: 16), + ), const Text( 'Pilih Sumber Foto', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildSumberFotoOption( + icon: Icons.camera_alt, + label: 'Kamera', + onTap: () { + Get.back(); + controller.pickImage(isCamera: true); + }, + ), + _buildSumberFotoOption( + icon: Icons.photo_library, + label: 'Galeri', + onTap: () { + Get.back(); + controller.pickImage(isCamera: false); + }, + ), + ], ), const SizedBox(height: 16), - ListTile( - leading: const Icon(Icons.camera_alt), - title: const Text('Kamera'), - onTap: () { - Get.back(); - controller.pickImage(isCamera: true); - }, + ], + ), + ), + ); + } + + Widget _buildSumberFotoOption({ + required IconData icon, + required String label, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + child: Container( + width: 100, + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.blue.shade200), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + color: Colors.blue.shade700, + size: 36, ), - ListTile( - leading: const Icon(Icons.photo_library), - title: const Text('Galeri'), - onTap: () { - Get.back(); - controller.pickImage(isCamera: false); - }, + const SizedBox(height: 8), + Text( + label, + style: TextStyle( + color: Colors.blue.shade700, + fontWeight: FontWeight.bold, + ), ), ], ), @@ -783,11 +1888,7 @@ class _FormPenitipanBantuanState extends State { color: Colors.blue.shade50, borderRadius: BorderRadius.circular(8), ), - child: Icon( - icon, - color: Colors.blue, - size: 20, - ), + child: Icon(icon, color: Colors.blue, size: 20), ), const SizedBox(width: 12), Expanded( @@ -804,10 +1905,7 @@ class _FormPenitipanBantuanState extends State { const SizedBox(height: 4), Text( content, - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade700, - ), + style: TextStyle(fontSize: 14, color: Colors.grey.shade700), ), ], ), diff --git a/lib/app/modules/donatur/views/donatur_riwayat_penitipan_view.dart b/lib/app/modules/donatur/views/donatur_riwayat_penitipan_view.dart index fcffda9..3437e5c 100644 --- a/lib/app/modules/donatur/views/donatur_riwayat_penitipan_view.dart +++ b/lib/app/modules/donatur/views/donatur_riwayat_penitipan_view.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:intl/intl.dart'; import 'package:penyaluran_app/app/modules/donatur/controllers/donatur_dashboard_controller.dart'; +import 'package:penyaluran_app/app/utils/format_helper.dart'; +import 'package:penyaluran_app/app/widgets/widgets.dart'; class DonaturRiwayatPenitipanView extends GetView { DonaturRiwayatPenitipanView({super.key}); @@ -60,8 +61,7 @@ class DonaturRiwayatPenitipanView extends GetView { final kategoriNama = item.kategoriBantuan?.nama?.toLowerCase() ?? ''; final deskripsi = item.deskripsi?.toLowerCase() ?? ''; final tanggal = item.tanggalPenitipan != null - ? DateFormat('dd MMMM yyyy', 'id_ID') - .format(item.tanggalPenitipan!) + ? FormatHelper.formatDateTime(item.tanggalPenitipan!) .toLowerCase() : ''; @@ -214,8 +214,7 @@ class DonaturRiwayatPenitipanView extends GetView { Widget _buildPenitipanCard( BuildContext context, dynamic penitipan, Color statusColor) { final formattedDate = penitipan.tanggalPenitipan != null - ? DateFormat('dd MMMM yyyy', 'id_ID') - .format(penitipan.tanggalPenitipan!) + ? FormatHelper.formatDateTime(penitipan.tanggalPenitipan!) : 'Tanggal tidak tersedia'; IconData statusIcon; @@ -435,61 +434,6 @@ class DonaturRiwayatPenitipanView extends GetView { return id != null ? 'Petugas Desa' : 'Tidak ada petugas'; } - void showFullScreenImage(String imageUrl) { - Get.dialog( - Dialog( - insetPadding: EdgeInsets.zero, - child: Container( - color: Colors.black, - child: Stack( - fit: StackFit.expand, - children: [ - InteractiveViewer( - panEnabled: true, - minScale: 0.5, - maxScale: 4, - child: Image.network( - imageUrl, - fit: BoxFit.contain, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return Center( - child: CircularProgressIndicator( - value: loadingProgress.expectedTotalBytes != null - ? loadingProgress.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null, - ), - ); - }, - ), - ), - Positioned( - top: 20, - right: 20, - child: GestureDetector( - onTap: () => Get.back(), - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.5), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.close, - color: Colors.white, - size: 24, - ), - ), - ), - ), - ], - ), - ), - ), - ); - } - Get.dialog( AlertDialog( title: const Text('Detail Penitipan'), @@ -509,8 +453,7 @@ class DonaturRiwayatPenitipanView extends GetView { _buildInfoRow( 'Tanggal Penitipan', penitipan.tanggalPenitipan != null - ? DateFormat('dd MMMM yyyy', 'id_ID') - .format(penitipan.tanggalPenitipan!) + ? FormatHelper.formatDateTime(penitipan.tanggalPenitipan!) : 'Tanggal tidak tersedia', ), _buildInfoRow( @@ -520,8 +463,7 @@ class DonaturRiwayatPenitipanView extends GetView { if (penitipan.tanggalVerifikasi != null) _buildInfoRow( 'Tanggal Verifikasi', - DateFormat('dd MMMM yyyy HH:mm', 'id_ID') - .format(penitipan.tanggalVerifikasi!), + FormatHelper.formatDateTime(penitipan.tanggalVerifikasi!), ), if (penitipan.deskripsi != null && penitipan.deskripsi!.isNotEmpty) @@ -543,8 +485,10 @@ class DonaturRiwayatPenitipanView extends GetView { ), const SizedBox(height: 8), GestureDetector( - onTap: () => - showFullScreenImage(penitipan.fotoBantuan!.first), + onTap: () => ShowImageDialog.showFullScreen( + context, + penitipan.fotoBantuan!.first, + ), child: Container( height: 200, width: double.infinity, @@ -572,8 +516,10 @@ class DonaturRiwayatPenitipanView extends GetView { ), const SizedBox(height: 8), GestureDetector( - onTap: () => - showFullScreenImage(penitipan.fotoBuktiSerahTerima!), + onTap: () => ShowImageDialog.showFullScreen( + context, + penitipan.fotoBuktiSerahTerima!, + ), child: Container( height: 200, width: double.infinity, diff --git a/lib/app/modules/donatur/views/donatur_skema_view.dart b/lib/app/modules/donatur/views/donatur_skema_view.dart index 9487b5c..447aeec 100644 --- a/lib/app/modules/donatur/views/donatur_skema_view.dart +++ b/lib/app/modules/donatur/views/donatur_skema_view.dart @@ -4,7 +4,6 @@ import 'package:penyaluran_app/app/modules/donatur/controllers/donatur_dashboard import 'package:penyaluran_app/app/widgets/section_header.dart'; import 'package:penyaluran_app/app/data/models/stok_bantuan_model.dart'; import 'package:penyaluran_app/app/utils/format_helper.dart'; -import 'package:penyaluran_app/app/utils/date_helper.dart'; class DonaturSkemaView extends GetView { const DonaturSkemaView({super.key}); @@ -549,14 +548,14 @@ class DonaturSkemaView extends GetView { int days = difference.inDays; if (days > 0) { - return 'Batas waktu: ${days} hari lagi'; + return 'Batas waktu: $days hari lagi'; } else { int hours = difference.inHours; if (hours > 0) { - return 'Batas waktu: ${hours} jam lagi'; + return 'Batas waktu: $hours jam lagi'; } else { int minutes = difference.inMinutes; - return 'Batas waktu: ${minutes} menit lagi'; + return 'Batas waktu: $minutes menit lagi'; } } } @@ -597,20 +596,20 @@ class DonaturSkemaView extends GetView { } } // Format nilai sebagai Rupiah menggunakan DateHelper - return DateHelper.formatRupiah(nilai); + return FormatHelper.formatRupiah(nilai); } // Jika bukan uang, kembalikan nilai + satuan (jika ada) - return '${jumlahDiterimaPerOrang} ${stokBantuan.satuan ?? ''}'; + return '$jumlahDiterimaPerOrang ${stokBantuan.satuan ?? ''}'; } String _formatRupiah(dynamic amount) { if (amount is num) { - return DateHelper.formatRupiah(amount); + return FormatHelper.formatRupiah(amount); } else if (amount is String) { try { double nilai = double.parse(amount); - return DateHelper.formatRupiah(nilai); + return FormatHelper.formatRupiah(nilai); } catch (e) { return 'Rp ${amount.replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}'; } diff --git a/lib/app/modules/donatur/views/donatur_view.dart b/lib/app/modules/donatur/views/donatur_view.dart index c56ed91..c3e6fd1 100644 --- a/lib/app/modules/donatur/views/donatur_view.dart +++ b/lib/app/modules/donatur/views/donatur_view.dart @@ -34,7 +34,7 @@ class DonaturView extends GetView { title: Obx(() { switch (controller.activeTabIndex.value) { case 0: - return const Text('Dashboard Donatur'); + return const Text('Dashboard'); case 1: return const Text('Skema Bantuan'); case 2: @@ -44,7 +44,7 @@ class DonaturView extends GetView { case 4: return const Text('Laporan Penyaluran'); default: - return const Text('Dashboard Donatur'); + return const Text('Dashboard'); } }), leading: IconButton( @@ -201,12 +201,20 @@ class DonaturView extends GetView { controller.profilePhotoUrl!.isNotEmpty ? NetworkImage(controller.profilePhotoUrl!) : null, - child: controller.profilePhotoUrl == null || - controller.profilePhotoUrl!.isEmpty - ? const Icon( - Icons.person, - color: Colors.white, - size: 40, + child: (controller.profilePhotoUrl == null || + controller.profilePhotoUrl!.isEmpty) + ? Text( + controller.nama.isNotEmpty + ? controller.nama + .toString() + .substring(0, 1) + .toUpperCase() + : '?', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.blue.shade700, + fontSize: 24, + ), ) : null, ), @@ -284,44 +292,175 @@ class DonaturView extends GetView { child: ListView( padding: EdgeInsets.zero, children: [ - ListTile( - leading: const Icon(Icons.person_outline), - title: const Text('Profil'), + _buildMenuCategory('Menu Utama'), + Obx(() => _buildMenuItem( + icon: Icons.dashboard_outlined, + activeIcon: Icons.dashboard, + title: 'Dashboard', + isSelected: controller.activeTabIndex.value == 0, + onTap: () { + Navigator.pop(context); + controller.activeTabIndex.value = 0; + }, + )), + Obx(() => _buildMenuItem( + icon: Icons.description_outlined, + activeIcon: Icons.description, + title: 'Skema Bantuan', + isSelected: controller.activeTabIndex.value == 1, + onTap: () { + Navigator.pop(context); + controller.activeTabIndex.value = 1; + }, + )), + Obx(() => _buildMenuItem( + icon: Icons.calendar_today_outlined, + activeIcon: Icons.calendar_today, + title: 'Jadwal Penyaluran', + isSelected: controller.activeTabIndex.value == 2, + onTap: () { + Navigator.pop(context); + controller.activeTabIndex.value = 2; + }, + )), + Obx(() => _buildMenuItem( + icon: Icons.add_box_outlined, + activeIcon: Icons.add_box, + title: 'Penitipan Bantuan', + isSelected: controller.activeTabIndex.value == 3, + onTap: () { + Navigator.pop(context); + controller.activeTabIndex.value = 3; + }, + )), + Obx(() => _buildMenuItem( + icon: Icons.assignment_outlined, + activeIcon: Icons.assignment, + title: 'Laporan Penyaluran', + isSelected: controller.activeTabIndex.value == 4, + onTap: () { + Navigator.pop(context); + controller.activeTabIndex.value = 4; + }, + )), + _buildMenuCategory('Pengaturan'), + _buildMenuItem( + icon: Icons.person_outline, + activeIcon: Icons.person, + title: 'Profil', onTap: () { Navigator.pop(context); Get.toNamed('/profile'); }, ), - ListTile( - leading: const Icon(Icons.history), - title: const Text('Riwayat Donasi'), + _buildMenuItem( + icon: Icons.info_outline, + activeIcon: Icons.info, + title: 'Tentang Kami', onTap: () { Navigator.pop(context); - // TODO: Implementasi riwayat donasi + Get.toNamed('/about'); }, ), - ListTile( - leading: const Icon(Icons.settings_outlined), - title: const Text('Pengaturan'), - onTap: () { - Navigator.pop(context); - // TODO: Implementasi pengaturan - }, - ), - const Divider(), - ListTile( - leading: const Icon(Icons.logout), - title: const Text('Keluar'), + _buildMenuItem( + icon: Icons.logout, + title: 'Keluar', onTap: () { Navigator.pop(context); controller.logout(); }, + isLogout: true, ), ], ), ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + '© ${DateTime.now().year} DisalurKita', + style: TextStyle( + fontSize: 12, + color: Colors.grey, + ), + textAlign: TextAlign.center, + ), + ), ], ), ); } + + Widget _buildMenuCategory(String title) { + return Padding( + padding: const EdgeInsets.only(left: 16, right: 16, top: 16, bottom: 8), + child: Text( + title, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.grey[600], + ), + ), + ); + } + + Widget _buildMenuItem({ + required IconData icon, + IconData? activeIcon, + required String title, + bool isSelected = false, + String? badge, + required Function() onTap, + bool isLogout = false, + }) { + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + color: isSelected + ? AppTheme.primaryColor.withOpacity(0.1) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + child: ListTile( + leading: Icon( + isSelected ? (activeIcon ?? icon) : icon, + color: isSelected + ? AppTheme.primaryColor + : (isLogout ? Colors.red : null), + ), + title: Text( + title, + style: TextStyle( + color: isSelected + ? AppTheme.primaryColor + : (isLogout ? Colors.red : null), + fontWeight: isSelected ? FontWeight.bold : null, + ), + ), + trailing: badge != null + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.circular(10), + ), + constraints: const BoxConstraints( + minWidth: 20, + minHeight: 20, + ), + child: Text( + badge, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + textAlign: TextAlign.center, + ), + ) + : null, + onTap: onTap, + ), + ); + } } diff --git a/lib/app/modules/laporan_penyaluran/controllers/laporan_penyaluran_controller.dart b/lib/app/modules/laporan_penyaluran/controllers/laporan_penyaluran_controller.dart index 6b3b4c0..22fbf89 100644 --- a/lib/app/modules/laporan_penyaluran/controllers/laporan_penyaluran_controller.dart +++ b/lib/app/modules/laporan_penyaluran/controllers/laporan_penyaluran_controller.dart @@ -11,7 +11,6 @@ import 'package:path_provider/path_provider.dart'; import 'dart:io'; import 'package:open_file/open_file.dart'; import 'package:flutter/services.dart'; -import 'package:intl/intl.dart'; import 'package:http/http.dart' as http; import 'package:penyaluran_app/app/utils/format_helper.dart'; @@ -635,7 +634,7 @@ class LaporanPenyaluranController extends GetxController { fontSize: 12, color: PdfColors.blue900)), pw.Text( - 'Tanggal: ${DateFormat('dd MMMM yyyy').format(DateTime.now())}', + 'Tanggal: ${FormatHelper.formatDateTime(DateTime.now())}', style: pw.TextStyle(font: ttf, fontSize: 10), ), ], @@ -708,8 +707,7 @@ class LaporanPenyaluranController extends GetxController { _buildPdfRow( 'Tanggal Laporan', laporan.tanggalLaporan != null - ? DateTimeHelper.formatDateTime( - laporan.tanggalLaporan!) + ? FormatHelper.formatDateTime(laporan.tanggalLaporan!) : '-', ttf, ttfBold), @@ -731,7 +729,7 @@ class LaporanPenyaluranController extends GetxController { _buildPdfRow( 'Tanggal Penyaluran', penyaluran.tanggalPenyaluran != null - ? DateTimeHelper.formatDateTime( + ? FormatHelper.formatDateTime( penyaluran.tanggalPenyaluran!) : '-', ttf, @@ -739,7 +737,7 @@ class LaporanPenyaluranController extends GetxController { _buildPdfRow( 'Tanggal Selesai', penyaluran.tanggalSelesai != null - ? DateTimeHelper.formatDateTime( + ? FormatHelper.formatDateTime( penyaluran.tanggalSelesai!) : '-', ttf, @@ -902,7 +900,7 @@ class LaporanPenyaluranController extends GetxController { final isUang = stokBantuan['is_uang'] == true; final formattedJumlah = isUang - ? 'Rp ${NumberFormat.currency(locale: 'id', symbol: '', decimalDigits: 0).format(jumlah)}' + ? FormatHelper.formatRupiah(jumlah) : '$jumlah ${stokBantuan['satuan'] ?? ''}'; return pw.TableRow( @@ -975,7 +973,7 @@ class LaporanPenyaluranController extends GetxController { final jumlahBantuan = penerima.jumlahBantuan ?? 0; final formattedJumlah = isUang - ? 'Rp ${NumberFormat.currency(locale: 'id', symbol: '', decimalDigits: 0).format(jumlahBantuan)}' + ? FormatHelper.formatRupiah(jumlahBantuan) : '$jumlahBantuan ${penerima.satuan ?? ''}'; return pw.TableRow( diff --git a/lib/app/modules/laporan_penyaluran/views/laporan_penyaluran_create_view.dart b/lib/app/modules/laporan_penyaluran/views/laporan_penyaluran_create_view.dart index 7f938e0..0526a9a 100644 --- a/lib/app/modules/laporan_penyaluran/views/laporan_penyaluran_create_view.dart +++ b/lib/app/modules/laporan_penyaluran/views/laporan_penyaluran_create_view.dart @@ -65,7 +65,7 @@ class LaporanPenyaluranCreateView extends GetView { controller.selectedPenyaluran.value! .tanggalPenyaluran != null - ? DateTimeHelper.formatDateTime(controller + ? FormatHelper.formatDateTime(controller .selectedPenyaluran.value!.tanggalPenyaluran!) : '-', ), @@ -73,7 +73,7 @@ class LaporanPenyaluranCreateView extends GetView { 'Tanggal Selesai', controller.selectedPenyaluran.value!.tanggalSelesai != null - ? DateTimeHelper.formatDateTime(controller + ? FormatHelper.formatDateTime(controller .selectedPenyaluran.value!.tanggalSelesai!) : '-', ), diff --git a/lib/app/modules/laporan_penyaluran/views/laporan_penyaluran_detail_view.dart b/lib/app/modules/laporan_penyaluran/views/laporan_penyaluran_detail_view.dart index 1670333..c0148ba 100644 --- a/lib/app/modules/laporan_penyaluran/views/laporan_penyaluran_detail_view.dart +++ b/lib/app/modules/laporan_penyaluran/views/laporan_penyaluran_detail_view.dart @@ -6,7 +6,6 @@ import 'package:penyaluran_app/app/utils/format_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'; import 'package:penyaluran_app/app/data/models/penerima_penyaluran_model.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -139,19 +138,33 @@ class LaporanPenyaluranDetailView extends GetView { // Informasi laporan Card( margin: EdgeInsets.zero, - elevation: 2, + elevation: 3, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(16), ), child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SectionHeader( - title: 'Informasi Laporan', + Row( + children: [ + Icon( + Icons.description, + color: AppTheme.primaryColor, + size: 24, + ), + const SizedBox(width: 10), + const Text( + 'Informasi Laporan', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], ), - const SizedBox(height: 16), + const Divider(height: 25), _buildInfoRow( 'Judul Laporan', laporan.judul, @@ -167,33 +180,46 @@ class LaporanPenyaluranDetailView extends GetView { if (penyaluran != null) Card( margin: EdgeInsets.zero, - elevation: 2, + elevation: 3, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(16), ), child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SectionHeader( - title: 'Informasi Penyaluran', - // subtitle: 'Detail informasi penyaluran terkait', + Row( + children: [ + Icon( + Icons.local_shipping, + color: AppTheme.primaryColor, + size: 24, + ), + const SizedBox(width: 10), + const Text( + 'Informasi Penyaluran', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], ), - const SizedBox(height: 16), + const Divider(height: 25), _buildInfoRow( 'Nama Penyaluran', penyaluran.nama ?? '-'), _buildInfoRow( 'Tanggal Penyaluran', penyaluran.tanggalPenyaluran != null - ? DateTimeHelper.formatDateTime( + ? FormatHelper.formatDateTime( penyaluran.tanggalPenyaluran!) : '-', ), _buildInfoRow( 'Tanggal Selesai', penyaluran.tanggalSelesai != null - ? DateTimeHelper.formatDateTime( + ? FormatHelper.formatDateTime( penyaluran.tanggalSelesai!) : '-', ), @@ -203,19 +229,30 @@ class LaporanPenyaluranDetailView extends GetView { 'Status Penyaluran', penyaluran.status ?? '-'), if (penyaluran.deskripsi != null && penyaluran.deskripsi!.isNotEmpty) ...[ - const SizedBox(height: 8), - const Text( + const SizedBox(height: 5), + Text( 'Deskripsi Penyaluran:', style: TextStyle( fontSize: 14, - fontWeight: FontWeight.bold, - color: Colors.grey, + color: Colors.grey[600], + fontWeight: FontWeight.w500, ), ), - const SizedBox(height: 4), - Text( - penyaluran.deskripsi!, - style: const TextStyle(fontSize: 14), + const SizedBox(height: 6), + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.grey.shade200), + ), + child: Text( + penyaluran.deskripsi!, + style: const TextStyle( + fontSize: 15, + ), + ), ), ], ], @@ -230,19 +267,33 @@ class LaporanPenyaluranDetailView extends GetView { const SizedBox(height: 24), Card( margin: EdgeInsets.zero, - elevation: 2, + elevation: 3, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(16), ), child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SectionHeader( - title: 'Lokasi Penyaluran', + Row( + children: [ + Icon( + Icons.location_on, + color: AppTheme.primaryColor, + size: 24, + ), + const SizedBox(width: 10), + const Text( + 'Lokasi Penyaluran', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], ), - const SizedBox(height: 16), + const Divider(height: 25), _buildInfoRow('Nama Lokasi', controller.lokasiPenyaluran['nama'] ?? '-'), _buildInfoRow( @@ -278,19 +329,29 @@ class LaporanPenyaluranDetailView extends GetView { controller.lokasiPenyaluran['keterangan'] .toString() .isNotEmpty) ...[ - const SizedBox(height: 16), - const Text( + const SizedBox(height: 5), + Text( 'Keterangan Lokasi:', style: TextStyle( fontSize: 14, - fontWeight: FontWeight.bold, - color: Colors.grey, + color: Colors.grey[600], + fontWeight: FontWeight.w500, ), ), - const SizedBox(height: 4), - Text( - controller.lokasiPenyaluran['keterangan'], - style: const TextStyle(fontSize: 14), + const SizedBox(height: 6), + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(10), + border: + Border.all(color: Colors.grey.shade200), + ), + child: Text( + controller.lokasiPenyaluran['keterangan'], + style: const TextStyle(fontSize: 15), + ), ), ], ], @@ -307,44 +368,99 @@ class LaporanPenyaluranDetailView extends GetView { const SizedBox(height: 24), Card( margin: EdgeInsets.zero, - elevation: 2, + elevation: 3, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(16), ), child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SectionHeader( - title: 'Stok Bantuan yang Digunakan', + Row( + children: [ + Icon( + Icons.inventory_2, + color: AppTheme.primaryColor, + size: 24, + ), + const SizedBox(width: 10), + const Text( + 'Stok Bantuan yang Digunakan', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], ), - const SizedBox(height: 16), + const Divider(height: 25), // Informasi kategori bantuan jika tersedia if (controller.kategoriBantuan.isNotEmpty) ...[ Container( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.blue.shade50, - borderRadius: BorderRadius.circular(10), - border: - Border.all(color: Colors.blue.shade100), + gradient: LinearGradient( + colors: [ + Colors.blue.shade100, + Colors.blue.shade50, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.1), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - Icon(Icons.category, - color: Colors.blue.shade700), - const SizedBox(width: 8), - Text( - 'Kategori Bantuan: ${controller.kategoriBantuan['nama'] ?? 'Tidak Diketahui'}', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - color: Colors.blue.shade800, + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: + BorderRadius.circular(8), + ), + child: Icon( + Icons.category, + color: Colors.blue.shade700, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + const Text( + 'Kategori Bantuan', + style: TextStyle( + fontSize: 13, + color: Colors.black54, + ), + ), + const SizedBox(height: 4), + Text( + controller.kategoriBantuan[ + 'nama'] ?? + 'Tidak Diketahui', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: Colors.blue.shade800, + ), + ), + ], ), ), ], @@ -352,21 +468,56 @@ class LaporanPenyaluranDetailView extends GetView { if (controller .kategoriBantuan['deskripsi'] != null) ...[ - const SizedBox(height: 8), - Text( - controller.kategoriBantuan['deskripsi'], - style: TextStyle( - color: Colors.blue.shade700, - fontSize: 14, + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.7), + borderRadius: + BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + Icons.info_outline, + size: 16, + color: Colors.blue.shade800, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + controller.kategoriBantuan[ + 'deskripsi'], + style: TextStyle( + color: Colors.blue.shade700, + fontSize: 14, + ), + ), + ), + ], ), ), ], ], ), ), - const SizedBox(height: 16), + const SizedBox(height: 20), ], + // Label daftar stok bantuan + Padding( + padding: + const EdgeInsets.only(bottom: 15, left: 4), + child: Text( + 'Daftar Stok Bantuan', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.grey[800], + ), + ), + ), + // Daftar stok bantuan ListView.builder( shrinkWrap: true, @@ -398,89 +549,183 @@ class LaporanPenyaluranDetailView extends GetView { final isUang = stokBantuan['is_uang'] == true; - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Card( - color: isUang - ? Colors.green.shade50 - : Colors.grey.shade50, - margin: EdgeInsets.zero, - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: isUang + ? [ + Colors.green.shade100, + Colors.green.shade50 + ] + : [ + Colors.grey.shade200, + Colors.grey.shade100 + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.2), + blurRadius: 5, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + // Jika perlu tambahkan aksi detail + }, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, children: [ - Expanded( - child: Text( - stokBantuan['nama'] ?? '-', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 15, + Row( + children: [ + Container( + padding: + const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isUang + ? Colors + .green.shade200 + : Colors + .blue.shade200, + borderRadius: + BorderRadius.circular( + 10), + ), + child: Icon( + isUang + ? Icons + .account_balance_wallet + : Icons.inventory, + color: isUang + ? Colors + .green.shade800 + : Colors + .blue.shade800, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment + .start, + children: [ + Text( + stokBantuan['nama'] ?? + '-', + style: + const TextStyle( + fontWeight: + FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox( + height: 4), + Text( + kategori, + style: TextStyle( + fontSize: 13, + color: isUang + ? Colors.green + .shade800 + : Colors.blue + .shade800, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets + .symmetric( + horizontal: 12, + vertical: 8), + decoration: BoxDecoration( + color: isUang + ? Colors + .green.shade700 + : Colors + .blue.shade700, + borderRadius: + BorderRadius.circular( + 20), + ), + child: Text( + isUang + ? FormatHelper + .formatRupiah( + jumlah) + : '$jumlah ${stokBantuan['satuan'] ?? ''}', + style: const TextStyle( + fontSize: 14, + fontWeight: + FontWeight.bold, + color: Colors.white, + ), + ), + ), + ], + ), + if (stokBantuan['deskripsi'] != + null && + stokBantuan['deskripsi'] + .toString() + .isNotEmpty) ...[ + const SizedBox(height: 12), + Container( + padding: + const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white + .withOpacity(0.7), + borderRadius: + BorderRadius.circular( + 8), + ), + child: Row( + children: [ + Icon( + Icons.info_outline, + size: 16, + color: isUang + ? Colors + .green.shade800 + : Colors + .blue.shade800, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + stokBantuan[ + 'deskripsi'], + style: TextStyle( + fontSize: 13, + color: Colors + .grey[700], + ), + ), + ), + ], ), ), - ), - Container( - padding: - const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4), - decoration: BoxDecoration( - color: isUang - ? Colors.green.shade100 - : Colors.blue.shade100, - borderRadius: - BorderRadius.circular(12), - ), - child: Text( - kategori, - style: TextStyle( - fontSize: 12, - color: isUang - ? Colors.green.shade800 - : Colors.blue.shade800, - ), - ), - ), + ], ], ), - const SizedBox(height: 8), - Row( - children: [ - const Text( - 'Jumlah digunakan: ', - style: TextStyle(fontSize: 14), - ), - Text( - isUang - ? 'Rp ${NumberFormat.currency(locale: 'id', symbol: '', decimalDigits: 0).format(jumlah)}' - : '$jumlah ${stokBantuan['satuan'] ?? ''}', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - ), - ), - ], - ), - if (stokBantuan['deskripsi'] != - null && - stokBantuan['deskripsi'] - .toString() - .isNotEmpty) ...[ - const SizedBox(height: 8), - Text( - stokBantuan['deskripsi'], - style: TextStyle( - fontSize: 13, - color: Colors.grey[600], - ), - ), - ], - ], + ), ), ), ), @@ -501,26 +746,59 @@ class LaporanPenyaluranDetailView extends GetView { const SizedBox(height: 24), Card( margin: EdgeInsets.zero, - elevation: 2, + elevation: 3, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(16), ), child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SectionHeader( - title: 'Daftar Penerima Bantuan', + Row( + children: [ + Icon( + Icons.people_alt, + color: AppTheme.primaryColor, + size: 24, + ), + const SizedBox(width: 10), + const Text( + 'Daftar Penerima', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: + AppTheme.primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '${controller.daftarPenerima.length} Penerima', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: AppTheme.primaryColor, + ), + ), + ), + ], ), - const SizedBox(height: 16), + const Divider(height: 25), + // Header Tabel Container( padding: const EdgeInsets.symmetric( - vertical: 8, horizontal: 12), + vertical: 12, horizontal: 15), decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(8), + color: AppTheme.primaryColor, + borderRadius: BorderRadius.circular(10), ), child: Row( children: const [ @@ -529,24 +807,17 @@ class LaporanPenyaluranDetailView extends GetView { child: Text( 'NIK', style: TextStyle( - fontWeight: FontWeight.bold), + fontWeight: FontWeight.bold, + color: Colors.white), ), ), Expanded( - flex: 3, + flex: 4, child: Text( 'Nama Penerima', style: TextStyle( - fontWeight: FontWeight.bold), - ), - ), - Expanded( - flex: 2, - child: Text( - 'Jumlah', - style: TextStyle( - fontWeight: FontWeight.bold), - textAlign: TextAlign.center, + fontWeight: FontWeight.bold, + color: Colors.white), ), ), Expanded( @@ -554,7 +825,8 @@ class LaporanPenyaluranDetailView extends GetView { child: Text( 'Status', style: TextStyle( - fontWeight: FontWeight.bold), + fontWeight: FontWeight.bold, + color: Colors.white), textAlign: TextAlign.center, ), ), @@ -563,75 +835,114 @@ class LaporanPenyaluranDetailView extends GetView { ), // Baris Data Penerima - ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: controller.daftarPenerima.length, - itemBuilder: (context, index) { - final penerima = - controller.daftarPenerima[index]; - final wargaNik = penerima.warga != null - ? penerima.warga!['nik'] ?? '-' - : '-'; - final wargaNama = penerima.warga != null - ? penerima.warga!['nama_lengkap'] ?? '-' - : '-'; + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: controller.daftarPenerima.length, + itemBuilder: (context, index) { + final penerima = + controller.daftarPenerima[index]; + final wargaNik = penerima.warga != null + ? penerima.warga!['nik'] ?? '-' + : '-'; + final wargaNama = penerima.warga != null + ? penerima.warga!['nama_lengkap'] ?? '-' + : '-'; - final jumlah = penerima.jumlahBantuan != null - ? '${penerima.jumlahBantuan} ${penerima.satuan ?? ''}' - : '-'; + final statusColor = _getStatusColor( + penerima.statusPenerimaan); + final isEven = index % 2 == 0; - return Container( - padding: const EdgeInsets.symmetric( - vertical: 12, horizontal: 12), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: Colors.grey.shade200), + return Container( + decoration: BoxDecoration( + color: isEven + ? Colors.grey.shade50 + : Colors.white, + border: Border( + bottom: BorderSide( + color: Colors.grey.shade200), + ), ), - ), - child: Row( - children: [ - Expanded( - flex: 3, - child: Text(wargaNik), - ), - Expanded( - flex: 3, - child: Text(wargaNama), - ), - Expanded( - flex: 2, - child: Text( - jumlah, - textAlign: TextAlign.center, - ), - ), - Expanded( - flex: 2, - child: Container( + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + // Tambahkan aksi detail penerima jika perlu + }, + child: Padding( padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 4), - decoration: BoxDecoration( - color: _getStatusColor( - penerima.statusPenerimaan), - borderRadius: - BorderRadius.circular(12), - ), - child: Text( - penerima.statusPenerimaan ?? '-', - style: const TextStyle( - fontSize: 10, - color: Colors.white, - ), - textAlign: TextAlign.center, + vertical: 12, horizontal: 15), + child: Row( + children: [ + Expanded( + flex: 3, + child: Row( + children: [ + Expanded( + child: Text( + wargaNik, + style: const TextStyle( + fontWeight: + FontWeight.w500, + ), + ), + ), + ], + ), + ), + Expanded( + flex: 4, + child: Text( + wargaNama, + style: const TextStyle( + fontWeight: FontWeight.w600, + ), + ), + ), + Expanded( + flex: 3, + child: Center( + child: Container( + padding: const EdgeInsets + .symmetric( + horizontal: 8, + vertical: 5), + decoration: BoxDecoration( + color: statusColor + .withOpacity(0.15), + borderRadius: + BorderRadius.circular( + 20), + border: Border.all( + color: statusColor + .withOpacity(0.3), + ), + ), + child: Text( + penerima.statusPenerimaan ?? + '-', + style: TextStyle( + fontSize: 12, + fontWeight: + FontWeight.bold, + color: statusColor, + ), + textAlign: + TextAlign.center, + ), + ), + ), + ), + ], ), ), ), - ], - ), - ); - }, + ), + ); + }, + ), ), ], ), @@ -648,70 +959,155 @@ class LaporanPenyaluranDetailView extends GetView { const SizedBox(height: 24), Card( margin: EdgeInsets.zero, - elevation: 2, + elevation: 3, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(16), ), child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SectionHeader( - title: 'Dokumentasi & Berita Acara', + Row( + children: [ + Icon( + Icons.file_copy, + color: AppTheme.primaryColor, + size: 24, + ), + const SizedBox(width: 10), + const Text( + 'Dokumentasi & Berita Acara', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], ), - const SizedBox(height: 16), + const Divider(height: 25), // Dokumentasi if (controller .selectedLaporan.value?.dokumentasiUrl != null) ...[ - const Text( - 'Dokumentasi:', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: Colors.grey, + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(12), + border: + Border.all(color: Colors.blue.shade100), ), - ), - const SizedBox(height: 8), - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Image.network( - controller - .selectedLaporan.value!.dokumentasiUrl!, - width: double.infinity, - height: 200, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => - Container( - width: double.infinity, - height: 50, - color: Colors.grey[200], - child: const Center( - child: Text('Gagal memuat gambar'), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.photo_library, + color: Colors.blue.shade700, + size: 22, + ), + const SizedBox(width: 10), + const Text( + 'Dokumentasi Kegiatan', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ], ), - ), - ), - ), - const SizedBox(height: 16), - Center( - child: ElevatedButton.icon( - onPressed: () async { - final Uri url = Uri.parse(controller - .selectedLaporan - .value! - .dokumentasiUrl!); - if (!await launchUrl(url)) { - throw Exception( - 'Tidak dapat membuka $url'); - } - }, - icon: const Icon(Icons.image), - label: const Text('Lihat Dokumentasi'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue, - ), + const SizedBox(height: 16), + Container( + height: 220, + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: + Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 5), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.network( + controller.selectedLaporan.value! + .dokumentasiUrl!, + width: double.infinity, + height: 220, + fit: BoxFit.cover, + errorBuilder: + (context, error, stackTrace) => + Container( + width: double.infinity, + height: 150, + color: Colors.grey[300], + child: const Center( + child: Column( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Icon( + Icons.image_not_supported, + color: Colors.grey, + size: 48, + ), + SizedBox(height: 10), + Text( + 'Gagal memuat gambar', + style: TextStyle( + color: Colors.grey, + fontWeight: + FontWeight.bold, + ), + ), + ], + ), + ), + ), + ), + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ElevatedButton.icon( + onPressed: () async { + final Uri url = Uri.parse(controller + .selectedLaporan + .value! + .dokumentasiUrl!); + if (!await launchUrl(url)) { + throw Exception( + 'Tidak dapat membuka $url'); + } + }, + icon: const Icon(Icons.open_in_new), + label: + const Text('Lihat Dokumentasi'), + style: ElevatedButton.styleFrom( + backgroundColor: + Colors.blue.shade700, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 10), + ), + ), + ], + ), + ], ), ), ], @@ -722,44 +1118,148 @@ class LaporanPenyaluranDetailView extends GetView { controller.selectedLaporan.value ?.beritaAcaraUrl != null) - const SizedBox(height: 16), + const SizedBox(height: 20), // Berita Acara if (controller .selectedLaporan.value?.beritaAcaraUrl != null) ...[ - const Text( - 'Berita Acara:', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: Colors.grey, + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.amber.shade50, + borderRadius: BorderRadius.circular(12), + border: + Border.all(color: Colors.amber.shade200), ), - ), - const SizedBox(height: 8), - ListTile( - leading: const Icon( - Icons.description, - color: Colors.blue, - size: 40, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.description, + color: Colors.amber.shade800, + size: 22, + ), + const SizedBox(width: 10), + const Text( + 'Berita Acara', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ], + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: + Colors.black.withOpacity(0.05), + blurRadius: 5, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.amber.shade100, + borderRadius: + BorderRadius.circular(10), + ), + child: Icon( + Icons.insert_drive_file, + color: Colors.amber.shade800, + size: 30, + ), + ), + const SizedBox(width: 15), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + const Text( + 'Dokumen Berita Acara', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 5), + Text( + 'Berkas Resmi Penyaluran', + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + ), + ], + ), + ), + IconButton( + onPressed: () async { + final Uri url = Uri.parse( + controller.selectedLaporan + .value!.beritaAcaraUrl!); + if (!await launchUrl(url)) { + throw Exception( + 'Tidak dapat membuka $url'); + } + }, + icon: Icon( + Icons.file_open, + color: Colors.amber.shade800, + size: 24, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ElevatedButton.icon( + onPressed: () async { + final Uri url = Uri.parse(controller + .selectedLaporan + .value! + .beritaAcaraUrl!); + if (!await launchUrl(url)) { + throw Exception( + 'Tidak dapat membuka $url'); + } + }, + icon: const Icon(Icons.download), + label: + const Text('Unduh Berita Acara'), + style: ElevatedButton.styleFrom( + backgroundColor: + Colors.amber.shade800, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 10), + ), + ), + ], + ), + ], ), - title: const Text( - 'Dokumen Berita Acara', - style: TextStyle(fontWeight: FontWeight.bold), - ), - subtitle: - const Text('Tap untuk membuka dokumen'), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - side: BorderSide(color: Colors.grey.shade300), - ), - onTap: () async { - final Uri url = Uri.parse(controller - .selectedLaporan.value!.beritaAcaraUrl!); - if (!await launchUrl(url)) { - throw Exception('Tidak dapat membuka $url'); - } - }, ), ], ], @@ -768,63 +1268,91 @@ class LaporanPenyaluranDetailView extends GetView { ), ], ), - const SizedBox(height: 24), + const SizedBox(height: 30), // Tombol aksi - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - if (laporan.status != 'FINAL') - Expanded( - child: ElevatedButton.icon( - onPressed: () => - _showFinalizeConfirmation(context, laporanId), - icon: const Icon(Icons.check_circle), - label: const Text('Finalisasi'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.green, - padding: const EdgeInsets.symmetric(vertical: 12), - ), - ), + Container( + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.2), + blurRadius: 10, + offset: const Offset(0, -5), ), - if (laporan.status != 'FINAL') const SizedBox(width: 12), - Expanded( - child: Obx(() => ElevatedButton.icon( - onPressed: controller.isExporting.value - ? null - : () { - if (controller.selectedLaporan.value != - null && - controller.selectedPenyaluran.value != - null) { - controller.exportToPdf( - controller.selectedLaporan.value!, - controller.selectedPenyaluran.value!); - } - }, - icon: controller.isExporting.value - ? SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - Colors.white), - ), - ) - : const Icon(Icons.download), - label: Text(controller.isExporting.value - ? 'Mengekspor...' - : 'Export PDF'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue, - padding: const EdgeInsets.symmetric(vertical: 12), - disabledBackgroundColor: - Colors.blue.withOpacity(0.7), + ], + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + if (laporan.status != 'FINAL') + Expanded( + child: ElevatedButton.icon( + onPressed: () => + _showFinalizeConfirmation(context, laporanId), + icon: const Icon(Icons.check_circle), + label: const Text('Finalisasi'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + elevation: 0, + ), ), - )), + ), + if (laporan.status != 'FINAL') const SizedBox(width: 15), + Expanded( + child: Obx(() => ElevatedButton.icon( + onPressed: controller.isExporting.value + ? null + : () { + if (controller.selectedLaporan.value != + null && + controller.selectedPenyaluran.value != + null) { + controller.exportToPdf( + controller.selectedLaporan.value!, + controller + .selectedPenyaluran.value!); + } + }, + icon: controller.isExporting.value + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: + AlwaysStoppedAnimation( + Colors.white), + ), + ) + : const Icon(Icons.download), + label: Text(controller.isExporting.value + ? 'Mengekspor...' + : 'Export PDF'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + padding: + const EdgeInsets.symmetric(vertical: 14), + disabledBackgroundColor: + Colors.blue.withOpacity(0.7), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + elevation: 0, + ), + )), + ), + ], ), - ], + ), ), ], ), @@ -835,45 +1363,98 @@ class LaporanPenyaluranDetailView extends GetView { // Membangun header status Widget _buildStatusHeader(String status, DateTime? tanggalLaporan) { + final statusColor = _getStatusColor(status); + final statusIcon = _getStatusIcon(status); + return Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(20), decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), + gradient: LinearGradient( + colors: [ + AppTheme.primaryColor.withOpacity(0.7), + AppTheme.primaryColor.withOpacity(0.5), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.3), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ], ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - 'Status Laporan', - style: TextStyle( - fontSize: 14, - color: AppTheme.primaryColor, + Row( + children: [ + Icon( + statusIcon, + color: Colors.white, + size: 24, + ), + const SizedBox(width: 10), + Text( + 'Status Laporan', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ], + ), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: statusColor, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + status, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.white, + ), ), ), - const SizedBox(height: 6), - StatusBadge(status: status), ], ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, + const SizedBox(height: 15), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - 'Tanggal Laporan', - style: TextStyle( - fontSize: 14, - color: AppTheme.primaryColor, - ), + Row( + children: [ + const Icon( + Icons.calendar_today, + color: Colors.white70, + size: 18, + ), + const SizedBox(width: 8), + Text( + 'Tanggal Laporan:', + style: const TextStyle( + fontSize: 14, + color: Colors.white70, + ), + ), + ], ), - const SizedBox(height: 6), Text( - DateTimeHelper.formatDateTime(tanggalLaporan), + FormatHelper.formatDateTime(tanggalLaporan), style: const TextStyle( - fontSize: 14, + fontSize: 15, fontWeight: FontWeight.bold, + color: Colors.white, ), ), ], @@ -883,35 +1464,18 @@ class LaporanPenyaluranDetailView extends GetView { ); } - // Membangun baris informasi - Widget _buildInfoRow(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, - ), - ), - ), - ], - ), - ); + // Helper untuk mendapatkan icon status + IconData _getStatusIcon(String status) { + switch (status.toUpperCase()) { + case 'FINAL': + return Icons.check_circle; + case 'DRAFT': + return Icons.edit_note; + case 'DIPROSES': + return Icons.sync; + default: + return Icons.info; + } } // Helper untuk mendapatkan warna status penerimaan @@ -920,18 +1484,58 @@ class LaporanPenyaluranDetailView extends GetView { switch (status.toUpperCase()) { case 'DITERIMA': + case 'FINAL': return Colors.green; case 'TERTUNDA': + case 'DRAFT': return Colors.orange; case 'DIBATALKAN': return Colors.red; case 'SEDANG DIPROSES': + case 'DIPROSES': return Colors.blue; default: return Colors.grey; } } + // Membangun baris informasi + Widget _buildInfoRow(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 6), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.grey.shade200), + ), + child: Text( + value, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } + // Menampilkan dokumen Widget _buildDocumentSection(String judul, String? url, IconData icon) { if (url == null || url.isEmpty) { @@ -1007,9 +1611,64 @@ class LaporanPenyaluranDetailView extends GetView { context: context, builder: (BuildContext context) { return AlertDialog( - title: const Text('Finalisasi Laporan'), - content: const Text( - 'Laporan yang sudah difinalisasi tidak dapat diubah lagi. Lanjutkan?'), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + title: Row( + children: [ + Icon( + Icons.check_circle, + color: Colors.green, + size: 28, + ), + const SizedBox(width: 10), + const Text( + 'Finalisasi Laporan', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.amber.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.amber.shade200), + ), + child: Row( + children: [ + Icon( + Icons.warning_amber_rounded, + color: Colors.amber.shade800, + size: 24, + ), + const SizedBox(width: 10), + const Expanded( + child: Text( + 'Laporan yang sudah difinalisasi tidak dapat diubah lagi.', + style: TextStyle( + fontSize: 14, + color: Colors.black87, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 15), + const Text( + 'Apakah Anda yakin ingin memfinalisasi laporan ini?', + style: TextStyle(fontSize: 15), + ), + ], + ), actions: [ TextButton( onPressed: () { @@ -1017,12 +1676,21 @@ class LaporanPenyaluranDetailView extends GetView { }, child: Text( 'Batal', - style: TextStyle(color: AppTheme.primaryColor), + style: TextStyle( + color: Colors.grey[800], + fontWeight: FontWeight.w600, + ), ), ), ElevatedButton( style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primaryColor, + backgroundColor: Colors.green, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: + const EdgeInsets.symmetric(horizontal: 15, vertical: 10), ), onPressed: () { Navigator.of(context).pop(); @@ -1031,6 +1699,8 @@ class LaporanPenyaluranDetailView extends GetView { child: const Text('Finalisasi'), ), ], + actionsPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), ); }, ); @@ -1042,9 +1712,64 @@ class LaporanPenyaluranDetailView extends GetView { context: context, builder: (BuildContext context) { return AlertDialog( - title: const Text('Hapus Laporan'), - content: const Text( - 'Laporan yang dihapus tidak dapat dikembalikan. Lanjutkan?'), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + title: Row( + children: [ + Icon( + Icons.delete_forever, + color: Colors.red, + size: 28, + ), + const SizedBox(width: 10), + const Text( + 'Hapus Laporan', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.shade200), + ), + child: Row( + children: [ + Icon( + Icons.error_outline, + color: Colors.red.shade800, + size: 24, + ), + const SizedBox(width: 10), + const Expanded( + child: Text( + 'Laporan yang dihapus tidak dapat dikembalikan.', + style: TextStyle( + fontSize: 14, + color: Colors.black87, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 15), + const Text( + 'Apakah Anda yakin ingin menghapus laporan ini?', + style: TextStyle(fontSize: 15), + ), + ], + ), actions: [ TextButton( onPressed: () { @@ -1052,12 +1777,21 @@ class LaporanPenyaluranDetailView extends GetView { }, child: Text( 'Batal', - style: TextStyle(color: AppTheme.primaryColor), + style: TextStyle( + color: Colors.grey[800], + fontWeight: FontWeight.w600, + ), ), ), ElevatedButton( style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.errorColor, + backgroundColor: Colors.red, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: + const EdgeInsets.symmetric(horizontal: 15, vertical: 10), ), onPressed: () { Navigator.of(context).pop(); @@ -1066,8 +1800,42 @@ class LaporanPenyaluranDetailView extends GetView { child: const Text('Hapus'), ), ], + actionsPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), ); }, ); } + + // Tambahkan helper untuk membuat counter status + Widget _buildStatusCounter(String status, Color color, int count) { + return Column( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: color.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: Text( + count.toString(), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: color, + ), + ), + ), + const SizedBox(height: 8), + Text( + status, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: color, + ), + ), + ], + ); + } } diff --git a/lib/app/modules/laporan_penyaluran/views/laporan_penyaluran_view.dart b/lib/app/modules/laporan_penyaluran/views/laporan_penyaluran_view.dart index f31c874..c0cbbd1 100644 --- a/lib/app/modules/laporan_penyaluran/views/laporan_penyaluran_view.dart +++ b/lib/app/modules/laporan_penyaluran/views/laporan_penyaluran_view.dart @@ -6,7 +6,6 @@ import 'package:penyaluran_app/app/theme/app_theme.dart'; import 'package:penyaluran_app/app/utils/format_helper.dart'; import 'package:penyaluran_app/app/widgets/custom_app_bar.dart'; import 'package:penyaluran_app/app/widgets/status_badge.dart'; -import 'package:intl/intl.dart'; class LaporanPenyaluranView extends GetView { const LaporanPenyaluranView({super.key}); @@ -255,8 +254,8 @@ class LaporanPenyaluranView extends GetView { overflow: TextOverflow.ellipsis, ), ), - const SizedBox(width: 8), - StatusBadge(status: laporan.status ?? 'DRAFT'), + // const SizedBox(width: 8), + // StatusBadge(status: laporan.status ?? 'DRAFT'), ], ), ), @@ -273,10 +272,11 @@ class LaporanPenyaluranView extends GetView { Icons.calendar_today, 'Tanggal', laporan.tanggalLaporan != null - ? DateTimeHelper.formatDateTime( + ? FormatHelper.formatDateTime( laporan.tanggalLaporan!) : '-', ), + const SizedBox(width: 16), _buildInfoItem( Icons.description, 'Status', @@ -538,8 +538,8 @@ class LaporanPenyaluranView extends GetView { const SizedBox(width: 4), Text( penyaluran.tanggalSelesai != null - ? DateFormat('dd/MM/yyyy') - .format(penyaluran.tanggalSelesai!) + ? FormatHelper.formatDateTime( + penyaluran.tanggalSelesai!) : '-', style: TextStyle( fontSize: 12, diff --git a/lib/app/modules/penyaluran/detail_penyaluran_page.dart b/lib/app/modules/penyaluran/detail_penyaluran_page.dart deleted file mode 100644 index 8b13789..0000000 --- a/lib/app/modules/penyaluran/detail_penyaluran_page.dart +++ /dev/null @@ -1 +0,0 @@ - diff --git a/lib/app/modules/petugas_desa/bindings/jadwal_penyaluran_binding.dart b/lib/app/modules/petugas_desa/bindings/jadwal_penyaluran_binding.dart new file mode 100644 index 0000000..3d09ea1 --- /dev/null +++ b/lib/app/modules/petugas_desa/bindings/jadwal_penyaluran_binding.dart @@ -0,0 +1,20 @@ +import 'package:get/get.dart'; +import 'package:penyaluran_app/app/modules/petugas_desa/controllers/jadwal_penyaluran_controller.dart'; +import 'package:penyaluran_app/app/services/jadwal_update_service.dart'; + +class JadwalPenyaluranBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut( + () => JadwalPenyaluranController(), + ); + + // Register service untuk komunikasi pembaruan jadwal + if (!Get.isRegistered()) { + Get.lazyPut( + () => JadwalUpdateService(), + fenix: true, // Pastikan service tetap aktif selama aplikasi berjalan + ); + } + } +} diff --git a/lib/app/modules/petugas_desa/components/calendar_view_widget.dart b/lib/app/modules/petugas_desa/components/calendar_view_widget.dart index 9cef6c9..95e2e00 100644 --- a/lib/app/modules/petugas_desa/components/calendar_view_widget.dart +++ b/lib/app/modules/petugas_desa/components/calendar_view_widget.dart @@ -298,7 +298,7 @@ class CalendarViewWidget extends StatelessWidget { for (var jadwal in allJadwal) { if (jadwal.tanggalPenyaluran != null) { DateTime jadwalDate = - DateTimeHelper.toLocalDateTime(jadwal.tanggalPenyaluran!); + FormatHelper.toLocalDateTime(jadwal.tanggalPenyaluran!); if (jadwalDate .isAfter(firstDayOfMonth.subtract(const Duration(days: 1))) && @@ -346,7 +346,7 @@ class CalendarViewWidget extends StatelessWidget { void _showAppointmentDetails(BuildContext context, Appointment appointment) { final String formattedDate = - DateTimeHelper.formatDateIndonesian(appointment.startTime); + FormatHelper.formatDateIndonesian(appointment.startTime); // Dapatkan status dari ID jadwal String? status = _getStatusFromAppointmentId(appointment.id); diff --git a/lib/app/modules/petugas_desa/components/jadwal_section_widget.dart b/lib/app/modules/petugas_desa/components/jadwal_section_widget.dart index 68537ee..bcbee64 100644 --- a/lib/app/modules/petugas_desa/components/jadwal_section_widget.dart +++ b/lib/app/modules/petugas_desa/components/jadwal_section_widget.dart @@ -207,7 +207,7 @@ class JadwalSectionWidget extends StatelessWidget { // Format tanggal dan waktu menggunakan helper String formattedDateTime = - DateTimeHelper.formatDateTime(jadwal.tanggalPenyaluran); + FormatHelper.formatDateTime(jadwal.tanggalPenyaluran); // Dapatkan nama lokasi dan kategori String lokasiName = diff --git a/lib/app/modules/petugas_desa/controllers/detail_penyaluran_controller.dart b/lib/app/modules/petugas_desa/controllers/detail_penyaluran_controller.dart index 18cfcfb..f50ef4b 100644 --- a/lib/app/modules/petugas_desa/controllers/detail_penyaluran_controller.dart +++ b/lib/app/modules/petugas_desa/controllers/detail_penyaluran_controller.dart @@ -211,18 +211,16 @@ class DetailPenyaluranController extends GetxController { .eq('id', penerima.id!) .single(); - if (penerimaData != null) { - final String stokBantuanId = penerimaData['stok_bantuan_id']; - final double jumlah = penerimaData['jumlah_bantuan'] is int - ? penerimaData['jumlah_bantuan'].toDouble() - : penerimaData['jumlah_bantuan']; + final String stokBantuanId = penerimaData['stok_bantuan_id']; + final double jumlah = penerimaData['jumlah_bantuan'] is int + ? penerimaData['jumlah_bantuan'].toDouble() + : penerimaData['jumlah_bantuan']; - // Kurangi stok dan catat riwayat - final petugasId = _supabaseService.client.auth.currentUser?.id; - if (petugasId != null) { - await _supabaseService.kurangiStokDariPenyaluran( - penerima.id!, stokBantuanId, jumlah, petugasId); - } + // Kurangi stok dan catat riwayat + final petugasId = _supabaseService.client.auth.currentUser?.id; + if (petugasId != null) { + await _supabaseService.kurangiStokDariPenyaluran( + penerima.id!, stokBantuanId, jumlah, petugasId); } // Refresh data setelah konfirmasi berhasil diff --git a/lib/app/modules/petugas_desa/controllers/jadwal_penyaluran_controller.dart b/lib/app/modules/petugas_desa/controllers/jadwal_penyaluran_controller.dart index aaca91b..4cb1f86 100644 --- a/lib/app/modules/petugas_desa/controllers/jadwal_penyaluran_controller.dart +++ b/lib/app/modules/petugas_desa/controllers/jadwal_penyaluran_controller.dart @@ -11,14 +11,21 @@ import 'package:penyaluran_app/app/utils/format_helper.dart'; import 'dart:async'; import 'dart:convert'; import 'package:crypto/crypto.dart'; +import 'package:penyaluran_app/app/services/jadwal_update_service.dart'; +import 'package:penyaluran_app/app/services/notification_service.dart'; +import 'package:penyaluran_app/app/modules/petugas_desa/controllers/counter_service.dart'; class JadwalPenyaluranController extends GetxController { final AuthController _authController = Get.find(); final SupabaseService _supabaseService = SupabaseService.to; + late final JadwalUpdateService _jadwalUpdateService; + late final StreamSubscription _jadwalUpdateSubscription; SupabaseService get supabaseService => _supabaseService; final RxBool isLoading = false.obs; + final RxBool isLoadingStatusUpdate = false.obs; + final RxBool isLokasiLoading = false.obs; // Indeks kategori yang dipilih untuk filter final RxInt selectedCategoryIndex = 0.obs; @@ -52,6 +59,21 @@ class JadwalPenyaluranController extends GetxController { @override void onInit() { super.onInit(); + + // Inisialisasi JadwalUpdateService + if (Get.isRegistered()) { + _jadwalUpdateService = Get.find(); + } else { + _jadwalUpdateService = Get.put(JadwalUpdateService()); + } + + // Daftarkan controller ini untuk menerima pembaruan + _jadwalUpdateService.registerForUpdates('JadwalPenyaluranController'); + + // Berlangganan ke pembaruan jadwal + _jadwalUpdateSubscription = + _jadwalUpdateService.jadwalUpdateStream.listen(_handleJadwalUpdate); + loadJadwalData(); loadPermintaanPenjadwalanData(); loadLokasiPenyaluranData(); @@ -67,100 +89,444 @@ class JadwalPenyaluranController extends GetxController { searchController.dispose(); // Hentikan timer jika ada _stopJadwalCheckTimer(); + // Berhenti berlangganan pembaruan jadwal + _jadwalUpdateSubscription.cancel(); + // Batalkan pendaftaran controller + _jadwalUpdateService.unregisterFromUpdates('JadwalPenyaluranController'); super.onClose(); } // Timer untuk memeriksa jadwal secara berkala Timer? _jadwalCheckTimer; + Timer? + _intensiveCheckTimer; // Timer untuk pengecekan intensif mendekati waktu penyaluran + final RxBool _intensiveCheckActive = false.obs; // Status pengecekan intensif void _startJadwalCheckTimer() { - // Periksa jadwal setiap 1 menit - _jadwalCheckTimer = Timer.periodic(const Duration(minutes: 1), (_) { - checkAndUpdateJadwalStatus(); + // Dengan fitur realtime yang sudah aktif, kita bisa mengurangi frekuensi polling + // Cek setiap 30 detik sebagai fallback untuk realtime + _jadwalCheckTimer = Timer.periodic(const Duration(seconds: 30), (_) { + if (!isLoadingStatusUpdate.value) { + checkAndUpdateJadwalStatus(); + } }); // Periksa jadwal segera saat aplikasi dimulai checkAndUpdateJadwalStatus(); + + // Log info untuk debugging + print('Jadwal check timer started with 30 seconds interval'); + + // Mulai juga pengecekan jadwal yang akan datang + _startUpcomingJadwalCheck(); } void _stopJadwalCheckTimer() { _jadwalCheckTimer?.cancel(); _jadwalCheckTimer = null; + _intensiveCheckTimer?.cancel(); + _intensiveCheckTimer = null; + } + + // Metode baru untuk memeriksa jadwal mendatang dan memulai pemeriksaan intensif jika perlu + void _startUpcomingJadwalCheck() { + Timer.periodic(const Duration(minutes: 1), (timer) { + // Jika sudah ada timer intensif yang berjalan, tidak perlu melakukan pengecekan lagi + if (_intensiveCheckActive.value) return; + + final now = DateTime.now(); + bool foundUpcomingJadwal = false; + + // Periksa apakah ada jadwal yang akan aktif dalam 10 menit ke depan + for (var jadwal in jadwalMendatang) { + if (jadwal.tanggalPenyaluran != null && + jadwal.status == 'DIJADWALKAN') { + final jadwalTime = jadwal.tanggalPenyaluran!; + final diff = jadwalTime.difference(now).inMinutes; + + // Jika ada jadwal dalam 10 menit ke depan, mulai pemeriksaan intensif + if (diff >= 0 && diff <= 10) { + print( + 'Found upcoming jadwal in $diff minutes: ${jadwal.id} - ${jadwal.nama}'); + foundUpcomingJadwal = true; + break; + } + } + } + + // Jika ditemukan jadwal yang akan datang, mulai pemeriksaan intensif + if (foundUpcomingJadwal && !_intensiveCheckActive.value) { + _startIntensiveCheck(); + } + }); + } + + // Metode untuk memulai pemeriksaan intensif untuk jadwal yang mendekati waktu + void _startIntensiveCheck() { + if (_intensiveCheckActive.value) return; + + _intensiveCheckActive.value = true; + print('Starting intensive jadwal check every 5 seconds'); + + // Periksa setiap 5 detik + _intensiveCheckTimer = Timer.periodic(const Duration(seconds: 5), (timer) { + if (!isLoadingStatusUpdate.value) { + checkAndUpdateJadwalStatus(); + } + + // Periksa apakah masih perlu melakukan pemeriksaan intensif + final now = DateTime.now(); + bool needIntensiveCheck = false; + + for (var jadwal in jadwalMendatang) { + if (jadwal.tanggalPenyaluran != null && + jadwal.status == 'DIJADWALKAN') { + final jadwalTime = jadwal.tanggalPenyaluran!; + final diff = jadwalTime.difference(now).inMinutes; + + // Jika masih ada jadwal dalam 10 menit ke depan, lanjutkan pemeriksaan + if (diff >= -5 && diff <= 10) { + needIntensiveCheck = true; + break; + } + } + } + + // Jika tidak ada lagi jadwal yang mendekati waktu, hentikan pemeriksaan intensif + if (!needIntensiveCheck) { + _stopIntensiveCheck(); + } + }); + } + + // Metode untuk menghentikan pemeriksaan intensif + void _stopIntensiveCheck() { + _intensiveCheckTimer?.cancel(); + _intensiveCheckTimer = null; + _intensiveCheckActive.value = false; + print('Stopping intensive jadwal check'); + } + + // Handler untuk menerima pembaruan jadwal dari service + void _handleJadwalUpdate(Map updateData) { + if (updateData['type'] == 'status_update') { + // Update lokal jika jadwal yang diperbarui ada di salah satu list + final jadwalId = updateData['jadwal_id']; + final newStatus = updateData['new_status']; + + // Periksa dan update jadwal di berbagai daftar + _updateJadwalStatusLocally(jadwalId, newStatus); + } else if (updateData['type'] == 'reload_required') { + // Muat ulang data jika diminta + loadJadwalData(); + loadPermintaanPenjadwalanData(); + } else if (updateData['type'] == 'check_required') { + // Segera periksa status jadwal + if (!isLoadingStatusUpdate.value) { + print( + 'Received check_required signal, checking jadwal status immediately'); + checkAndUpdateJadwalStatus(); + } else { + print('Already checking jadwal status, ignoring check_required signal'); + } + } + } + + // Perbarui status jadwal secara lokal tanpa perlu memanggil API lagi + void _updateJadwalStatusLocally(String jadwalId, String newStatus) { + bool updated = false; + print( + 'Updating jadwal status locally - ID: $jadwalId, New Status: $newStatus'); + + // Periksa jadwal aktif + final jadwalAktifIndex = + jadwalAktif.indexWhere((jadwal) => jadwal.id == jadwalId); + if (jadwalAktifIndex >= 0) { + print('Found in jadwalAktif at index $jadwalAktifIndex'); + jadwalAktif[jadwalAktifIndex] = + jadwalAktif[jadwalAktifIndex].copyWith(status: newStatus); + updated = true; + } + + // Periksa jadwal mendatang + final jadwalMendatangIndex = + jadwalMendatang.indexWhere((jadwal) => jadwal.id == jadwalId); + if (jadwalMendatangIndex >= 0) { + print('Found in jadwalMendatang at index $jadwalMendatangIndex'); + jadwalMendatang[jadwalMendatangIndex] = + jadwalMendatang[jadwalMendatangIndex].copyWith(status: newStatus); + updated = true; + } + + // Periksa jadwal terlaksana + final jadwalTerlaksanaIndex = + jadwalTerlaksana.indexWhere((jadwal) => jadwal.id == jadwalId); + if (jadwalTerlaksanaIndex >= 0) { + print('Found in jadwalTerlaksana at index $jadwalTerlaksanaIndex'); + jadwalTerlaksana[jadwalTerlaksanaIndex] = + jadwalTerlaksana[jadwalTerlaksanaIndex].copyWith(status: newStatus); + updated = true; + } + + // Jika perlu, reorganisasi daftar berdasarkan status baru + if (updated) { + print('Status updated locally, reorganizing lists'); + _reorganizeJadwalLists(); + + // Perbarui counter penyaluran setelah reorganisasi daftar + _updatePenyaluranCounters(); + } else { + print( + 'Jadwal with ID $jadwalId not found in any list, refreshing data from server'); + // Jika jadwal tidak ditemukan di daftar lokal, muat ulang data + loadJadwalData(); + } + } + + // Reorganisasi daftar jadwal berdasarkan status mereka + void _reorganizeJadwalLists() { + // Filter jadwal yang seharusnya pindah dari satu list ke list lain + + // Jadwal yang seharusnya pindah dari aktif ke terlaksana + final completedJadwal = jadwalAktif + .where((j) => j.status == 'TERLAKSANA' || j.status == 'BATALTERLAKSANA') + .toList(); + if (completedJadwal.isNotEmpty) { + jadwalAktif.removeWhere( + (j) => j.status == 'TERLAKSANA' || j.status == 'BATALTERLAKSANA'); + jadwalTerlaksana.addAll(completedJadwal); + } + + // Jadwal yang seharusnya pindah dari mendatang ke aktif + final activeJadwal = + jadwalMendatang.where((j) => j.status == 'AKTIF').toList(); + if (activeJadwal.isNotEmpty) { + jadwalMendatang.removeWhere((j) => j.status == 'AKTIF'); + jadwalAktif.addAll(activeJadwal); + } + + // Jadwal yang seharusnya pindah dari mendatang ke terlaksana + final expiredJadwal = jadwalMendatang + .where((j) => j.status == 'TERLAKSANA' || j.status == 'BATALTERLAKSANA') + .toList(); + if (expiredJadwal.isNotEmpty) { + jadwalMendatang.removeWhere( + (j) => j.status == 'TERLAKSANA' || j.status == 'BATALTERLAKSANA'); + jadwalTerlaksana.addAll(expiredJadwal); + } + + // Memicu pembaruan UI + jadwalAktif.refresh(); + jadwalMendatang.refresh(); + jadwalTerlaksana.refresh(); + } + + // Metode baru untuk memperbarui counter penyaluran + void _updatePenyaluranCounters() { + try { + // Dapatkan jumlah jadwal untuk setiap status + int dijadwalkan = + jadwalMendatang.where((j) => j.status == 'DIJADWALKAN').length; + int aktif = jadwalAktif.where((j) => j.status == 'AKTIF').length; + int batal = + jadwalTerlaksana.where((j) => j.status == 'BATALTERLAKSANA').length; + int terlaksana = + jadwalTerlaksana.where((j) => j.status == 'TERLAKSANA').length; + + // Hitung total jadwal aktif untuk tab hari ini + int jadwalHariIni = jadwalAktif.length; + + // Perbarui counter jadwal + if (Get.isRegistered()) { + final counterService = Get.find(); + counterService.updateJadwalCounter(jadwalHariIni); + } + + print( + 'Jadwal counters updated - Aktif: $aktif, Dijadwalkan: $dijadwalkan, Terlaksana: $terlaksana, Batal: $batal'); + } catch (e) { + print('Error updating jadwal counters: $e'); + } } // Memeriksa dan memperbarui status jadwal Future checkAndUpdateJadwalStatus() async { + if (isLoadingStatusUpdate.value) return; + + isLoadingStatusUpdate.value = true; + print('Starting jadwal status check at ${DateTime.now()}'); + try { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); - List jadwalToUpdate = []; - List jadwalTerlewat = []; + // Kelompokkan jadwal yang perlu diperbarui untuk mengurangi jumlah operasi database + final Map jadwalUpdates = {}; + final List jadwalToUpdate = []; + final List jadwalTerlewat = []; - for (var jadwal in jadwalAktif) { - if (jadwal.tanggalPenyaluran != null) { - final jadwalDateTime = - DateTimeHelper.toLocalDateTime(jadwal.tanggalPenyaluran!); - final jadwalDate = DateTime( - jadwalDateTime.year, - jadwalDateTime.month, - jadwalDateTime.day, - ); + print('Checking ${jadwalMendatang.length} upcoming schedules'); - if (isSameDay(jadwalDate, today)) { - if (now.isAfter(jadwalDateTime) || - now.isAtSameMomentAs(jadwalDateTime)) { - if (jadwal.status == 'DIJADWALKAN') { - if (now - .isBefore(jadwalDateTime.add(const Duration(hours: 2)))) { - await _supabaseService.updateJadwalStatus( - jadwal.id!, 'AKTIF'); - jadwalToUpdate.add(jadwal); - } else { - await _supabaseService.updateJadwalStatus( - jadwal.id!, 'BATALTERLAKSANA'); - jadwalTerlewat.add(jadwal); - } - } else if (jadwal.status == 'AKTIF') { - if (now.isAfter(jadwalDateTime.add(const Duration(hours: 2)))) { - await _supabaseService.updateJadwalStatus( - jadwal.id!, 'BATALTERLAKSANA'); - jadwalTerlewat.add(jadwal); - } + // Proses semua jadwal yang perlu diperbarui + for (var jadwal in jadwalMendatang) { + if (jadwal.tanggalPenyaluran != null && jadwal.id != null) { + final jadwalDate = jadwal.tanggalPenyaluran!; + + // Log untuk debugging waktu pemeriksaan + print( + 'Checking jadwal: ${jadwal.id} - ${jadwal.nama} scheduled for ${jadwal.tanggalPenyaluran}'); + print('Current time: $now, Jadwal time: $jadwalDate'); + + // Periksa apakah jadwal sudah melewati waktunya + // Kita gunakan isAtSameMomentAs atau isAfter untuk menangkap dengan tepat + if (now.isAfter(jadwalDate) || now.isAtSameMomentAs(jadwalDate)) { + print('Jadwal time has passed/reached for ${jadwal.id}'); + + // Batasan 2 jam untuk status aktif + final batasAktif = jadwalDate.add(const Duration(hours: 2)); + + if (jadwal.status == 'DIJADWALKAN' && now.isBefore(batasAktif)) { + print( + 'Updating to AKTIF: ${jadwal.id} - Time difference: ${now.difference(jadwalDate).inSeconds} seconds'); + jadwalUpdates[jadwal.id!] = 'AKTIF'; + jadwalToUpdate.add(jadwal); + } else if ((jadwal.status == 'DIJADWALKAN' || + jadwal.status == 'AKTIF') && + now.isAfter(batasAktif)) { + print('Updating to BATALTERLAKSANA (time expired): ${jadwal.id}'); + jadwalUpdates[jadwal.id!] = 'BATALTERLAKSANA'; + jadwalTerlewat.add(jadwal); + } + } else { + // Periksa apakah jadwal hampir memasuki waktunya (dalam 5 menit ke depan) + final diff = jadwalDate.difference(now).inMinutes; + if (diff >= 0 && diff <= 5 && jadwal.status == 'DIJADWALKAN') { + print('Jadwal will be active in $diff minutes: ${jadwal.id}'); + + // Tambahkan jadwal ke daftar pengawasan intensif + _jadwalUpdateService.addJadwalToWatch(jadwal.id!, jadwalDate); + + // Jika tinggal 1 menit atau kurang, cek setiap 15 detik + if (diff <= 1) { + Future.delayed(const Duration(seconds: 15), () { + if (!isLoadingStatusUpdate.value) { + checkAndUpdateJadwalStatus(); + } + }); } } } } } - if (jadwalToUpdate.isNotEmpty || jadwalTerlewat.isNotEmpty) { - await loadJadwalData(); + // Update database hanya jika ada perubahan + if (jadwalUpdates.isNotEmpty) { + print('Batch updating ${jadwalUpdates.length} schedules'); - if (jadwalToUpdate.isNotEmpty) { - Get.snackbar( - 'Jadwal Diperbarui', - '${jadwalToUpdate.length} jadwal dipindahkan ke section Hari Ini', - snackPosition: SnackPosition.TOP, - backgroundColor: Colors.green, - colorText: Colors.white, - duration: const Duration(seconds: 3), - ); - } + try { + // Gunakan batch update untuk meningkatkan efisiensi + await _supabaseService.batchUpdateJadwalStatus(jadwalUpdates); - if (jadwalTerlewat.isNotEmpty) { - Get.snackbar( - 'Jadwal Terlewat', - '${jadwalTerlewat.length} jadwal diubah menjadi BATALTERLAKSANA', - snackPosition: SnackPosition.TOP, - backgroundColor: Colors.orange, - colorText: Colors.white, - duration: const Duration(seconds: 3), - ); + // Perbarui data lokal + await loadJadwalData(); + + // Beritahu seluruh aplikasi tentang pembaruan + await _jadwalUpdateService.notifyJadwalUpdate(); + + // Kirim notifikasi untuk perubahan status jadwal + bool notificationsSuccessful = true; + final notificationService = Get.find(); + + try { + // Kirim notifikasi untuk jadwal yang diperbarui menjadi Aktif + for (var jadwal in jadwalToUpdate) { + if (jadwal.id != null && jadwal.nama != null) { + await notificationService.sendJadwalStatusNotification( + jadwalId: jadwal.id!, + newStatus: 'AKTIF', + jadwalNama: jadwal.nama!, + ); + } + } + } catch (notificationError) { + print( + 'Warning: Error sending AKTIF notifications: $notificationError'); + notificationsSuccessful = false; + } + + try { + // Kirim notifikasi untuk jadwal yang terlewat + for (var jadwal in jadwalTerlewat) { + if (jadwal.id != null && jadwal.nama != null) { + await notificationService.sendJadwalStatusNotification( + jadwalId: jadwal.id!, + newStatus: 'BATALTERLAKSANA', + jadwalNama: jadwal.nama!, + ); + } + } + } catch (notificationError) { + print( + 'Warning: Error sending BATALTERLAKSANA notifications: $notificationError'); + notificationsSuccessful = false; + } + + // Tampilkan notifikasi hanya jika ada perubahan + if (jadwalToUpdate.isNotEmpty) { + Get.snackbar( + 'Jadwal Diperbarui', + '${jadwalToUpdate.length} jadwal dipindahkan ke section Hari Ini', + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.green, + colorText: Colors.white, + duration: const Duration(seconds: 3), + ); + } + + if (jadwalTerlewat.isNotEmpty) { + Get.snackbar( + 'Jadwal Terlewat', + '${jadwalTerlewat.length} jadwal diubah menjadi BATALTERLAKSANA', + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.orange, + colorText: Colors.white, + duration: const Duration(seconds: 3), + ); + } + + // Log status keseluruhan + if (notificationsSuccessful) { + print( + 'Jadwal status update and notifications completed successfully'); + } else { + print('Jadwal status update completed with notification errors'); + } + } catch (updateError) { + print('Error during batch update process: $updateError'); + // Jika batch update gagal, coba update satu-per-satu secara manual + print('Trying individual updates for critical jadwal...'); + + // Prioritaskan jadwal yang akan diaktifkan + for (var jadwal in jadwalToUpdate) { + if (jadwal.id != null) { + try { + await _supabaseService.updateJadwalStatus(jadwal.id!, 'AKTIF'); + print('Manual update successful for jadwal ${jadwal.id}'); + } catch (e) { + print('Manual update failed for jadwal ${jadwal.id}: $e'); + } + } + } } + } else { + print('No schedule updates needed'); } } catch (e, stackTrace) { print('Error checking and updating jadwal status: $e'); print('Stack trace: $stackTrace'); + } finally { + isLoadingStatusUpdate.value = false; + print('Jadwal status check completed at ${DateTime.now()}'); } } @@ -197,6 +563,9 @@ class JadwalPenyaluranController extends GetxController { .map((data) => PenyaluranBantuanModel.fromJson(data)) .toList(); } + + // Perbarui counter penyaluran setelah data dimuat + _updatePenyaluranCounters(); } catch (e) { print('Error loading jadwal data: $e'); } finally { @@ -220,6 +589,7 @@ class JadwalPenyaluranController extends GetxController { Future loadLokasiPenyaluranData() async { try { + isLokasiLoading(true); final lokasiData = await _supabaseService.getAllLokasiPenyaluran(); if (lokasiData != null) { for (var lokasi in lokasiData) { @@ -229,6 +599,8 @@ class JadwalPenyaluranController extends GetxController { } } catch (e) { print('Error loading lokasi penyaluran data: $e'); + } finally { + isLokasiLoading(false); } } @@ -335,8 +707,30 @@ class JadwalPenyaluranController extends GetxController { Future completeJadwal(String jadwalId) async { isLoading.value = true; try { + // Dapatkan detail jadwal + final jadwalIndex = jadwalAktif.indexWhere((j) => j.id == jadwalId); + PenyaluranBantuanModel? jadwal; + + if (jadwalIndex >= 0) { + jadwal = jadwalAktif[jadwalIndex]; + } + + // Update status di database await _supabaseService.completeJadwal(jadwalId); + + // Kirim notifikasi + if (jadwal != null && jadwal.nama != null) { + final notificationService = Get.find(); + await notificationService.sendJadwalStatusNotification( + jadwalId: jadwalId, + newStatus: 'TERLAKSANA', + jadwalNama: jadwal.nama!, + ); + } + + // Reload data await loadJadwalData(); + Get.snackbar( 'Sukses', 'Jadwal berhasil diselesaikan', @@ -359,15 +753,13 @@ class JadwalPenyaluranController extends GetxController { } Future refreshData() async { - isLoading.value = true; - try { - await loadJadwalData(); - await loadPermintaanPenjadwalanData(); - } catch (e) { - print('Error refreshing data: $e'); - } finally { - isLoading.value = false; - } + await Future.wait([ + loadJadwalData(), + loadPermintaanPenjadwalanData(), + loadLokasiPenyaluranData(), + loadKategoriBantuanData(), + loadSkemaBantuanData(), + ]); } void changeCategory(int index) { @@ -431,6 +823,7 @@ class JadwalPenyaluranController extends GetxController { 'status_penerimaan': 'BELUMMENERIMA', 'qr_code_hash': qrCodeHash, 'jumlah_bantuan': jumlahDiterimaPerOrang, + 'created_at': DateTime.now().toIso8601String(), }; // Simpan data penerima ke database diff --git a/lib/app/modules/petugas_desa/controllers/pelaksanaan_penyaluran_controller.dart b/lib/app/modules/petugas_desa/controllers/pelaksanaan_penyaluran_controller.dart index e2c09a5..ddb33a8 100644 --- a/lib/app/modules/petugas_desa/controllers/pelaksanaan_penyaluran_controller.dart +++ b/lib/app/modules/petugas_desa/controllers/pelaksanaan_penyaluran_controller.dart @@ -96,10 +96,10 @@ class PelaksanaanPenyaluranController extends GetxController { ? response['kategori_bantuan']['nama'] : 'Tidak tersedia', 'tanggal': penyaluranModel.tanggalPenyaluran != null - ? DateTimeHelper.formatDate(penyaluranModel.tanggalPenyaluran!) + ? FormatHelper.formatDateTime(penyaluranModel.tanggalPenyaluran!) : 'Tidak tersedia', 'waktu': penyaluranModel.tanggalPenyaluran != null - ? DateTimeHelper.formatTime(penyaluranModel.tanggalPenyaluran!) + ? FormatHelper.formatTime(penyaluranModel.tanggalPenyaluran!) : 'Tidak tersedia', 'jumlah_penerima': penyaluranModel.jumlahPenerima?.toString() ?? '0', 'status': penyaluranModel.status, diff --git a/lib/app/modules/petugas_desa/controllers/penerima_controller.dart b/lib/app/modules/petugas_desa/controllers/penerima_controller.dart index a9064e1..6d64101 100644 --- a/lib/app/modules/petugas_desa/controllers/penerima_controller.dart +++ b/lib/app/modules/petugas_desa/controllers/penerima_controller.dart @@ -289,7 +289,7 @@ class PenerimaController extends GetxController { ); if (picked != null) { - tanggalPenyaluran.value = DateTimeHelper.formatDate(picked); + tanggalPenyaluran.value = FormatHelper.formatDateTime(picked); } } diff --git a/lib/app/modules/petugas_desa/controllers/petugas_desa_controller.dart b/lib/app/modules/petugas_desa/controllers/petugas_desa_controller.dart index 676f3f2..ccb0bc5 100644 --- a/lib/app/modules/petugas_desa/controllers/petugas_desa_controller.dart +++ b/lib/app/modules/petugas_desa/controllers/petugas_desa_controller.dart @@ -8,6 +8,7 @@ import 'package:penyaluran_app/app/modules/petugas_desa/controllers/counter_serv import 'package:penyaluran_app/app/services/supabase_service.dart'; import 'package:penyaluran_app/app/modules/petugas_desa/controllers/penitipan_bantuan_controller.dart'; import 'package:penyaluran_app/app/modules/petugas_desa/controllers/stok_bantuan_controller.dart'; +import 'package:penyaluran_app/app/services/jadwal_update_service.dart'; class PetugasDesaController extends GetxController { final AuthController _authController = Get.find(); @@ -182,10 +183,22 @@ class PetugasDesaController extends GetxController { } _counterService = Get.find(); + // Pastikan JadwalUpdateService juga tersedia + JadwalUpdateService jadwalUpdateService; + if (Get.isRegistered()) { + jadwalUpdateService = Get.find(); + } else { + jadwalUpdateService = Get.put(JadwalUpdateService()); + } + + // Perbarui counter pada saat aplikasi dimulai + jadwalUpdateService.refreshCounters(); + + // Muat data awal loadUserProfile(); - loadNotifikasiData(); - loadJadwalData(); loadPenitipanData(); + loadJadwalData(); + loadNotifikasiData(); loadPengaduanData(); } diff --git a/lib/app/modules/petugas_desa/controllers/petugas_desa_dashboard_controller.dart b/lib/app/modules/petugas_desa/controllers/petugas_desa_dashboard_controller.dart index 79ab5a7..fae570e 100644 --- a/lib/app/modules/petugas_desa/controllers/petugas_desa_dashboard_controller.dart +++ b/lib/app/modules/petugas_desa/controllers/petugas_desa_dashboard_controller.dart @@ -5,11 +5,15 @@ import 'package:penyaluran_app/app/data/models/notifikasi_model.dart'; import 'package:penyaluran_app/app/modules/auth/controllers/auth_controller.dart'; import 'package:penyaluran_app/app/services/supabase_service.dart'; import 'package:penyaluran_app/app/modules/petugas_desa/controllers/counter_service.dart'; +import 'package:penyaluran_app/app/services/jadwal_update_service.dart'; +import 'dart:async'; class PetugasDesaDashboardController extends GetxController { final AuthController _authController = Get.find(); final SupabaseService _supabaseService = SupabaseService.to; late final CounterService _counterService; + late final JadwalUpdateService _jadwalUpdateService; + late StreamSubscription _jadwalUpdateSubscription; final RxBool isLoading = false.obs; @@ -67,18 +71,47 @@ class PetugasDesaDashboardController extends GetxController { } _counterService = Get.find(); + // Inisialisasi JadwalUpdateService untuk pembaruan realtime + if (Get.isRegistered()) { + _jadwalUpdateService = Get.find(); + } else { + _jadwalUpdateService = Get.put(JadwalUpdateService()); + } + + // Daftarkan controller ini untuk menerima pembaruan + _jadwalUpdateService.registerForUpdates('PetugasDesaDashboardController'); + + // Berlangganan ke pembaruan jadwal + _jadwalUpdateSubscription = + _jadwalUpdateService.jadwalUpdateStream.listen(_handleJadwalUpdate); + loadUserProfile(); loadDashboardData(); loadNotifikasiData(); - loadJadwalAktif(); + loadJadwalHariIni(); } @override void onClose() { + // Berhenti berlangganan pembaruan jadwal + _jadwalUpdateSubscription.cancel(); + // Batalkan pendaftaran controller + _jadwalUpdateService + .unregisterFromUpdates('PetugasDesaDashboardController'); searchController.dispose(); super.onClose(); } + // Handler untuk menerima pembaruan jadwal dari service + void _handleJadwalUpdate(Map updateData) { + if (updateData['type'] == 'status_update' || + updateData['type'] == 'reload_required' || + updateData['type'] == 'check_required') { + // Muat ulang data dashboard saat ada perubahan status jadwal + loadDashboardData(); + } + } + // Metode untuk memuat data profil pengguna dari cache Future loadUserProfile() async { try { @@ -155,14 +188,14 @@ class PetugasDesaDashboardController extends GetxController { } } - Future loadJadwalAktif() async { + Future loadJadwalHariIni() async { try { final jadwalData = await _supabaseService.getJadwalAktif(); if (jadwalData != null) { jadwalHariIni.value = jadwalData; } } catch (e) { - print('Error loading jadwal hari ini: $e'); + print('Error loading jadwal data: $e'); } } @@ -173,7 +206,7 @@ class PetugasDesaDashboardController extends GetxController { loadUserProfile(), loadDashboardData(), loadNotifikasiData(), - loadJadwalAktif(), + loadJadwalHariIni(), ]); } catch (e) { print('Error refreshing data: $e'); diff --git a/lib/app/modules/petugas_desa/views/daftar_penerima_view.dart b/lib/app/modules/petugas_desa/views/daftar_penerima_view.dart index b76e730..a780478 100644 --- a/lib/app/modules/petugas_desa/views/daftar_penerima_view.dart +++ b/lib/app/modules/petugas_desa/views/daftar_penerima_view.dart @@ -221,15 +221,25 @@ class DaftarPenerimaView extends GetView { ), child: CircleAvatar( radius: 35, - backgroundColor: AppTheme.primaryColor.withOpacity(0.1), - backgroundImage: penerima['foto_profil'] != null + backgroundColor: AppTheme.primaryColor.withOpacity(0.2), + backgroundImage: penerima['foto_profil'] != null && + penerima['foto_profil'].toString().isNotEmpty ? NetworkImage(penerima['foto_profil']) : null, - child: penerima['foto_profil'] == null - ? Icon( - Icons.person, - size: 35, - color: AppTheme.primaryColor.withOpacity(0.7), + child: (penerima['foto_profil'] == null || + penerima['foto_profil'].toString().isEmpty) + ? Text( + penerima['nama_lengkap'] != null + ? penerima['nama_lengkap'] + .toString() + .substring(0, 1) + .toUpperCase() + : '?', + style: TextStyle( + fontWeight: FontWeight.bold, + color: AppTheme.primaryColor, + fontSize: 24, + ), ) : null, ), @@ -435,13 +445,24 @@ class PenerimaSearchDelegate extends SearchDelegate { }, leading: CircleAvatar( backgroundColor: AppTheme.primaryColor.withOpacity(0.1), - backgroundImage: penerima['foto_profil'] != null + backgroundImage: penerima['foto_profil'] != null && + penerima['foto_profil'].toString().isNotEmpty ? NetworkImage(penerima['foto_profil']) : null, - child: penerima['foto_profil'] == null - ? const Icon( - Icons.person, - color: AppTheme.primaryColor, + child: (penerima['foto_profil'] == null || + penerima['foto_profil'].toString().isEmpty) + ? Text( + penerima['nama_lengkap'] != null + ? penerima['nama_lengkap'] + .toString() + .substring(0, 1) + .toUpperCase() + : '?', + style: TextStyle( + fontWeight: FontWeight.bold, + color: AppTheme.primaryColor, + fontSize: 24, + ), ) : null, ), diff --git a/lib/app/modules/petugas_desa/views/dashboard_view.dart b/lib/app/modules/petugas_desa/views/dashboard_view.dart index 8160c40..dc5dccf 100644 --- a/lib/app/modules/petugas_desa/views/dashboard_view.dart +++ b/lib/app/modules/petugas_desa/views/dashboard_view.dart @@ -33,6 +33,58 @@ class DashboardView extends GetView { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Header DisalurKita dengan logo dan slogan + FadeInAnimation( + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15), + boxShadow: [ + BoxShadow( + color: Colors.blue.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + Image.asset( + 'assets/images/logo-disalurkita.png', + width: 50, + height: 50, + ), + const SizedBox(width: 15), + const Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + 'DisalurKita', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Color(0xFF1565C0), + ), + ), + SizedBox(height: 5), + Text( + 'Salurkan dengan Pasti, Pantau dengan Bukti', + style: TextStyle( + fontSize: 12, + color: Colors.grey, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 20), + // Header dengan greeting FadeInAnimation( child: GreetingHeader( @@ -83,7 +135,7 @@ class DashboardView extends GetView { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Jadwal Penyaluran', + 'Jadwal Penyaluran Hari Ini', style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, @@ -130,19 +182,25 @@ class DashboardView extends GetView { final DateTime tanggal = DateTime.parse(jadwal['tanggal_penyaluran']); final String formattedDate = - DateTimeHelper.formatDateTime(tanggal); + FormatHelper.formatDateTime(tanggal); final kategoriBantuan = jadwal['kategori_bantuan'] as Map; final lokasiPenyaluran = jadwal['lokasi_penyaluran'] as Map; - return ScheduleCard( - title: kategoriBantuan['nama'] ?? 'Jadwal Penyaluran', - location: lokasiPenyaluran['nama'] ?? 'Lokasi tidak tersedia', - dateTime: formattedDate, - isToday: true, - onTap: () => Get.toNamed(Routes.detailPenyaluran, - parameters: {'id': jadwal['id']}), + return Column( + children: [ + if (index > 0) const SizedBox(height: 10), + ScheduleCard( + title: kategoriBantuan['nama'] ?? 'Jadwal Penyaluran', + location: + lokasiPenyaluran['nama'] ?? 'Lokasi tidak tersedia', + dateTime: formattedDate, + isToday: true, + onTap: () => Get.toNamed(Routes.detailPenyaluran, + parameters: {'id': jadwal['id']}), + ), + ], ); }, ); @@ -391,8 +449,10 @@ class DashboardView extends GetView { final nik = penerima['nik'] ?? 'NIK tidak tersedia'; final status = penerima['status'] ?? 'AKTIF'; final id = penerima['id'] ?? 'ID tidak tersedia'; + final fotoProfil = penerima['foto_profil'] ?? null; - return _buildRecipientItem(name, nik, status, id, textTheme); + return _buildRecipientItem( + name, nik, status, id, textTheme, fotoProfil); }, ); }, @@ -401,8 +461,8 @@ class DashboardView extends GetView { ); } - Widget _buildRecipientItem( - String name, String nik, String status, String id, TextTheme textTheme) { + Widget _buildRecipientItem(String name, String nik, String status, String id, + TextTheme textTheme, String? fotoProfil) { return Container( width: double.infinity, margin: const EdgeInsets.only(bottom: 10), @@ -428,7 +488,20 @@ class DashboardView extends GetView { children: [ CircleAvatar( backgroundColor: Colors.white.withOpacity(0.2), - child: const Icon(Icons.person, color: Colors.white), + backgroundImage: + fotoProfil != null && fotoProfil.toString().isNotEmpty + ? NetworkImage(fotoProfil) + : null, + child: (fotoProfil == null || fotoProfil.toString().isEmpty) + ? Text( + name.toString().substring(0, 1).toUpperCase(), + style: const TextStyle( + fontWeight: FontWeight.bold, + color: Colors.white, + fontSize: 24, + ), + ) + : null, ), const SizedBox(width: 12), Expanded( diff --git a/lib/app/modules/petugas_desa/views/detail_donatur_view.dart b/lib/app/modules/petugas_desa/views/detail_donatur_view.dart index a502794..b680308 100644 --- a/lib/app/modules/petugas_desa/views/detail_donatur_view.dart +++ b/lib/app/modules/petugas_desa/views/detail_donatur_view.dart @@ -5,6 +5,7 @@ import 'package:penyaluran_app/app/data/models/donatur_model.dart'; import 'package:penyaluran_app/app/data/models/penitipan_bantuan_model.dart'; import 'package:penyaluran_app/app/widgets/dialogs/detail_penitipan_dialog.dart'; import 'package:penyaluran_app/app/utils/format_helper.dart'; +import 'package:penyaluran_app/app/widgets/widgets.dart'; class DetailDonaturView extends GetView { const DetailDonaturView({super.key}); @@ -359,7 +360,7 @@ class DetailDonaturView extends GetView { Icons.calendar_today, 'Terdaftar Sejak', donatur.createdAt != null - ? DateTimeHelper.formatDate(donatur.createdAt!) + ? FormatHelper.formatDateTime(donatur.createdAt!) : 'Tidak diketahui', ), ], @@ -514,7 +515,8 @@ class DetailDonaturView extends GetView { Widget _buildDonasiItem(PenitipanBantuanModel penitipan) { final isUang = penitipan.isUang == true; final tanggal = penitipan.createdAt != null - ? DateTimeHelper.formatDate(penitipan.createdAt!, format: 'dd MMM yyyy') + ? FormatHelper.formatDateTime(penitipan.createdAt!, + format: 'dd MMM yyyy') : 'Tanggal tidak diketahui'; String nilaiDonasi = ''; @@ -626,7 +628,7 @@ class DetailDonaturView extends GetView { getPetugasDesaNama: (String? id) => controller.getPetugasDesaNama(id) ?? 'Petugas tidak diketahui', showFullScreenImage: (String imageUrl) { - DetailPenitipanDialog.showFullScreenImage(Get.context!, imageUrl); + ShowImageDialog.showFullScreen(Get.context!, imageUrl); }, ); } diff --git a/lib/app/modules/petugas_desa/views/detail_penerima_view.dart b/lib/app/modules/petugas_desa/views/detail_penerima_view.dart index 956e5d8..8ca5510 100644 --- a/lib/app/modules/petugas_desa/views/detail_penerima_view.dart +++ b/lib/app/modules/petugas_desa/views/detail_penerima_view.dart @@ -107,14 +107,24 @@ class DetailPenerimaView extends GetView { child: CircleAvatar( radius: 60, backgroundColor: Colors.white, - backgroundImage: penerima['foto_profil'] != null + backgroundImage: penerima['foto_profil'] != null && + penerima['foto_profil'].toString().isNotEmpty ? NetworkImage(penerima['foto_profil']) : null, - child: penerima['foto_profil'] == null - ? Icon( - Icons.person, - size: 60, - color: AppTheme.primaryColor.withOpacity(0.7), + child: (penerima['foto_profil'] == null || + penerima['foto_profil'].toString().isEmpty) + ? Text( + penerima['nama_lengkap'] != null + ? penerima['nama_lengkap'] + .toString() + .substring(0, 1) + .toUpperCase() + : '?', + style: TextStyle( + fontWeight: FontWeight.bold, + color: AppTheme.primaryColor.withOpacity(0.7), + fontSize: 36, + ), ) : null, ), @@ -507,7 +517,7 @@ class DetailPenerimaView extends GetView { child: _buildInfoItem( Icons.calendar_today, 'Tanggal Penerimaan', - DateTimeHelper.formatDateTime(tanggalPenerimaan), + FormatHelper.formatDateTime(tanggalPenerimaan), ), ), Expanded( diff --git a/lib/app/modules/petugas_desa/views/detail_pengaduan_view.dart b/lib/app/modules/petugas_desa/views/detail_pengaduan_view.dart index 7a297f1..c61eb45 100644 --- a/lib/app/modules/petugas_desa/views/detail_pengaduan_view.dart +++ b/lib/app/modules/petugas_desa/views/detail_pengaduan_view.dart @@ -1,13 +1,12 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:intl/intl.dart'; import 'package:penyaluran_app/app/data/models/pengaduan_model.dart'; import 'package:penyaluran_app/app/data/models/tindakan_pengaduan_model.dart'; import 'package:penyaluran_app/app/modules/petugas_desa/controllers/pengaduan_controller.dart'; import 'package:penyaluran_app/app/theme/app_theme.dart'; +import 'package:penyaluran_app/app/utils/format_helper.dart'; import 'package:penyaluran_app/app/widgets/cards/info_card.dart'; import 'package:penyaluran_app/app/widgets/indicators/status_pill.dart'; -import 'package:penyaluran_app/app/widgets/section_header.dart'; import 'package:penyaluran_app/app/services/supabase_service.dart'; import 'package:timeline_tile/timeline_tile.dart'; import 'package:image_picker/image_picker.dart'; @@ -15,7 +14,7 @@ import 'dart:io'; import 'package:penyaluran_app/app/widgets/inputs/dropdown_input.dart'; import 'package:penyaluran_app/app/widgets/inputs/text_input.dart'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:penyaluran_app/app/routes/app_pages.dart'; +import 'package:penyaluran_app/app/widgets/widgets.dart'; class DetailPengaduanView extends GetView { const DetailPengaduanView({super.key}); @@ -1092,8 +1091,8 @@ class DetailPengaduanView extends GetView { child: Row( children: tindakan.buktiTindakan!.map((bukti) { return GestureDetector( - onTap: () => - showFullScreenImage(context, bukti), + onTap: () => ShowImageDialog.showFullScreen( + context, bukti), child: Container( width: 100, height: 100, @@ -1190,8 +1189,8 @@ class DetailPengaduanView extends GetView { Expanded( child: Text( tindakan.tanggalTindakan != null - ? DateFormat('dd MMM yyyy HH:mm', 'id_ID') - .format(tindakan.tanggalTindakan!) + ? FormatHelper.formatDateTime( + tindakan.tanggalTindakan!) : '-', style: TextStyle( fontSize: 12, @@ -1669,9 +1668,11 @@ class DetailPengaduanView extends GetView { return Stack( children: [ GestureDetector( - onTap: () => showFullScreenImage( - stateContext, - buktiTindakanPaths[index]), + onTap: () => ShowImageDialog + .showFullScreen( + stateContext, + buktiTindakanPaths[ + index]), child: Container( width: 100, height: 100, @@ -2003,63 +2004,6 @@ class DetailPengaduanView extends GetView { ); } - void showFullScreenImage(BuildContext context, String imagePath) { - showDialog( - context: context, - builder: (BuildContext context) { - return Dialog( - insetPadding: EdgeInsets.zero, - backgroundColor: Colors.transparent, - child: Stack( - alignment: Alignment.center, - children: [ - GestureDetector( - onTap: () => Navigator.pop(context), - child: Container( - width: double.infinity, - height: double.infinity, - color: Colors.black87, - ), - ), - InteractiveViewer( - panEnabled: true, - boundaryMargin: const EdgeInsets.all(20), - minScale: 0.5, - maxScale: 4.0, - child: CachedNetworkImage( - imageUrl: imagePath, - placeholder: (context, url) => const Center( - child: CircularProgressIndicator(), - ), - errorWidget: (context, url, error) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.error, color: Colors.white, size: 32), - const SizedBox(height: 8), - Text( - 'Gagal memuat gambar', - style: TextStyle(color: Colors.white), - ), - ], - ), - ), - ), - Positioned( - top: 20, - right: 20, - child: IconButton( - icon: const Icon(Icons.close, color: Colors.white, size: 30), - onPressed: () => Navigator.pop(context), - ), - ), - ], - ), - ); - }, - ); - } - - // Widget untuk menampilkan feedback dan rating warga Widget _buildFeedbackSection(BuildContext context, PengaduanModel pengaduan) { return Card( elevation: 3, @@ -2348,8 +2292,7 @@ class DetailPengaduanView extends GetView { const SizedBox(width: 12), Text( pengaduan.tanggalPengaduan != null - ? DateFormat('dd MMMM yyyy', 'id_ID') - .format(pengaduan.tanggalPengaduan!) + ? FormatHelper.formatDateTime(pengaduan.tanggalPengaduan!) : '-', style: TextStyle( fontSize: 15, @@ -2376,7 +2319,8 @@ class DetailPengaduanView extends GetView { return Padding( padding: const EdgeInsets.only(right: 8), child: GestureDetector( - onTap: () => _showFullScreenImage(context, url), + onTap: () => + ShowImageDialog.showFullScreen(context, url), child: Container( width: 120, decoration: BoxDecoration( @@ -2589,57 +2533,4 @@ class DetailPengaduanView extends GetView { ); } } - - void _showFullScreenImage(BuildContext context, String imagePath) { - showDialog( - context: context, - builder: (BuildContext context) { - return Dialog( - insetPadding: EdgeInsets.zero, - backgroundColor: Colors.transparent, - child: Stack( - children: [ - InteractiveViewer( - panEnabled: true, - minScale: 0.5, - maxScale: 4, - child: Container( - width: double.infinity, - height: double.infinity, - color: Colors.black.withOpacity(0.7), - child: Center( - child: imagePath.startsWith('http') - ? CachedNetworkImage( - imageUrl: imagePath, - placeholder: (context, url) => const Center( - child: CircularProgressIndicator(), - ), - errorWidget: (context, url, error) => const Icon( - Icons.error, - color: Colors.red, - size: 50, - ), - ) - : Image.file(File(imagePath)), - ), - ), - ), - Positioned( - top: 20, - right: 20, - child: IconButton( - icon: const Icon( - Icons.close, - color: Colors.white, - size: 30, - ), - onPressed: () => Navigator.pop(context), - ), - ), - ], - ), - ); - }, - ); - } } diff --git a/lib/app/modules/petugas_desa/views/detail_penyaluran_page.dart b/lib/app/modules/petugas_desa/views/detail_penyaluran_page.dart index 52a7ce0..4db0fcd 100644 --- a/lib/app/modules/petugas_desa/views/detail_penyaluran_page.dart +++ b/lib/app/modules/petugas_desa/views/detail_penyaluran_page.dart @@ -267,7 +267,7 @@ class DetailPenyaluranPage extends StatelessWidget { Icons.event, 'Tanggal Penyaluran', penyaluran.tanggalPenyaluran != null - ? DateTimeHelper.formatDateTime( + ? FormatHelper.formatDateTime( penyaluran.tanggalPenyaluran!) : 'Belum dijadwalkan', AppTheme.secondaryColor), @@ -280,7 +280,7 @@ class DetailPenyaluranPage extends StatelessWidget { Icons.event_available, 'Tanggal Selesai', penyaluran.tanggalSelesai != null - ? DateTimeHelper.formatDateTime( + ? FormatHelper.formatDateTime( penyaluran.tanggalSelesai!) : '-', AppTheme.secondaryColor), @@ -1065,19 +1065,30 @@ class DetailPenyaluranPage extends StatelessWidget { backgroundColor: sudahMenerima ? statusColor.withOpacity(0.15) : Colors.grey.shade50, - child: Text( - warga != null && warga['nama_lengkap'] != null - ? warga['nama_lengkap'] - .toString() - .substring(0, 1) - .toUpperCase() - : '?', - style: TextStyle( - fontWeight: FontWeight.bold, - color: sudahMenerima ? statusColor : Colors.grey.shade700, - fontSize: 22, - ), - ), + backgroundImage: warga != null && + warga['foto_profil'] != null && + warga['foto_profil'].toString().isNotEmpty + ? NetworkImage(warga['foto_profil']) + : null, + child: (warga == null || + warga['foto_profil'] == null || + warga['foto_profil'].toString().isEmpty) + ? Text( + warga != null && warga['nama_lengkap'] != null + ? warga['nama_lengkap'] + .toString() + .substring(0, 1) + .toUpperCase() + : '?', + style: TextStyle( + fontWeight: FontWeight.bold, + color: sudahMenerima + ? statusColor + : Colors.grey.shade700, + fontSize: 22, + ), + ) + : null, ), ), const SizedBox(width: 16), @@ -1621,19 +1632,28 @@ class DetailPenyaluranPage extends StatelessWidget { CircleAvatar( radius: 30, backgroundColor: statusColor.withOpacity(0.2), - child: Text( - warga != null && warga['nama_lengkap'] != null - ? warga['nama_lengkap'] - .toString() - .substring(0, 1) - .toUpperCase() - : '?', - style: TextStyle( - fontWeight: FontWeight.bold, - color: statusColor, - fontSize: 24, - ), - ), + backgroundImage: warga != null && + warga['foto_profil'] != null && + warga['foto_profil'].toString().isNotEmpty + ? NetworkImage(warga['foto_profil']) + : null, + child: (warga == null || + warga['foto_profil'] == null || + warga['foto_profil'].toString().isEmpty) + ? Text( + warga != null && warga['nama_lengkap'] != null + ? warga['nama_lengkap'] + .toString() + .substring(0, 1) + .toUpperCase() + : '?', + style: TextStyle( + fontWeight: FontWeight.bold, + color: statusColor, + fontSize: 24, + ), + ) + : null, ), const SizedBox(width: 16), Expanded( @@ -1753,7 +1773,7 @@ class DetailPenyaluranPage extends StatelessWidget { if (penerima.tanggalPenerimaan != null) _buildInfoRow( 'Tanggal Penerimaan', - DateTimeHelper.formatDate( + FormatHelper.formatDateTime( penerima.tanggalPenerimaan!)), if (penerima.jumlahBantuan != null) _buildInfoRow('Jumlah Bantuan', @@ -1946,7 +1966,7 @@ class DetailPenyaluranPage extends StatelessWidget { _buildInfoRow('Status', 'Batal Terlaksana'), if (penyaluran.tanggalSelesai != null) _buildInfoRow('Tanggal Pembatalan', - DateTimeHelper.formatDateTime(penyaluran.tanggalSelesai!)), + FormatHelper.formatDateTime(penyaluran.tanggalSelesai!)), const SizedBox(height: 8), const Text( 'Alasan Pembatalan:', @@ -2126,7 +2146,7 @@ class DetailPenyaluranPage extends StatelessWidget { _buildInfoRow( 'Tanggal Laporan', controller.laporan.value?.tanggalLaporan != null - ? DateTimeHelper.formatDateTime( + ? FormatHelper.formatDateTime( controller.laporan.value!.tanggalLaporan!) : '-', ), diff --git a/lib/app/modules/petugas_desa/views/konfirmasi_penerima_page.dart b/lib/app/modules/petugas_desa/views/konfirmasi_penerima_page.dart index 827f29e..755d55d 100644 --- a/lib/app/modules/petugas_desa/views/konfirmasi_penerima_page.dart +++ b/lib/app/modules/petugas_desa/views/konfirmasi_penerima_page.dart @@ -198,7 +198,7 @@ class _KonfirmasiPenerimaPageState extends State { 'Tempat, Tanggal Lahir', warga?['tempat_lahir'] != null && warga?['tanggal_lahir'] != null - ? '${warga!['tempat_lahir']}, ${DateTimeHelper.formatDate(DateTime.parse(warga['tanggal_lahir']), format: 'd MMMM yyyy')}' + ? '${warga!['tempat_lahir']}, ${FormatHelper.formatDateTime(DateTime.parse(warga['tanggal_lahir']), format: 'd MMMM yyyy')}' : 'Bogor, 2 Juni 1990'), const Divider(), @@ -236,18 +236,18 @@ class _KonfirmasiPenerimaPageState extends State { String tanggalWaktuPenyaluran = ''; if (widget.tanggalPenyaluran != null) { - final tanggal = DateTimeHelper.formatDate(widget.tanggalPenyaluran!); - final waktuMulai = DateTimeHelper.formatTime(widget.tanggalPenyaluran!); - final waktuSelesai = DateTimeHelper.formatTime( + final tanggal = FormatHelper.formatDateTime(widget.tanggalPenyaluran!); + final waktuMulai = FormatHelper.formatTime(widget.tanggalPenyaluran!); + final waktuSelesai = FormatHelper.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( + final tanggal = FormatHelper.formatDateTime(tanggalPenyaluran); + final waktuMulai = FormatHelper.formatTime(tanggalPenyaluran); + final waktuSelesai = FormatHelper.formatTime( tanggalPenyaluran.add(const Duration(hours: 1))); tanggalWaktuPenyaluran = '$tanggal $waktuMulai-$waktuSelesai'; } else { diff --git a/lib/app/modules/petugas_desa/views/pengaduan_view.dart b/lib/app/modules/petugas_desa/views/pengaduan_view.dart index acb7ceb..651b571 100644 --- a/lib/app/modules/petugas_desa/views/pengaduan_view.dart +++ b/lib/app/modules/petugas_desa/views/pengaduan_view.dart @@ -44,7 +44,7 @@ class PengaduanView extends GetView { Widget _buildLastUpdateInfo(BuildContext context) { final lastUpdate = DateTime .now(); // Gunakan waktu saat ini atau dari controller jika tersedia - final formattedDate = DateTimeHelper.formatDateTimeWithHour(lastUpdate); + final formattedDate = FormatHelper.formatDateTimeWithHour(lastUpdate); return Padding( padding: const EdgeInsets.only(top: 8.0), @@ -280,7 +280,7 @@ class PengaduanView extends GetView { ), ), Text( - '${DateTimeHelper.formatNumber(filteredPengaduan.length)} item', + '${FormatHelper.formatNumber(filteredPengaduan.length)} item', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Colors.grey, ), @@ -320,7 +320,7 @@ class PengaduanView extends GetView { // Format tanggal menggunakan DateTimeHelper String formattedDate = ''; if (item.tanggalPengaduan != null) { - formattedDate = DateTimeHelper.formatDate(item.tanggalPengaduan); + formattedDate = FormatHelper.formatDateTime(item.tanggalPengaduan); } return Card( diff --git a/lib/app/modules/petugas_desa/views/penitipan_view.dart b/lib/app/modules/petugas_desa/views/penitipan_view.dart index 197aff0..8dc072b 100644 --- a/lib/app/modules/petugas_desa/views/penitipan_view.dart +++ b/lib/app/modules/petugas_desa/views/penitipan_view.dart @@ -5,6 +5,7 @@ import 'package:penyaluran_app/app/modules/petugas_desa/controllers/penitipan_ba import 'package:penyaluran_app/app/theme/app_theme.dart'; import 'package:penyaluran_app/app/utils/format_helper.dart'; import 'package:penyaluran_app/app/widgets/dialogs/detail_penitipan_dialog.dart'; +import 'package:penyaluran_app/app/widgets/widgets.dart'; import 'dart:io'; class PenitipanView extends GetView { @@ -72,7 +73,7 @@ class PenitipanView extends GetView { context, icon: Icons.pending_actions, title: 'Menunggu', - value: DateTimeHelper.formatNumber( + value: FormatHelper.formatNumber( controller.jumlahMenunggu.value), color: Colors.orange, ), @@ -82,7 +83,7 @@ class PenitipanView extends GetView { context, icon: Icons.check_circle, title: 'Terverifikasi', - value: DateTimeHelper.formatNumber( + value: FormatHelper.formatNumber( controller.jumlahTerverifikasi.value), color: Colors.green, ), @@ -92,8 +93,8 @@ class PenitipanView extends GetView { context, icon: Icons.cancel, title: 'Ditolak', - value: DateTimeHelper.formatNumber( - controller.jumlahDitolak.value), + value: + FormatHelper.formatNumber(controller.jumlahDitolak.value), color: Colors.red, ), ), @@ -219,7 +220,7 @@ class PenitipanView extends GetView { ), ), Text( - '${DateTimeHelper.formatNumber(filteredList.length)} item', + '${FormatHelper.formatNumber(filteredList.length)} item', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Colors.grey, ), @@ -360,7 +361,7 @@ class PenitipanView extends GetView { ], ), Text( - DateTimeHelper.formatDate(item.createdAt), + FormatHelper.formatDateTime(item.createdAt), style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Colors.grey.shade700, fontStyle: FontStyle.italic, @@ -380,15 +381,27 @@ class PenitipanView extends GetView { Row( children: [ CircleAvatar( - backgroundColor: AppTheme.primaryColor.withOpacity(0.1), radius: 20, - child: Text( - donaturNama.substring(0, 1).toUpperCase(), - style: TextStyle( - color: AppTheme.primaryColor, - fontWeight: FontWeight.bold, - ), - ), + backgroundColor: AppTheme.primaryColor.withOpacity(0.1), + backgroundImage: item.donatur != null && + item.donatur!.fotoProfil != null && + item.donatur!.fotoProfil!.isNotEmpty + ? NetworkImage(item.donatur!.fotoProfil!) + : null, + child: (item.donatur == null || + item.donatur!.fotoProfil == null || + item.donatur!.fotoProfil!.isEmpty) + ? Text( + donaturNama.isNotEmpty + ? donaturNama.substring(0, 1).toUpperCase() + : '?', + style: TextStyle( + fontWeight: FontWeight.bold, + color: AppTheme.primaryColor, + fontSize: 16, + ), + ) + : null, ), const SizedBox(width: 12), Expanded( @@ -546,8 +559,8 @@ class PenitipanView extends GetView { const SizedBox(height: 4), Text( isUang - ? 'Rp ${DateTimeHelper.formatNumber(item.jumlah)}' - : '${DateTimeHelper.formatNumber(item.jumlah)} $kategoriSatuan', + ? 'Rp ${FormatHelper.formatNumber(item.jumlah)}' + : '${FormatHelper.formatNumber(item.jumlah)} $kategoriSatuan', style: Theme.of(context) .textTheme .titleSmall @@ -947,7 +960,7 @@ class PenitipanView extends GetView { kategoriSatuan: kategoriSatuan, getPetugasDesaNama: (String? id) => controller.getPetugasDesaNama(id), showFullScreenImage: (String imageUrl) { - DetailPenitipanDialog.showFullScreenImage(context, imageUrl); + ShowImageDialog.showFullScreen(context, imageUrl); }, ); } @@ -992,7 +1005,7 @@ class PenitipanView extends GetView { Widget _buildLastUpdateInfo(BuildContext context) { return Obx(() { final lastUpdate = controller.lastUpdateTime.value; - final formattedDate = DateTimeHelper.formatDateTimeWithHour(lastUpdate); + final formattedDate = FormatHelper.formatDateTimeWithHour(lastUpdate); return Padding( padding: const EdgeInsets.only(top: 8.0), diff --git a/lib/app/modules/petugas_desa/views/penyaluran_view.dart b/lib/app/modules/petugas_desa/views/penyaluran_view.dart index 340ed42..5ad1507 100644 --- a/lib/app/modules/petugas_desa/views/penyaluran_view.dart +++ b/lib/app/modules/petugas_desa/views/penyaluran_view.dart @@ -5,6 +5,7 @@ import 'package:penyaluran_app/app/theme/app_theme.dart'; import 'package:penyaluran_app/app/modules/petugas_desa/components/jadwal_section_widget.dart'; import 'package:penyaluran_app/app/modules/petugas_desa/components/calendar_view_widget.dart'; import 'package:penyaluran_app/app/modules/petugas_desa/views/tambah_penyaluran_view.dart'; +import 'package:penyaluran_app/app/routes/app_pages.dart'; class PenyaluranView extends GetView { const PenyaluranView({super.key}); @@ -41,13 +42,20 @@ class PenyaluranView extends GetView { ), ], ), - floatingActionButton: FloatingActionButton.extended( - onPressed: () => Get.to(() => const TambahPenyaluranView()), - backgroundColor: AppTheme.primaryColor, - icon: const Icon(Icons.add, color: Colors.white), - label: const Text('Tambah Jadwal', - style: TextStyle(color: Colors.white)), - elevation: 2, + floatingActionButton: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Tombol untuk menambah jadwal penyaluran + FloatingActionButton.extended( + heroTag: 'tambahJadwal', + onPressed: () => Get.to(() => const TambahPenyaluranView()), + backgroundColor: AppTheme.primaryColor, + icon: const Icon(Icons.add, color: Colors.white), + label: const Text('Tambah Jadwal', + style: TextStyle(color: Colors.white)), + elevation: 2, + ), + ], ), ), ); @@ -76,6 +84,11 @@ class PenyaluranView extends GetView { // Ringkasan jadwal _buildJadwalSummary(Get.context!), + const SizedBox(height: 16), + + // Tombol untuk mengelola lokasi penyaluran + _buildLokasiPenyaluranSection(), + const SizedBox(height: 24), // Jadwal hari ini @@ -224,4 +237,240 @@ class PenyaluranView extends GetView { ], ); } + + // Widget untuk menampilkan section lokasi penyaluran + Widget _buildLokasiPenyaluranSection() { + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: Colors.blue.shade100, width: 1), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Lokasi Penyaluran', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.blue.shade800, + ), + ), + OutlinedButton.icon( + onPressed: () { + // Menampilkan dialog daftar lokasi penyaluran + _showLokasiPenyaluranDialog(); + }, + icon: const Icon(Icons.map, size: 16), + label: const Text('Lihat Lokasi'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.blue, + side: BorderSide(color: Colors.blue.shade300), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Kelola lokasi penyaluran bantuan untuk masyarakat dengan lebih mudah', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 12), + ElevatedButton.icon( + onPressed: () => Get.toNamed(Routes.tambahLokasiPenyaluran), + icon: const Icon(Icons.add_location, size: 16), + label: const Text('Tambah Lokasi Penyaluran Baru'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue.shade50, + foregroundColor: Colors.blue.shade700, + padding: + const EdgeInsets.symmetric(vertical: 10, horizontal: 12), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: Colors.blue.shade200), + ), + ), + ), + ], + ), + ), + ); + } + + // Fungsi untuk menampilkan dialog daftar lokasi penyaluran + void _showLokasiPenyaluranDialog() { + Get.dialog( + Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Daftar Lokasi Penyaluran', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.blue.shade800, + ), + ), + IconButton( + onPressed: () => Get.back(), + icon: const Icon(Icons.close), + visualDensity: VisualDensity.compact, + ), + ], + ), + const SizedBox(height: 12), + Container( + constraints: BoxConstraints( + maxHeight: Get.height * 0.5, + ), + width: double.infinity, + child: Obx(() { + if (controller.isLokasiLoading.value) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + if (controller.lokasiPenyaluranCache.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.location_off, + size: 48, + color: Colors.grey.shade400, + ), + const SizedBox(height: 16), + Text( + 'Belum ada lokasi penyaluran', + style: TextStyle( + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 8), + ElevatedButton.icon( + onPressed: () { + Get.back(); + Get.toNamed(Routes.tambahLokasiPenyaluran); + }, + icon: const Icon(Icons.add_location), + label: const Text('Tambah Lokasi'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + ), + ), + ], + ), + ); + } + + return ListView.builder( + shrinkWrap: true, + itemCount: controller.lokasiPenyaluranCache.length, + itemBuilder: (context, index) { + final lokasi = controller.lokasiPenyaluranCache.values + .elementAt(index); + final lokasiId = controller.lokasiPenyaluranCache.keys + .elementAt(index); + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + title: Text( + lokasi.nama, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (lokasi.alamat != null && + lokasi.alamat!.isNotEmpty) + Text(lokasi.alamat!), + Row( + children: [ + if (lokasi.isLokasiTitip) + Container( + margin: const EdgeInsets.only(top: 4), + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.green.shade100, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'Lokasi Penitipan', + style: TextStyle( + fontSize: 10, + color: Colors.green.shade800, + ), + ), + ), + ], + ), + ], + ), + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.blue.shade50, + shape: BoxShape.circle, + ), + child: Icon( + Icons.location_on, + color: Colors.blue.shade700, + ), + ), + ), + ); + }, + ); + }), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedButton( + onPressed: () { + Get.back(); + Get.toNamed(Routes.tambahLokasiPenyaluran); + }, + child: const Text('Tambah Lokasi Baru'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.blue, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } } diff --git a/lib/app/modules/petugas_desa/views/petugas_desa_view.dart b/lib/app/modules/petugas_desa/views/petugas_desa_view.dart index b2904b5..075af80 100644 --- a/lib/app/modules/petugas_desa/views/petugas_desa_view.dart +++ b/lib/app/modules/petugas_desa/views/petugas_desa_view.dart @@ -39,7 +39,7 @@ class PetugasDesaView extends GetView { case 4: return const Text('Stok Bantuan'); default: - return const Text('Petugas Desa'); + return const Text('Dashboard'); } }), leading: IconButton( @@ -223,14 +223,23 @@ class PetugasDesaView extends GetView { child: CircleAvatar( radius: 40, backgroundColor: Colors.white70, - backgroundImage: controller.profilePhotoUrl != null + backgroundImage: controller.profilePhotoUrl != null && + controller.profilePhotoUrl!.isNotEmpty ? NetworkImage(controller.profilePhotoUrl!) : null, - child: controller.profilePhotoUrl == null - ? Icon( - Icons.person, - color: Colors.white, - size: 40, + child: (controller.profilePhotoUrl == null || + controller.profilePhotoUrl!.isEmpty) + ? Text( + controller.nama.isNotEmpty + ? controller.nama + .substring(0, 1) + .toUpperCase() + : '?', + style: TextStyle( + fontWeight: FontWeight.bold, + color: AppTheme.primaryColor, + fontSize: 30, + ), ) : null, ), @@ -396,6 +405,16 @@ class PetugasDesaView extends GetView { Get.toNamed('/profile'); }, ), + const Divider(), + _buildMenuItem( + icon: Icons.info_outline, + activeIcon: Icons.info, + title: 'Tentang Kami', + onTap: () { + Navigator.pop(context); + Get.toNamed('/about'); + }, + ), _buildMenuItem( icon: Icons.logout, title: 'Keluar', @@ -411,7 +430,7 @@ class PetugasDesaView extends GetView { Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Text( - '© ${DateTime.now().year} Aplikasi Penyaluran Bantuan', + '© ${DateTime.now().year} DisalurKita', style: TextStyle( fontSize: 12, color: Colors.grey, diff --git a/lib/app/modules/petugas_desa/views/riwayat_pengaduan_view.dart b/lib/app/modules/petugas_desa/views/riwayat_pengaduan_view.dart index 4b47c56..736d936 100644 --- a/lib/app/modules/petugas_desa/views/riwayat_pengaduan_view.dart +++ b/lib/app/modules/petugas_desa/views/riwayat_pengaduan_view.dart @@ -43,7 +43,7 @@ class RiwayatPengaduanView extends GetView { // Tambahkan widget untuk menampilkan waktu terakhir update Widget _buildLastUpdateInfo(BuildContext context) { final lastUpdate = DateTime.now(); - final formattedDate = DateTimeHelper.formatDateTimeWithHour(lastUpdate); + final formattedDate = FormatHelper.formatDateTimeWithHour(lastUpdate); return Padding( padding: const EdgeInsets.only(top: 8.0), @@ -135,7 +135,7 @@ class RiwayatPengaduanView extends GetView { ), ), Text( - '${DateTimeHelper.formatNumber(filteredPengaduan.length)} item', + '${FormatHelper.formatNumber(filteredPengaduan.length)} item', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Colors.grey, ), @@ -154,9 +154,9 @@ class RiwayatPengaduanView extends GetView { // Format tanggal menggunakan DateTimeHelper String formattedDate = ''; if (item.tanggalPengaduan != null) { - formattedDate = DateTimeHelper.formatDate(item.tanggalPengaduan); + formattedDate = FormatHelper.formatDateTime(item.tanggalPengaduan); } else if (item.createdAt != null) { - formattedDate = DateTimeHelper.formatDate(item.createdAt); + formattedDate = FormatHelper.formatDateTime(item.createdAt); } Color statusColor = AppTheme.successColor; diff --git a/lib/app/modules/petugas_desa/views/riwayat_penitipan_view.dart b/lib/app/modules/petugas_desa/views/riwayat_penitipan_view.dart index 44f1eea..09a28a2 100644 --- a/lib/app/modules/petugas_desa/views/riwayat_penitipan_view.dart +++ b/lib/app/modules/petugas_desa/views/riwayat_penitipan_view.dart @@ -4,6 +4,7 @@ import 'package:penyaluran_app/app/data/models/penitipan_bantuan_model.dart'; import 'package:penyaluran_app/app/modules/petugas_desa/controllers/penitipan_bantuan_controller.dart'; import 'package:penyaluran_app/app/utils/format_helper.dart'; import 'package:penyaluran_app/app/theme/app_theme.dart'; +import 'package:penyaluran_app/app/widgets/widgets.dart'; class RiwayatPenitipanView extends GetView { const RiwayatPenitipanView({super.key}); @@ -47,7 +48,7 @@ class RiwayatPenitipanView extends GetView { final kategoriNama = item.kategoriBantuan?.nama?.toLowerCase() ?? ''; final deskripsi = item.deskripsi?.toLowerCase() ?? ''; final tanggal = - DateTimeHelper.formatDateTime(item.tanggalPenitipan).toLowerCase(); + FormatHelper.formatDateTime(item.tanggalPenitipan).toLowerCase(); return donaturNama.contains(searchText) || kategoriNama.contains(searchText) || @@ -99,7 +100,7 @@ class RiwayatPenitipanView extends GetView { ), ), Text( - '${DateTimeHelper.formatNumber(filteredList.length)} item', + '${FormatHelper.formatNumber(filteredList.length)} item', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Colors.grey, @@ -113,7 +114,7 @@ class RiwayatPenitipanView extends GetView { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - 'Total: ${DateTimeHelper.formatNumber(filteredList.length)} item', + 'Total: ${FormatHelper.formatNumber(filteredList.length)} item', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Colors.grey, @@ -126,7 +127,7 @@ class RiwayatPenitipanView extends GetView { size: 16, color: Colors.grey[600]), const SizedBox(width: 4), Text( - 'Update: ${DateTimeHelper.formatDateTimeWithHour(controller.lastUpdateTime.value)}', + 'Update: ${FormatHelper.formatDateTimeWithHour(controller.lastUpdateTime.value)}', style: TextStyle( fontSize: 12, color: Colors.grey[600], @@ -262,7 +263,7 @@ class RiwayatPenitipanView extends GetView { ], ), Text( - DateTimeHelper.formatDate(item.createdAt), + FormatHelper.formatDateTime(item.createdAt), style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Colors.grey.shade700, fontStyle: FontStyle.italic, @@ -282,17 +283,26 @@ class RiwayatPenitipanView extends GetView { Row( children: [ CircleAvatar( - backgroundColor: AppTheme.primaryColor.withOpacity(0.1), radius: 20, - child: Text( - donaturNama.isNotEmpty - ? donaturNama.substring(0, 1).toUpperCase() - : '?', - style: TextStyle( - color: AppTheme.primaryColor, - fontWeight: FontWeight.bold, - ), - ), + backgroundColor: statusColor.withOpacity(0.2), + backgroundImage: item.donatur != null && + item.donatur!.fotoProfil != null && + item.donatur!.fotoProfil!.isNotEmpty + ? NetworkImage(item.donatur!.fotoProfil!) + : null, + child: (item.donatur == null || + item.donatur!.fotoProfil == null || + item.donatur!.fotoProfil!.isEmpty) + ? Text( + donaturNama.isNotEmpty + ? donaturNama.substring(0, 1).toUpperCase() + : '?', + style: TextStyle( + fontWeight: FontWeight.bold, + color: statusColor, + ), + ) + : null, ), const SizedBox(width: 12), Expanded( @@ -422,8 +432,8 @@ class RiwayatPenitipanView extends GetView { const SizedBox(height: 4), Text( isUang - ? 'Rp ${DateTimeHelper.formatNumber(item.jumlah)}' - : '${DateTimeHelper.formatNumber(item.jumlah)} $kategoriSatuan', + ? 'Rp ${FormatHelper.formatNumber(item.jumlah)}' + : '${FormatHelper.formatNumber(item.jumlah)} $kategoriSatuan', style: Theme.of(context) .textTheme .titleSmall @@ -579,20 +589,20 @@ class RiwayatPenitipanView extends GetView { _buildDetailItem( 'Jumlah', isUang - ? 'Rp ${DateTimeHelper.formatNumber(item.jumlah)}' - : '${DateTimeHelper.formatNumber(item.jumlah)} $kategoriSatuan'), + ? 'Rp ${FormatHelper.formatNumber(item.jumlah)}' + : '${FormatHelper.formatNumber(item.jumlah)} $kategoriSatuan'), if (isUang) _buildDetailItem('Jenis Bantuan', 'Uang (Rupiah)'), _buildDetailItem( 'Deskripsi', item.deskripsi ?? 'Tidak ada deskripsi'), _buildDetailItem( 'Tanggal Penitipan', - DateTimeHelper.formatDateTime(item.tanggalPenitipan, + FormatHelper.formatDateTime(item.tanggalPenitipan, defaultValue: 'Tidak ada tanggal'), ), if (item.tanggalVerifikasi != null) _buildDetailItem( 'Tanggal Verifikasi', - DateTimeHelper.formatDateTime(item.tanggalVerifikasi), + FormatHelper.formatDateTime(item.tanggalVerifikasi), ), if (item.status == 'TERVERIFIKASI' && item.petugasDesaId != null) _buildDetailItem( @@ -600,7 +610,7 @@ class RiwayatPenitipanView extends GetView { controller.getPetugasDesaNama(item.petugasDesaId), ), _buildDetailItem('Tanggal Dibuat', - DateTimeHelper.formatDateTime(item.createdAt)), + FormatHelper.formatDateTime(item.createdAt)), if (item.alasanPenolakan != null && item.alasanPenolakan!.isNotEmpty) _buildDetailItem('Alasan Penolakan', item.alasanPenolakan!), @@ -626,8 +636,10 @@ class RiwayatPenitipanView extends GetView { itemBuilder: (context, index) { return GestureDetector( onTap: () { - _showFullScreenImage( - context, item.fotoBantuan![index]); + ShowImageDialog.show( + context, + item.fotoBantuan![index], + ); }, child: Padding( padding: const EdgeInsets.only(right: 8.0), @@ -677,8 +689,10 @@ class RiwayatPenitipanView extends GetView { itemBuilder: (context, index) { return GestureDetector( onTap: () { - _showFullScreenImage( - context, item.fotoBantuan![index]); + ShowImageDialog.show( + context, + item.fotoBantuan![index], + ); }, child: Padding( padding: const EdgeInsets.only(right: 8.0), @@ -721,8 +735,10 @@ class RiwayatPenitipanView extends GetView { const SizedBox(height: 8), GestureDetector( onTap: () { - _showFullScreenImage( - context, item.fotoBuktiSerahTerima!); + ShowImageDialog.show( + context, + item.fotoBuktiSerahTerima!, + ); }, child: ClipRRect( borderRadius: BorderRadius.circular(8), @@ -757,58 +773,6 @@ class RiwayatPenitipanView extends GetView { ); } - void _showFullScreenImage(BuildContext context, String imageUrl) { - Get.dialog( - Dialog( - insetPadding: EdgeInsets.zero, - child: Stack( - fit: StackFit.expand, - children: [ - InteractiveViewer( - panEnabled: true, - minScale: 0.5, - maxScale: 4, - child: Image.network( - imageUrl, - fit: BoxFit.contain, - errorBuilder: (context, error, stackTrace) { - return Container( - color: Colors.grey.shade300, - child: const Center( - child: Icon( - Icons.error, - size: 50, - color: Colors.red, - ), - ), - ); - }, - ), - ), - Positioned( - top: 20, - right: 20, - child: GestureDetector( - onTap: () => Get.back(), - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.5), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.close, - color: Colors.white, - ), - ), - ), - ), - ], - ), - ), - ); - } - Widget _buildDetailItem(String label, String value) { return Padding( padding: const EdgeInsets.only(bottom: 8.0), diff --git a/lib/app/modules/petugas_desa/views/riwayat_penyaluran_view.dart b/lib/app/modules/petugas_desa/views/riwayat_penyaluran_view.dart index 4990e42..a9f5124 100644 --- a/lib/app/modules/petugas_desa/views/riwayat_penyaluran_view.dart +++ b/lib/app/modules/petugas_desa/views/riwayat_penyaluran_view.dart @@ -52,7 +52,7 @@ class RiwayatPenyaluranView extends GetView { .getKategoriBantuanName(item.kategoriBantuanId) .toLowerCase(); final tanggal = - DateTimeHelper.formatDateTime(item.tanggalPenyaluran).toLowerCase(); + FormatHelper.formatDateTime(item.tanggalPenyaluran).toLowerCase(); return nama.contains(searchText) || deskripsi.contains(searchText) || @@ -105,7 +105,7 @@ class RiwayatPenyaluranView extends GetView { ), ), Text( - '${DateTimeHelper.formatNumber(filteredList.length)} item', + '${FormatHelper.formatNumber(filteredList.length)} item', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Colors.grey, @@ -119,7 +119,7 @@ class RiwayatPenyaluranView extends GetView { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - 'Total: ${DateTimeHelper.formatNumber(filteredList.length)} item', + 'Total: ${FormatHelper.formatNumber(filteredList.length)} item', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Colors.grey, @@ -132,7 +132,7 @@ class RiwayatPenyaluranView extends GetView { size: 16, color: Colors.grey[600]), const SizedBox(width: 4), Text( - 'Update: ${DateTimeHelper.formatDateTimeWithHour(DateTime.now())}', + 'Update: ${FormatHelper.formatDateTimeWithHour(DateTime.now())}', style: TextStyle( fontSize: 12, color: Colors.grey[600], @@ -305,7 +305,7 @@ class RiwayatPenyaluranView extends GetView { child: _buildInfoItem( Icons.event, 'Tanggal', - DateTimeHelper.formatDateTime(item.tanggalPenyaluran, + FormatHelper.formatDateTime(item.tanggalPenyaluran, format: 'dd MMM yyyy HH:mm'), Theme.of(context).textTheme, ), @@ -316,17 +316,57 @@ class RiwayatPenyaluranView extends GetView { _buildInfoItem( Icons.people_outline, 'Jumlah Penerima', - '${DateTimeHelper.formatNumber(item.jumlahPenerima ?? 0)} orang', + '${FormatHelper.formatNumber(item.jumlahPenerima ?? 0)} orang', Theme.of(context).textTheme, ), if (item.alasanPembatalan != null && item.alasanPembatalan!.isNotEmpty) ...[ - const SizedBox(height: 8), - _buildInfoItem( - Icons.info_outline, - 'Alasan Pembatalan', - item.alasanPembatalan!, - Theme.of(context).textTheme, + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.shade200), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.cancel_outlined, + size: 20, + color: Colors.red.shade700, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Alasan Pembatalan', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + fontWeight: FontWeight.bold, + color: Colors.red.shade700, + ), + ), + const SizedBox(height: 4), + Text( + item.alasanPembatalan!, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: Colors.red.shade800, + ), + ), + ], + ), + ), + ], + ), ), ], const SizedBox(height: 16), diff --git a/lib/app/modules/petugas_desa/views/riwayat_stok_view.dart b/lib/app/modules/petugas_desa/views/riwayat_stok_view.dart index fccd7a4..fed5b74 100644 --- a/lib/app/modules/petugas_desa/views/riwayat_stok_view.dart +++ b/lib/app/modules/petugas_desa/views/riwayat_stok_view.dart @@ -6,6 +6,7 @@ import 'package:penyaluran_app/app/modules/petugas_desa/controllers/riwayat_stok import 'package:penyaluran_app/app/theme/app_theme.dart'; import 'package:penyaluran_app/app/utils/format_helper.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:penyaluran_app/app/widgets/widgets.dart'; class RiwayatStokView extends GetView { const RiwayatStokView({super.key}); @@ -353,7 +354,7 @@ class RiwayatStokView extends GetView { overflow: TextOverflow.ellipsis, ), ); - }).toList(), + }), ], onChanged: (value) { if (value != null) { @@ -543,7 +544,7 @@ class RiwayatStokView extends GetView { const SizedBox(height: 4), Text( riwayat.createdAt != null - ? DateTimeHelper.formatDateTime( + ? FormatHelper.formatDateTime( riwayat.createdAt!) : '-', style: TextStyle( @@ -598,7 +599,7 @@ class RiwayatStokView extends GetView { padding: const EdgeInsets.only(left: 44), child: InkWell( onTap: () => - _showImageDialog(context, riwayat.fotoBukti!), + ShowImageDialog.show(context, riwayat.fotoBukti!), child: Container( decoration: BoxDecoration( color: Colors.blue.withOpacity(0.1), @@ -704,97 +705,6 @@ class RiwayatStokView extends GetView { ); } - void _showImageDialog(BuildContext context, String imageUrl) { - showDialog( - context: context, - builder: (BuildContext context) { - return Dialog( - insetPadding: const EdgeInsets.all(16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AppBar( - leading: IconButton( - icon: const Icon( - Icons.close, - color: Colors.white, - ), - onPressed: () => Navigator.of(context).pop(), - ), - title: const Text( - 'Bukti Foto', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - elevation: 0, - backgroundColor: AppTheme.primaryColor, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), - ), - ), - ), - SizedBox( - height: MediaQuery.of(context).size.height * 0.5, - child: InteractiveViewer( - panEnabled: true, - boundaryMargin: const EdgeInsets.all(16), - minScale: 0.5, - maxScale: 4, - child: CachedNetworkImage( - imageUrl: imageUrl, - placeholder: (context, url) => const Center( - child: CircularProgressIndicator(), - ), - errorWidget: (context, url, error) => Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.error, color: Colors.red, size: 48), - const SizedBox(height: 16), - Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - 'Gagal memuat gambar: $error', - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.red), - ), - ), - ], - ), - fit: BoxFit.contain, - ), - ), - ), - Padding( - padding: const EdgeInsets.all(16.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.zoom_in, size: 20, color: Colors.grey), - const SizedBox(width: 8), - Text( - 'Cubit untuk memperbesar/memperkecil', - style: TextStyle( - color: Colors.grey[600], - fontSize: 14, - ), - ), - ], - ), - ), - ], - ), - ); - }, - ); - } - void _showStokManualDialog(BuildContext context, {required bool isAddition}) { // Reset form controller.resetForm(); @@ -1152,7 +1062,7 @@ class RiwayatStokView extends GetView { Widget _buildPenitipanDetail( BuildContext context, Map data) { final String tanggal = data['created_at'] != null - ? DateTimeHelper.formatDateTime(DateTime.parse(data['created_at'])) + ? FormatHelper.formatDateTime(DateTime.parse(data['created_at'])) : '-'; final String namaPenitip = data['donatur'] != null @@ -1357,7 +1267,8 @@ class RiwayatStokView extends GetView { padding: EdgeInsets.only( right: index < fotoBantuan.length - 1 ? 8.0 : 0), child: InkWell( - onTap: () => _showImageDialog(context, imageUrl), + onTap: () => + ShowImageDialog.show(context, imageUrl), child: Container( width: 200, decoration: BoxDecoration( @@ -1442,7 +1353,7 @@ class RiwayatStokView extends GetView { Widget _buildPenerimaanDetail( BuildContext context, Map data) { final String tanggal = data['created_at'] != null - ? DateTimeHelper.formatDateTime(DateTime.parse(data['created_at'])) + ? FormatHelper.formatDateTime(DateTime.parse(data['created_at'])) : '-'; final String namaPenerima = data['warga'] != null @@ -1646,7 +1557,7 @@ class RiwayatStokView extends GetView { ), const SizedBox(height: 12), InkWell( - onTap: () => _showImageDialog(context, buktiPenerimaan), + onTap: () => ShowImageDialog.show(context, buktiPenerimaan), child: Container( height: 180, width: double.infinity, diff --git a/lib/app/modules/petugas_desa/views/stok_bantuan_view.dart b/lib/app/modules/petugas_desa/views/stok_bantuan_view.dart index 1aeed6d..7232b3f 100644 --- a/lib/app/modules/petugas_desa/views/stok_bantuan_view.dart +++ b/lib/app/modules/petugas_desa/views/stok_bantuan_view.dart @@ -156,7 +156,7 @@ class StokBantuanView extends GetView { ), ), Text( - 'Rp ${DateTimeHelper.formatNumber(controller.totalDanaBantuan.value)}', + 'Rp ${FormatHelper.formatNumber(controller.totalDanaBantuan.value)}', style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, @@ -512,8 +512,8 @@ class StokBantuanView extends GetView { ), Text( item.isUang == true - ? 'Rp ${DateTimeHelper.formatNumber(item.totalStok)}' - : '${DateTimeHelper.formatNumber(item.totalStok)} ${item.satuan ?? ''}', + ? 'Rp ${FormatHelper.formatNumber(item.totalStok)}' + : '${FormatHelper.formatNumber(item.totalStok)} ${item.satuan ?? ''}', style: Theme.of(context) .textTheme .titleLarge @@ -549,7 +549,7 @@ class StokBantuanView extends GetView { Expanded( child: Text( item.updatedAt != null - ? 'Diperbarui: ${DateTimeHelper.formatDateTimeWithHour(item.updatedAt!)}' + ? 'Diperbarui: ${FormatHelper.formatDateTimeWithHour(item.updatedAt!)}' : 'Tidak ada data pembaruan', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Colors.grey[600], @@ -984,8 +984,8 @@ class StokBantuanView extends GetView { const SizedBox(width: 8), Text( isUang - ? 'Rp ${DateTimeHelper.formatNumber(stok.totalStok)}' - : '${DateTimeHelper.formatNumber(stok.totalStok)} ${stok.satuan ?? ''}', + ? 'Rp ${FormatHelper.formatNumber(stok.totalStok)}' + : '${FormatHelper.formatNumber(stok.totalStok)} ${stok.satuan ?? ''}', style: TextStyle(fontWeight: FontWeight.bold), ), ], @@ -1175,8 +1175,8 @@ class StokBantuanView extends GetView { SizedBox(width: 4), Text( stok.isUang == true - ? 'Rp ${DateTimeHelper.formatNumber(stok.totalStok)}' - : '${DateTimeHelper.formatNumber(stok.totalStok)} ${stok.satuan ?? ''}', + ? 'Rp ${FormatHelper.formatNumber(stok.totalStok)}' + : '${FormatHelper.formatNumber(stok.totalStok)} ${stok.satuan ?? ''}', style: TextStyle(fontWeight: FontWeight.bold), ), ], @@ -1240,7 +1240,7 @@ class StokBantuanView extends GetView { Widget _buildLastUpdateInfo(BuildContext context) { return Obx(() { final lastUpdate = controller.lastUpdateTime.value; - final formattedDate = DateTimeHelper.formatDateTimeWithHour(lastUpdate); + final formattedDate = FormatHelper.formatDateTimeWithHour(lastUpdate); return Padding( padding: const EdgeInsets.only(top: 8.0), diff --git a/lib/app/modules/petugas_desa/views/tambah_lokasi_penyaluran_view.dart b/lib/app/modules/petugas_desa/views/tambah_lokasi_penyaluran_view.dart new file mode 100644 index 0000000..79facf7 --- /dev/null +++ b/lib/app/modules/petugas_desa/views/tambah_lokasi_penyaluran_view.dart @@ -0,0 +1,233 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:uuid/uuid.dart'; +import 'package:penyaluran_app/app/theme/app_theme.dart'; +import 'package:penyaluran_app/app/modules/petugas_desa/controllers/jadwal_penyaluran_controller.dart'; + +class TambahLokasiPenyaluranView extends GetView { + const TambahLokasiPenyaluranView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Tambah Lokasi Penyaluran'), + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + ), + body: _buildTambahLokasiPenyaluranForm(context), + ); + } + + Widget _buildTambahLokasiPenyaluranForm(BuildContext context) { + final formKey = GlobalKey(); + final TextEditingController namaController = TextEditingController(); + final TextEditingController alamatLengkapController = + TextEditingController(); + + return Padding( + padding: const EdgeInsets.all(16.0), + child: Form( + key: formKey, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Judul Form + Text( + 'Formulir Lokasi Penyaluran', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + + // Nama Lokasi + Text( + 'Nama Lokasi', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + TextFormField( + controller: namaController, + decoration: InputDecoration( + hintText: 'Masukkan nama lokasi penyaluran', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Nama lokasi tidak boleh kosong'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Alamat Lengkap + Text( + 'Alamat Lengkap', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + TextFormField( + controller: alamatLengkapController, + maxLines: 3, + decoration: InputDecoration( + hintText: 'Masukkan alamat lengkap lokasi', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Alamat lengkap tidak boleh kosong'; + } + return null; + }, + ), + const SizedBox(height: 24), + + // Tombol Submit + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + if (formKey.currentState!.validate()) { + // Panggil fungsi untuk menambahkan lokasi penyaluran + _tambahLokasiPenyaluran( + nama: namaController.text, + alamatLengkap: alamatLengkapController.text, + ); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text( + 'Simpan Lokasi', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + Future _tambahLokasiPenyaluran({ + required String nama, + required String alamatLengkap, + }) async { + try { + // Tampilkan loading + Get.dialog( + const Center( + child: CircularProgressIndicator(), + ), + barrierDismissible: false, + ); + + // Generate UUID untuk ID lokasi + final uuid = const Uuid(); + final String id = uuid.v4(); + + // Ambil ID petugas desa yang sedang login dari controller + final String? petugasDesaId = controller.supabaseService.currentUser?.id; + + if (petugasDesaId == null) { + Get.back(); // Tutup dialog loading + ScaffoldMessenger.of(Get.context!).showSnackBar( + const SnackBar( + content: Text('Sesi login tidak valid. Silakan login kembali.'), + backgroundColor: Colors.red, + ), + ); + return; + } + + // Dapatkan desa_id dari data petugas desa + // Ambil data petugas desa dari Supabase untuk mendapatkan desa_id + final petugasDesaData = await controller.supabaseService.client + .from('petugas_desa') + .select('desa_id') + .eq('id', petugasDesaId) + .single(); + + final String? desaId = petugasDesaData['desa_id']; + + if (desaId == null) { + Get.back(); // Tutup dialog loading + ScaffoldMessenger.of(Get.context!).showSnackBar( + const SnackBar( + content: Text( + 'Data desa tidak ditemukan. Silakan hubungi administrator.'), + backgroundColor: Colors.red, + ), + ); + return; + } + + // Data untuk insert + final Map data = { + 'id': id, + 'nama': nama, + 'alamat_lengkap': alamatLengkap, + 'desa_id': desaId, + 'created_at': DateTime.now().toIso8601String(), + }; + + // Insert data ke tabel lokasi_penyaluran + await controller.supabaseService.client + .from('lokasi_penyaluran') + .insert(data); + + // Tutup dialog loading + Get.back(); + + // Tampilkan pesan sukses + ScaffoldMessenger.of(Get.context!).showSnackBar( + const SnackBar( + content: Text('Lokasi penyaluran berhasil ditambahkan'), + backgroundColor: Colors.green, + ), + ); + + // Kembali ke halaman sebelumnya + Get.back(); + + // Refresh data di controller + controller.refreshData(); + } catch (e) { + // Tutup dialog loading + Get.back(); + + // Tampilkan pesan error + ScaffoldMessenger.of(Get.context!).showSnackBar( + SnackBar( + content: Text('Gagal menambahkan lokasi penyaluran: $e'), + backgroundColor: Colors.red, + ), + ); + } + } +} diff --git a/lib/app/modules/petugas_desa/views/tambah_penyaluran_view.dart b/lib/app/modules/petugas_desa/views/tambah_penyaluran_view.dart index 8412768..b62c49c 100644 --- a/lib/app/modules/petugas_desa/views/tambah_penyaluran_view.dart +++ b/lib/app/modules/petugas_desa/views/tambah_penyaluran_view.dart @@ -12,9 +12,15 @@ class TambahPenyaluranView extends GetView { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('Tambah Penyaluran Bantuan'), + title: const Text('Tambah Penyaluran'), backgroundColor: AppTheme.primaryColor, foregroundColor: Colors.white, + elevation: 2, + centerTitle: true, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new_rounded), + onPressed: () => Get.back(), + ), ), body: _buildTambahPenyaluranForm(context), ); @@ -114,767 +120,1085 @@ class TambahPenyaluranView extends GetView { } } - return Padding( - padding: const EdgeInsets.all(16.0), - child: Form( - key: formKey, - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Judul Form - Text( - 'Formulir Penyaluran Bantuan', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 16), - - // Skema Bantuan - Text( - 'Skema Bantuan', - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(height: 8), - Obx(() => DropdownButtonFormField( - decoration: InputDecoration( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), + return Container( + decoration: BoxDecoration( + color: Colors.grey[50], + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Form( + key: formKey, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Form + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.1), + spreadRadius: 1, + blurRadius: 3, + offset: const Offset(0, 1), ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - ), - hint: const Text('Pilih skema bantuan'), - value: selectedSkemaBantuanId.value, - items: controller.skemaBantuanCache.entries - .map((entry) => DropdownMenuItem( - value: entry.key, - child: Text(entry.value.nama ?? 'Tidak ada nama'), - )) - .toList(), - onChanged: (value) async { - selectedSkemaBantuanId.value = value; - if (value != null) { - selectedSkemaBantuan.value = - controller.skemaBantuanCache[value]; - - // Set jumlah yang diterima per orang - jumlahDiterimaPerOrang.value = selectedSkemaBantuan - .value?.jumlahDiterimaPerOrang ?? - 0.0; - - // Load stok bantuan info - if (selectedSkemaBantuan.value?.stokBantuanId != null) { - await loadStokBantuanInfo( - selectedSkemaBantuan.value!.stokBantuanId!); - } else { - namaStokBantuan.value = 'Tidak ada stok terkait'; - } - - await loadPengajuanKelayakan(value); - - // Periksa apakah ada penerima - if (jumlahPenerima.value == 0) { - Get.snackbar( - 'Perhatian', - 'Skema bantuan ini tidak memiliki penerima yang terverifikasi!', - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.red, - colorText: Colors.white, - duration: const Duration(seconds: 4), - ); - } - } - }, - validator: (value) { - if (value == null || value.isEmpty) { - return 'Skema bantuan harus dipilih'; - } - return null; - }, - )), - - // const SizedBox(height: 16), - // Pesan pemberitahuan jika tidak ada penerima - Obx(() => jumlahPenerima.value == 0 && - selectedSkemaBantuanId.value != null - ? Container( - margin: const EdgeInsets.only(top: 16), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.red.shade50, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.red.shade200), - ), - child: Row( + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( children: [ - Icon(Icons.warning_amber_rounded, - color: Colors.red.shade700), - const SizedBox(width: 12), + Icon( + Icons.edit_note_rounded, + color: AppTheme.primaryColor, + size: 28, + ), + const SizedBox(width: 10), Expanded( child: Text( - 'Skema bantuan ini tidak memiliki penerima yang terverifikasi. Tambahkan penerima terlebih dahulu.', - style: TextStyle( - color: Colors.red.shade700, - fontWeight: FontWeight.w500, - ), + 'Formulir Penyaluran Bantuan', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + fontWeight: FontWeight.bold, + color: AppTheme.primaryColor, + ), ), ), ], ), - ) - : const SizedBox()), - const SizedBox(height: 16), - // Jumlah Penerima (Otomatis) - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - 'Jumlah Penerima', - style: Theme.of(context).textTheme.titleSmall, + const SizedBox(height: 8), + const Divider(), + const SizedBox(height: 8), + Text( + 'Masukkan detail penyaluran bantuan untuk dijadwalkan. Pastikan stok mencukupi dan data penerima sudah terverifikasi.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey[700], ), - const SizedBox(width: 4), - Tooltip( - message: - 'Jumlah penerima dari pengajuan kelayakan yang terverifikasi.', - triggerMode: TooltipTriggerMode.tap, - child: Icon( - Icons.info_outline, - size: 16, - color: Colors.grey[600], - ), - ), - ], - ), - const SizedBox(height: 8), - Obx(() => TextFormField( - readOnly: true, - controller: TextEditingController( - text: jumlahPenerima.value.toString()), - decoration: InputDecoration( - hintText: - 'Jumlah penerima akan diambil otomatis', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - ), - )), - ], - ), - ), - const SizedBox(width: 8), - Expanded( - child: Column( - children: [ - // Jumlah Diterima Per Orang (dari skema) - Row( - children: [ - Text( - 'Jumlah Per Penerima', - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(width: 4), - Tooltip( - message: - 'Jumlah yang akan diterima setiap penerima bantuan.', - triggerMode: TooltipTriggerMode.tap, - child: Icon( - Icons.info_outline, - size: 16, - color: Colors.grey[600], - ), - ), - ], - ), - const SizedBox(height: 8), - Obx(() => TextFormField( - readOnly: true, - controller: TextEditingController( - text: - jumlahDiterimaPerOrang.value.toString()), - decoration: InputDecoration( - hintText: - 'Jumlah diterima per orang dari skema bantuan', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - suffixText: satuanStokBantuan.value.isNotEmpty - ? satuanStokBantuan.value - : 'satuan', - ), - )), - ], - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - 'Daftar Penerima', - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(height: 4), - Obx(() => OutlinedButton.icon( - onPressed: jumlahPenerima.value > 0 - ? () async { - final pengajuanData = await controller - .supabaseService.client - .from('xx02_pengajuan_kelayakan_bantuan') - .select('*, warga:warga_id(*)') - .eq('skema_bantuan_id', - selectedSkemaBantuanId.value ?? '') - .eq('status', 'TERVERIFIKASI'); - - Get.dialog( - Dialog( - child: Container( - width: - MediaQuery.of(context).size.width * 0.9, - height: - MediaQuery.of(context).size.height * 0.8, - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - const Text( - 'Daftar Penerima Bantuan', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - IconButton( - onPressed: () => Get.back(), - icon: const Icon(Icons.close), - ), - ], - ), - const SizedBox(height: 16), - Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: SingleChildScrollView( - child: DataTable( - columnSpacing: 20, - horizontalMargin: 20, - columns: const [ - DataColumn(label: Text('No')), - DataColumn(label: Text('Nama')), - DataColumn(label: Text('NIK')), - DataColumn( - label: Text('Alamat')), - ], - rows: pengajuanData - .asMap() - .entries - .map((entry) { - final warga = - entry.value['warga']; - return DataRow( - cells: [ - DataCell(Text( - '${entry.key + 1}')), - DataCell(Text( - warga['nama_lengkap'] ?? - '-')), - DataCell(Text( - warga['nik'] ?? '-')), - DataCell(Text( - warga['alamat'] ?? - '-')), - ], - ); - }).toList(), - ), - ), - ), - ), - ], - ), - ), - ), - ); - } - : null, - icon: const Icon(Icons.people), - label: const Text('Lihat Daftar'), - style: ButtonStyle( - foregroundColor: - WidgetStateProperty.resolveWith((states) { - return jumlahPenerima.value <= 0 - ? Colors.grey - : Theme.of(context).primaryColor; - }), - backgroundColor: - WidgetStateProperty.resolveWith((states) { - return jumlahPenerima.value <= 0 - ? Colors.grey.withOpacity(0.1) - : Theme.of(context).primaryColor.withOpacity(0.1); - }), - padding: WidgetStateProperty.all( - const EdgeInsets.symmetric(horizontal: 16, vertical: 8), ), - shape: WidgetStateProperty.all( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - side: BorderSide( - color: jumlahPenerima.value <= 0 - ? Colors.grey.withOpacity(0.5) - : Theme.of(context).primaryColor, - ), - ), - ), - ), - )), - - const SizedBox(height: 16), - - // Informasi Stok Bantuan - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.amber[50], - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.amber[200]!), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Informasi Stok Bantuan', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - color: Colors.amber[800], - ), - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Nama Stok'), - const SizedBox(height: 4), - Obx(() => Text( - namaStokBantuan.value, - style: const TextStyle( - fontWeight: FontWeight.bold), - )), - ], - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Satuan Stok'), - const SizedBox(height: 4), - Obx(() => Text( - satuanStokBantuan.value, - style: const TextStyle( - fontWeight: FontWeight.bold), - )), - ], - ), - ), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Total Stok Tersedia'), - const SizedBox(height: 4), - Obx(() => Text( - isUang.value - ? 'Rp ${DateTimeHelper.formatNumber(totalStokTersedia.value)}' - : '${totalStokTersedia.value} ${satuanStokBantuan.value}', - style: const TextStyle( - fontWeight: FontWeight.bold), - )), - ], - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Text('Total Stok Dibutuhkan'), - const SizedBox(width: 4), - Tooltip( - message: - 'Total stok yang dibutuhkan dihitung dari jumlah penerima × jumlah yang diterima per orang', - triggerMode: TooltipTriggerMode.tap, - child: const Icon(Icons.info_outline, - size: 16), - ), - ], - ), - const SizedBox(height: 4), - Obx(() => Text( - isUang.value - ? 'Rp ${DateTimeHelper.formatNumber(totalStokDibutuhkan.value)}' - : '${totalStokDibutuhkan.value} ${satuanStokBantuan.value}', - style: const TextStyle( - fontWeight: FontWeight.bold), - )), - ], - ), - ), - ], - ), - const SizedBox(height: 12), - Obx(() => selectedSkemaBantuanId.value != null - ? Container( - padding: const EdgeInsets.symmetric( - vertical: 6, horizontal: 12), - decoration: BoxDecoration( - color: isStokCukup.value - ? Colors.green.withOpacity(0.1) - : Colors.red.withOpacity(0.1), - borderRadius: BorderRadius.circular(4), - border: Border.all( - color: isStokCukup.value - ? Colors.green - : Colors.red, - ), - ), - child: Row( - children: [ - Icon( - isStokCukup.value - ? Icons.check_circle - : Icons.error, - color: isStokCukup.value - ? Colors.green - : Colors.red, - size: 16, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - isStokCukup.value - ? 'Stok tersedia cukup untuk penyaluran' - : 'Stok tidak cukup untuk penyaluran! Tambah stok terlebih dahulu.', - style: TextStyle( - color: isStokCukup.value - ? Colors.green[800] - : Colors.red[800], - fontWeight: FontWeight.bold, - fontSize: 12, - ), - ), - ), - ], - ), - ) - : const SizedBox()), - ], - ), - ), - const SizedBox(height: 16), - // Nama Penyaluran - Text( - 'Judul Penyaluran', - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(height: 8), - TextFormField( - controller: namaController, - decoration: InputDecoration( - hintText: 'Masukkan judul penyaluran', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, + ], ), ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Judul penyaluran tidak boleh kosong'; - } - return null; - }, - ), - const SizedBox(height: 16), + const SizedBox(height: 20), - // Lokasi Penyaluran - Text( - 'Lokasi Penyaluran', - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(height: 8), - Obx(() => DropdownButtonFormField( - decoration: InputDecoration( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - ), - hint: const Text('Pilih lokasi penyaluran'), - value: selectedLokasiPenyaluranId.value, - items: controller.lokasiPenyaluranCache.entries - .map((entry) => DropdownMenuItem( - value: entry.key, - child: Text(entry.value.nama), - )) - .toList(), - onChanged: (value) { - selectedLokasiPenyaluranId.value = value; - }, - validator: (value) { - if (value == null || value.isEmpty) { - return 'Lokasi penyaluran harus dipilih'; - } - return null; - }, - )), - const SizedBox(height: 16), + // Content sections will be added here + // Bagian 1: Skema Bantuan + _buildSectionContainer( + context, + title: 'Skema Bantuan', + icon: Icons.category_rounded, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + Obx(() => DropdownButtonFormField( + isExpanded: true, + decoration: InputDecoration( + labelText: 'Pilih Skema Bantuan', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + prefixIcon: const Icon(Icons.list_alt_rounded), + ), + hint: const Text( + 'Pilih skema bantuan yang akan disalurkan'), + value: selectedSkemaBantuanId.value, + items: controller.skemaBantuanCache.entries + .map((entry) => DropdownMenuItem( + value: entry.key, + child: Text( + entry.value.nama ?? 'Tidak ada nama'), + )) + .toList(), + onChanged: (value) async { + selectedSkemaBantuanId.value = value; + if (value != null) { + selectedSkemaBantuan.value = + controller.skemaBantuanCache[value]; - // Tanggal Penyaluran - Row( - children: [ - Text( - 'Tanggal Penyaluran', - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(width: 4), - Tooltip( - message: - 'Tanggal pelaksanaan minimal 1 hari sebelum dijadwalkan', - triggerMode: TooltipTriggerMode.tap, - child: Icon( - Icons.info_outline, - size: 16, - color: Colors.grey[600], - ), - ), - ], - ), - const SizedBox(height: 8), - TextFormField( - controller: tanggalPenyaluranController, - readOnly: true, - decoration: InputDecoration( - hintText: 'Pilih tanggal penyaluran', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - suffixIcon: const Icon(Icons.calendar_today), - ), - onTap: () async { - // Tanggal minimal adalah 1 hari setelah hari ini - final DateTime tomorrow = - DateTime.now().add(const Duration(days: 1)); - final DateTime? pickedDate = await showDatePicker( - context: context, - initialDate: tomorrow, - firstDate: tomorrow, - lastDate: DateTime.now().add(const Duration(days: 365)), - ); - if (pickedDate != null) { - selectedDate.value = pickedDate; - tanggalPenyaluranController.text = - DateTimeHelper.formatDate(pickedDate); - } - }, - validator: (value) { - if (value == null || value.isEmpty) { - return 'Tanggal penyaluran harus dipilih'; - } - return null; - }, - ), + // Set jumlah yang diterima per orang + jumlahDiterimaPerOrang.value = + selectedSkemaBantuan + .value?.jumlahDiterimaPerOrang ?? + 0.0; - const SizedBox(height: 16), + // Load stok bantuan info + if (selectedSkemaBantuan.value?.stokBantuanId != + null) { + await loadStokBantuanInfo(selectedSkemaBantuan + .value!.stokBantuanId!); + } else { + namaStokBantuan.value = + 'Tidak ada stok terkait'; + } - // Waktu Mulai - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Waktu Mulai'), - const SizedBox(height: 4), - TextFormField( - controller: waktuMulaiController, - readOnly: true, - decoration: InputDecoration( - hintText: 'Pilih waktu mulai', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - suffixIcon: const Icon(Icons.access_time), - ), - onTap: () async { - final TimeOfDay? pickedTime = await showTimePicker( - context: context, - initialTime: TimeOfDay.now(), - ); - if (pickedTime != null) { - selectedWaktuMulai.value = pickedTime; - waktuMulaiController.text = - '${pickedTime.hour.toString().padLeft(2, '0')}:${pickedTime.minute.toString().padLeft(2, '0')}'; - } - }, - validator: (value) { - if (value == null || value.isEmpty) { - return 'Waktu mulai harus dipilih'; - } - return null; - }, - ), - ], - ), - const SizedBox(height: 16), + await loadPengajuanKelayakan(value); - // Deskripsi - Text( - 'Deskripsi', - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(height: 8), - TextFormField( - controller: deskripsiController, - maxLines: 4, - decoration: InputDecoration( - hintText: 'Masukkan deskripsi penyaluran', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Deskripsi tidak boleh kosong'; - } - return null; - }, - ), - const SizedBox(height: 24), - - // Tombol Submit - SizedBox( - width: double.infinity, - child: Obx(() => ElevatedButton( - onPressed: jumlahPenerima.value > 0 - ? () { - if (formKey.currentState!.validate()) { - // Periksa kecukupan stok - if (!isStokCukup.value) { + // Periksa apakah ada penerima + if (jumlahPenerima.value == 0) { Get.snackbar( - 'Stok Tidak Cukup', - 'Stok bantuan tidak mencukupi untuk penyaluran ini. Silakan tambah stok terlebih dahulu.', + 'Perhatian', + 'Skema bantuan ini tidak memiliki penerima yang terverifikasi!', snackPosition: SnackPosition.BOTTOM, backgroundColor: Colors.red, colorText: Colors.white, duration: const Duration(seconds: 4), ); - return; } - - // Gabungkan tanggal dan waktu mulai - DateTime? tanggalWaktuMulai; - if (selectedDate.value != null && - selectedWaktuMulai.value != null) { - tanggalWaktuMulai = DateTime( - selectedDate.value!.year, - selectedDate.value!.month, - selectedDate.value!.day, - selectedWaktuMulai.value!.hour, - selectedWaktuMulai.value!.minute, - ).toLocal(); - } - - // Panggil fungsi untuk menambahkan penyaluran - controller.tambahPenyaluran( - nama: namaController.text, - deskripsi: deskripsiController.text, - skemaId: selectedSkemaBantuanId.value!, - lokasiPenyaluranId: - selectedLokasiPenyaluranId.value!, - jumlahPenerima: jumlahPenerima.value, - tanggalPenyaluran: tanggalWaktuMulai, - kategoriBantuanId: selectedSkemaBantuan - .value!.kategoriBantuanId!, - jumlahDiterimaPerOrang: - jumlahDiterimaPerOrang.value, - stokBantuanId: selectedSkemaBantuan - .value!.stokBantuanId!, - totalStokDibutuhkan: - totalStokDibutuhkan.value); - - //get back and refresh page - Get.back(); - controller.refreshData(); } - } - : null, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primaryColor, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - disabledBackgroundColor: Colors.grey.shade300, - disabledForegroundColor: Colors.grey.shade600, + }, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Skema bantuan harus dipilih'; + } + return null; + }, + )), + + // Pesan pemberitahuan jika tidak ada penerima + Obx(() => jumlahPenerima.value == 0 && + selectedSkemaBantuanId.value != null + ? Container( + margin: const EdgeInsets.only(top: 16), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.shade200), + ), + child: Row( + children: [ + Icon(Icons.warning_amber_rounded, + color: Colors.red.shade700), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Skema bantuan ini tidak memiliki penerima yang terverifikasi. Tambahkan penerima terlebih dahulu.', + style: TextStyle( + color: Colors.red.shade700, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ) + : const SizedBox()), + ], + ), + ), + const SizedBox(height: 16), + + // Bagian 2: Informasi Penerima Bantuan + _buildSectionContainer( + context, + title: 'Informasi Penerima', + icon: Icons.people_rounded, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Jumlah Penerima + const Text( + 'Jumlah Penerima', + style: TextStyle( + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + Obx(() => Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey[300]!), + ), + child: Row( + children: [ + const Icon(Icons.people), + const SizedBox(width: 8), + Text( + jumlahPenerima.value.toString(), + style: const TextStyle(fontSize: 16), + ), + ], + ), + )), + + const SizedBox(height: 16), + + // Jumlah Per Penerima + const Text( + 'Jumlah Per Penerima', + style: TextStyle( + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + Obx(() => Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey[300]!), + ), + child: Row( + children: [ + isUang.value + ? const Icon(Icons.attach_money) + : const Icon(Icons.inventory_2), + const SizedBox(width: 8), + Expanded( + child: Text( + jumlahDiterimaPerOrang.value.toString(), + style: const TextStyle(fontSize: 16), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 4), + Text( + satuanStokBantuan.value.isNotEmpty + ? satuanStokBantuan.value + : 'satuan', + style: TextStyle( + color: Colors.grey[700], + fontWeight: FontWeight.w500, + ), + ), + ], + ), + )), + ], ), - child: const Text( - 'Simpan Penyaluran', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, + const SizedBox(height: 16), + // Tombol lihat daftar penerima + Obx(() => Center( + child: ElevatedButton.icon( + onPressed: jumlahPenerima.value > 0 + ? () async { + final pengajuanData = await controller + .supabaseService.client + .from( + 'xx02_pengajuan_kelayakan_bantuan') + .select('*, warga:warga_id(*)') + .eq( + 'skema_bantuan_id', + selectedSkemaBantuanId.value ?? + '') + .eq('status', 'TERVERIFIKASI'); + + Get.dialog( + Dialog( + child: Container( + width: MediaQuery.of(context) + .size + .width * + 0.9, + height: MediaQuery.of(context) + .size + .height * + 0.8, + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: + MainAxisAlignment + .spaceBetween, + children: [ + Row( + children: [ + Icon( + Icons.people_alt, + color: AppTheme + .primaryColor, + ), + const SizedBox( + width: 8), + const Text( + 'Daftar Penerima ', + style: TextStyle( + fontSize: 18, + fontWeight: + FontWeight.bold, + ), + ), + ], + ), + IconButton( + onPressed: () => + Get.back(), + icon: const Icon( + Icons.close), + ), + ], + ), + const SizedBox(height: 8), + const Divider(), + const SizedBox(height: 16), + Expanded( + child: SingleChildScrollView( + scrollDirection: + Axis.horizontal, + child: + SingleChildScrollView( + child: DataTable( + columnSpacing: 20, + horizontalMargin: 20, + headingRowColor: + MaterialStateProperty + .all(Colors + .grey[100]), + columns: const [ + DataColumn( + label: + Text('No')), + DataColumn( + label: + Text('Nama')), + DataColumn( + label: + Text('NIK')), + DataColumn( + label: Text( + 'Alamat')), + ], + rows: pengajuanData + .asMap() + .entries + .map((entry) { + final warga = entry + .value['warga']; + return DataRow( + cells: [ + DataCell(Text( + '${entry.key + 1}')), + DataCell(Text( + warga['nama_lengkap'] ?? + '-')), + DataCell(Text( + warga['nik'] ?? + '-')), + DataCell(Text( + warga['alamat'] ?? + '-')), + ], + ); + }).toList(), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + : null, + icon: Icon( + Icons.list, + color: jumlahPenerima.value > 0 + ? Colors.white + : Colors.grey[400], + ), + label: Text( + 'Lihat Daftar Penerima', + style: TextStyle( + color: jumlahPenerima.value > 0 + ? Colors.white + : Colors.grey[400], + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: jumlahPenerima.value > 0 + ? AppTheme.primaryColor + : Colors.grey[200], + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + )), + ], + ), + ), + const SizedBox(height: 16), + + // Bagian 3: Informasi Stok Bantuan + _buildSectionContainer( + context, + title: 'Informasi Stok Bantuan', + icon: Icons.inventory_2_rounded, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + // Informasi stok + Obx(() => Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.amber[50], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.amber[200]!), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.info_outline_rounded, + color: Colors.amber[800], + size: 20, + ), + const SizedBox(width: 8), + Text( + 'Detail Stok', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith( + fontWeight: FontWeight.bold, + color: Colors.amber[800], + ), + ), + ], + ), + const SizedBox(height: 16), + // Baris pertama: Nama dan Satuan + Row( + children: [ + Expanded( + child: _buildInfoItem( + context, + icon: Icons.inventory, + title: 'Nama Stok', + value: namaStokBantuan.value, + iconColor: Colors.amber[800]!, + ), + ), + Expanded( + child: _buildInfoItem( + context, + icon: Icons.straighten, + title: 'Satuan', + value: satuanStokBantuan.value.isEmpty + ? '-' + : satuanStokBantuan.value, + iconColor: Colors.amber[800]!, + ), + ), + ], + ), + const SizedBox(height: 16), + // Baris kedua: Total Tersedia dan Dibutuhkan + Row( + children: [ + Expanded( + child: _buildInfoItem( + context, + icon: Icons.storage, + title: 'Total Tersedia', + value: isUang.value + ? 'Rp ${FormatHelper.formatNumber(totalStokTersedia.value)}' + : '${totalStokTersedia.value} ${satuanStokBantuan.value}', + iconColor: Colors.blue[700]!, + ), + ), + Expanded( + child: _buildInfoItem( + context, + icon: Icons.shopping_basket, + title: 'Total Dibutuhkan', + value: isUang.value + ? 'Rp ${FormatHelper.formatNumber(totalStokDibutuhkan.value)}' + : '${totalStokDibutuhkan.value} ${satuanStokBantuan.value}', + iconColor: Colors.purple[700]!, + ), + ), + ], + ), + const SizedBox(height: 16), + // Status kecukupan stok + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isStokCukup.value + ? Colors.green.withOpacity(0.1) + : Colors.red.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isStokCukup.value + ? Colors.green + : Colors.red, + ), + ), + child: Row( + children: [ + Icon( + isStokCukup.value + ? Icons.check_circle + : Icons.error, + color: isStokCukup.value + ? Colors.green + : Colors.red, + size: 24, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + isStokCukup.value + ? 'Stok Tersedia Mencukupi' + : 'Stok Tidak Mencukupi', + style: TextStyle( + color: isStokCukup.value + ? Colors.green[800] + : Colors.red[800], + fontWeight: FontWeight.bold, + ), + ), + if (!isStokCukup.value) ...[ + const SizedBox(height: 4), + Text( + 'Tambah stok terlebih dahulu sebelum melanjutkan penyaluran.', + style: TextStyle( + color: Colors.red[700], + fontSize: 12, + ), + ), + ], + ], + ), + ), + if (isStokCukup.value) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.green[50], + borderRadius: + BorderRadius.circular(16), + border: Border.all( + color: Colors.green[300]!, + ), + ), + child: Text( + 'Siap Disalurkan', + style: TextStyle( + color: Colors.green[800], + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ], + ), + ), + ], + ), + )), + ], + ), + ), + const SizedBox(height: 16), + + // Bagian 4: Detail Penyaluran + _buildSectionContainer( + context, + title: 'Detail Penyaluran', + icon: Icons.event_note_rounded, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + + TextFormField( + controller: namaController, + decoration: InputDecoration( + labelText: 'Masukkan judul penyaluran', + hintText: 'Contoh: Penyaluran BLT Desa April 2023', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + prefixIcon: const Icon(Icons.title), ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Judul penyaluran tidak boleh kosong'; + } + return null; + }, ), - )), - ), - ], + const SizedBox(height: 16), + + Obx(() => DropdownButtonFormField( + decoration: InputDecoration( + labelText: 'Pilih lokasi penyaluran', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + prefixIcon: const Icon(Icons.location_on), + ), + hint: const Text('Pilih lokasi penyaluran'), + value: selectedLokasiPenyaluranId.value, + items: controller.lokasiPenyaluranCache.entries + .map((entry) => DropdownMenuItem( + value: entry.key, + child: Text(entry.value.nama), + )) + .toList(), + onChanged: (value) { + selectedLokasiPenyaluranId.value = value; + }, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Lokasi penyaluran harus dipilih'; + } + return null; + }, + )), + const SizedBox(height: 16), + + // Tanggal dan Waktu dalam satu baris + Row( + children: [ + // Tanggal Penyaluran + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Tanggal Penyaluran', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 4), + Tooltip( + message: + 'Tanggal pelaksanaan minimal 1 hari setelah hari ini', + triggerMode: TooltipTriggerMode.tap, + child: Icon( + Icons.info_outline, + size: 16, + color: Colors.grey[600], + ), + ), + ], + ), + const SizedBox(height: 8), + TextFormField( + controller: tanggalPenyaluranController, + readOnly: true, + decoration: InputDecoration( + hintText: 'Pilih tanggal', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + prefixIcon: + const Icon(Icons.calendar_today), + ), + onTap: () async { + // Tanggal minimal adalah 1 hari setelah hari ini + final DateTime tomorrow = DateTime.now() + .add(const Duration(days: 1)); + final DateTime? pickedDate = + await showDatePicker( + context: context, + initialDate: tomorrow, + firstDate: tomorrow, + lastDate: DateTime.now() + .add(const Duration(days: 365)), + ); + if (pickedDate != null) { + selectedDate.value = pickedDate; + tanggalPenyaluranController.text = + FormatHelper.formatDateTime( + pickedDate); + } + }, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Tanggal penyaluran harus dipilih'; + } + return null; + }, + ), + ], + ), + ), + const SizedBox(width: 16), + // Waktu Mulai + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Waktu Mulai', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: waktuMulaiController, + readOnly: true, + decoration: InputDecoration( + hintText: 'Pilih waktu', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + prefixIcon: const Icon(Icons.access_time), + ), + onTap: () async { + final TimeOfDay? pickedTime = + await showTimePicker( + context: context, + initialTime: TimeOfDay.now(), + ); + if (pickedTime != null) { + selectedWaktuMulai.value = pickedTime; + waktuMulaiController.text = + '${pickedTime.hour.toString().padLeft(2, '0')}:${pickedTime.minute.toString().padLeft(2, '0')}'; + } + }, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Waktu mulai harus dipilih'; + } + return null; + }, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + + // Deskripsi + Text( + 'Deskripsi Penyaluran', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: deskripsiController, + maxLines: 4, + decoration: InputDecoration( + hintText: 'Masukkan deskripsi detail penyaluran', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.all(12), + prefixIcon: Padding( + padding: const EdgeInsets.fromLTRB(0, 10, 0, 72), + child: Icon(Icons.description, + color: Colors.grey[600]), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Deskripsi tidak boleh kosong'; + } + return null; + }, + ), + ], + ), + ), + const SizedBox(height: 24), + + // Tombol Simpan + Container( + margin: const EdgeInsets.only(top: 24, bottom: 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.1), + spreadRadius: 1, + blurRadius: 3, + offset: const Offset(0, 1), + ), + ], + ), + child: Column( + children: [ + Obx(() => !isStokCukup.value || jumlahPenerima.value <= 0 + ? Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.shade200), + ), + child: Row( + children: [ + Icon(Icons.warning_amber_rounded, + color: Colors.red.shade700), + const SizedBox(width: 12), + Expanded( + child: Text( + !isStokCukup.value + ? 'Stok tidak mencukupi untuk penyaluran. Tambah stok terlebih dahulu.' + : 'Tidak ada penerima bantuan yang terverifikasi.', + style: TextStyle( + color: Colors.red.shade700, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ) + : const SizedBox()), + SizedBox( + width: double.infinity, + child: Obx(() => ElevatedButton( + onPressed: (jumlahPenerima.value > 0 && + isStokCukup.value) + ? () { + if (formKey.currentState!.validate()) { + // Gabungkan tanggal dan waktu mulai + DateTime? tanggalWaktuMulai; + if (selectedDate.value != null && + selectedWaktuMulai.value != null) { + tanggalWaktuMulai = DateTime( + selectedDate.value!.year, + selectedDate.value!.month, + selectedDate.value!.day, + selectedWaktuMulai.value!.hour, + selectedWaktuMulai.value!.minute, + ).toLocal(); + } + + // Panggil fungsi untuk menambahkan penyaluran + controller.tambahPenyaluran( + nama: namaController.text, + deskripsi: deskripsiController.text, + skemaId: + selectedSkemaBantuanId.value!, + lokasiPenyaluranId: + selectedLokasiPenyaluranId + .value!, + jumlahPenerima: + jumlahPenerima.value, + tanggalPenyaluran: + tanggalWaktuMulai, + kategoriBantuanId: + selectedSkemaBantuan + .value!.kategoriBantuanId!, + jumlahDiterimaPerOrang: + jumlahDiterimaPerOrang.value, + stokBantuanId: selectedSkemaBantuan + .value!.stokBantuanId!, + totalStokDibutuhkan: + totalStokDibutuhkan.value); + + // get back and refresh page + Get.back(); + controller.refreshData(); + } + } + : null, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + padding: + const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + disabledBackgroundColor: Colors.grey.shade300, + disabledForegroundColor: Colors.grey.shade600, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.save), + const SizedBox(width: 8), + const Text( + 'Simpan Penyaluran', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + )), + ), + ], + ), + ), + + // Padding bottom untuk scroll + const SizedBox(height: 24), + ], + ), ), ), ), ); } + + Widget _buildSectionContainer( + BuildContext context, { + required String title, + required IconData icon, + required Widget child, + }) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.1), + spreadRadius: 1, + blurRadius: 3, + offset: const Offset(0, 1), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + icon, + color: AppTheme.primaryColor, + size: 28, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: AppTheme.primaryColor, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + const Divider(), + const SizedBox(height: 8), + child, + ], + ), + ); + } + + Widget _buildInfoItem( + BuildContext context, { + required IconData icon, + required String title, + required String value, + required Color iconColor, + String? tooltip, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + icon, + color: iconColor, + size: 20, + ), + const SizedBox(width: 8), + Text( + title, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + value, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + if (tooltip != null) ...[ + const SizedBox(height: 8), + Tooltip( + message: tooltip, + child: Icon( + Icons.info_outline_rounded, + color: Colors.grey, + size: 16, + ), + ), + ], + ], + ); + } } diff --git a/lib/app/modules/profile/views/profile_view.dart b/lib/app/modules/profile/views/profile_view.dart index 8091ffe..982eccc 100644 --- a/lib/app/modules/profile/views/profile_view.dart +++ b/lib/app/modules/profile/views/profile_view.dart @@ -231,12 +231,39 @@ class ProfileView extends GetView { Widget _buildDefaultProfileImage() { return CircleAvatar( radius: 60, - backgroundColor: AppTheme.primaryColor.withOpacity(0.1), - child: const Icon( - Icons.person, - size: 70, - color: AppTheme.primaryColor, - ), + backgroundColor: AppTheme.primaryColor.withOpacity(0.2), + child: Obx(() { + final user = controller.user.value; + final roleData = controller.roleData.value; + + String displayInitial = '?'; + + if (roleData != null && roleData.isNotEmpty) { + final roleDataValue = roleData; + if (roleDataValue['nama_lengkap'] != null && + roleDataValue['nama_lengkap'].toString().isNotEmpty) { + displayInitial = roleDataValue['nama_lengkap'] + .toString() + .substring(0, 1) + .toUpperCase(); + } else if (roleDataValue['nama'] != null && + roleDataValue['nama'].toString().isNotEmpty) { + displayInitial = + roleDataValue['nama'].toString().substring(0, 1).toUpperCase(); + } + } else if (user != null && user.name != null && user.name!.isNotEmpty) { + displayInitial = user.name!.substring(0, 1).toUpperCase(); + } + + return Text( + displayInitial, + style: TextStyle( + fontWeight: FontWeight.bold, + color: AppTheme.primaryColor, + fontSize: 60, + ), + ); + }), ); } diff --git a/lib/app/modules/splash/views/splash_view.dart b/lib/app/modules/splash/views/splash_view.dart index ee60051..ce44d98 100644 --- a/lib/app/modules/splash/views/splash_view.dart +++ b/lib/app/modules/splash/views/splash_view.dart @@ -33,23 +33,20 @@ class _SplashViewState extends State { Widget build(BuildContext context) { return Scaffold( body: Container( - decoration: BoxDecoration( - gradient: AppTheme.primaryGradient, - ), child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Image.asset( - 'assets/images/logo.png', - width: 120, - height: 120, + 'assets/images/logo-disalurkita.png', + width: 150, + height: 150, errorBuilder: (context, error, stackTrace) { return Container( width: 120, height: 120, decoration: BoxDecoration( - color: Colors.white, + color: AppTheme.primaryColor, borderRadius: BorderRadius.circular(20), ), child: const Icon( @@ -62,24 +59,25 @@ class _SplashViewState extends State { ), const SizedBox(height: 24), const Text( - 'Aplikasi Penyaluran', + 'DisalurKita', style: TextStyle( fontSize: 24, fontWeight: FontWeight.bold, - color: Colors.white, + color: AppTheme.primaryColor, ), ), const SizedBox(height: 8), const Text( - 'Bantuan Sosial', + 'Salurkan dengan Pasti, Pantau dengan Bukti', style: TextStyle( - fontSize: 18, - color: Colors.white, + fontSize: 16, + color: AppTheme.primaryColor, ), ), const SizedBox(height: 48), const CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(Colors.white), + valueColor: + AlwaysStoppedAnimation(AppTheme.primaryColor), ), ], ), diff --git a/lib/app/modules/warga/views/detail_pengaduan_view.dart b/lib/app/modules/warga/views/detail_pengaduan_view.dart index d95d94e..5e5d3ea 100644 --- a/lib/app/modules/warga/views/detail_pengaduan_view.dart +++ b/lib/app/modules/warga/views/detail_pengaduan_view.dart @@ -1,16 +1,16 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:intl/intl.dart'; import 'package:penyaluran_app/app/data/models/pengaduan_model.dart'; import 'package:penyaluran_app/app/data/models/tindakan_pengaduan_model.dart'; import 'package:penyaluran_app/app/modules/warga/controllers/warga_dashboard_controller.dart'; import 'package:penyaluran_app/app/theme/app_theme.dart'; +import 'package:penyaluran_app/app/utils/format_helper.dart'; import 'package:timeline_tile/timeline_tile.dart'; import 'package:image_picker/image_picker.dart'; import 'package:penyaluran_app/app/widgets/indicators/status_pill.dart'; -import 'package:penyaluran_app/app/widgets/section_header.dart'; import 'package:penyaluran_app/app/widgets/cards/info_card.dart'; import 'dart:io'; +import 'package:penyaluran_app/app/widgets/widgets.dart'; class WargaDetailPengaduanView extends GetView { const WargaDetailPengaduanView({super.key}); @@ -670,8 +670,7 @@ class WargaDetailPengaduanView extends GetView { const SizedBox(width: 12), Text( pengaduan.tanggalPengaduan != null - ? DateFormat('dd MMMM yyyy', 'id_ID') - .format(pengaduan.tanggalPengaduan!) + ? FormatHelper.formatDateTime(pengaduan.tanggalPengaduan!) : '-', style: TextStyle( fontSize: 15, @@ -1309,8 +1308,8 @@ class WargaDetailPengaduanView extends GetView { child: Row( children: tindakan.buktiTindakan!.map((bukti) { return GestureDetector( - onTap: () => - showFullScreenImage(context, bukti), + onTap: () => ShowImageDialog.showFullScreen( + context, bukti), child: Container( width: 100, height: 100, @@ -1407,8 +1406,8 @@ class WargaDetailPengaduanView extends GetView { Expanded( child: Text( tindakan.tanggalTindakan != null - ? DateFormat('dd MMM yyyy HH:mm', 'id_ID') - .format(tindakan.tanggalTindakan!) + ? FormatHelper.formatDateTime( + tindakan.tanggalTindakan!) : '-', style: TextStyle( fontSize: 12, @@ -1429,183 +1428,8 @@ class WargaDetailPengaduanView extends GetView { ); } - void showFullScreenImage(BuildContext context, String imageUrl) { - // Buat controller untuk InteractiveViewer - final TransformationController transformationController = - TransformationController(); - - Get.dialog( - Dialog( - insetPadding: EdgeInsets.zero, - child: Stack( - fit: StackFit.expand, - children: [ - Container( - color: Colors.black, - child: InteractiveViewer( - panEnabled: true, - minScale: 0.5, - maxScale: 4, - transformationController: transformationController, - child: Center( - child: Hero( - tag: imageUrl, - child: imageUrl.startsWith('http') - ? Image.network( - imageUrl, - fit: BoxFit.contain, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return Center( - child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation( - Colors.white), - value: loadingProgress.expectedTotalBytes != - null - ? loadingProgress.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null, - ), - ); - }, - errorBuilder: (context, error, stackTrace) { - return Container( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.broken_image, - size: 60, - color: Colors.red, - ), - const SizedBox(height: 16), - Text( - 'Gagal memuat gambar', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ); - }, - ) - : Image.file( - File(imageUrl), - fit: BoxFit.contain, - errorBuilder: (context, error, stackTrace) { - return Container( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.broken_image, - size: 60, - color: Colors.red, - ), - const SizedBox(height: 16), - Text( - 'Gagal memuat gambar', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ); - }, - ), - ), - ), - ), - ), - Positioned( - top: 20, - right: 20, - child: GestureDetector( - onTap: () => Get.back(), - child: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.6), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.close, - color: Colors.white, - size: 24, - ), - ), - ), - ), - Positioned( - bottom: 20, - left: 0, - right: 0, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildImageControlButton( - icon: Icons.zoom_out, - onTap: () { - // Zoom out - final Matrix4 matrix = - transformationController.value.clone(); - matrix.scale(0.75); - transformationController.value = matrix; - }, - ), - const SizedBox(width: 16), - _buildImageControlButton( - icon: Icons.refresh, - onTap: () { - // Reset - transformationController.value = Matrix4.identity(); - }, - ), - const SizedBox(width: 16), - _buildImageControlButton( - icon: Icons.zoom_in, - onTap: () { - // Zoom in - final Matrix4 matrix = - transformationController.value.clone(); - matrix.scale(1.5); - transformationController.value = matrix; - }, - ), - ], - ), - ), - ], - ), - ), - ); - } - - Widget _buildImageControlButton({ - required IconData icon, - required Function() onTap, - }) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.6), - shape: BoxShape.circle, - ), - child: Icon( - icon, - color: Colors.white, - size: 24, - ), - ), - ); + void _showFullScreenImage(BuildContext context, String imagePath) { + ShowImageDialog.showFullScreen(context, imagePath); } } @@ -2056,8 +1880,7 @@ class _TambahTindakanPengaduanViewState } void _showFullScreenImage(BuildContext context, String imagePath) { - final wargaDetailView = Get.find(); - wargaDetailView.showFullScreenImage(context, imagePath); + ShowImageDialog.showFullScreen(context, imagePath); } Future _simpanTindakan() async { @@ -2078,22 +1901,6 @@ class _TambahTindakanPengaduanViewState }); try { - // Di sini kita baru melakukan upload file ke server - // Contoh implementasi: - - // 1. Upload semua file bukti tindakan - // final List buktiTindakanUrls = await uploadMultipleFiles(buktiTindakanPaths); - - // 2. Simpan data tindakan ke database - // await saveTindakanPengaduan( - // pengaduanId: widget.pengaduanId, - // kategoriTindakan: selectedKategori!, - // prioritas: selectedPrioritas!, - // tindakan: tindakanController.text, - // catatan: catatanController.text, - // buktiTindakanUrls: buktiTindakanUrls, - // ); - // Tampilkan pesan sukses Get.back(); // Kembali ke halaman sebelumnya Get.snackbar( diff --git a/lib/app/modules/warga/views/form_pengaduan_view.dart b/lib/app/modules/warga/views/form_pengaduan_view.dart index d14a9dd..8b28dbe 100644 --- a/lib/app/modules/warga/views/form_pengaduan_view.dart +++ b/lib/app/modules/warga/views/form_pengaduan_view.dart @@ -11,12 +11,12 @@ class FormPengaduanView extends StatefulWidget { final List? selectedImages; const FormPengaduanView({ - Key? key, + super.key, required this.uidPenerimaan, this.judul, this.deskripsi, this.selectedImages, - }) : super(key: key); + }); @override State createState() => _FormPengaduanViewState(); @@ -219,7 +219,7 @@ class _FormPengaduanViewState extends State { ), ), const SizedBox(height: 8), - Container( + SizedBox( height: 120, child: ListView.builder( scrollDirection: Axis.horizontal, diff --git a/lib/app/modules/warga/views/warga_dashboard_view.dart b/lib/app/modules/warga/views/warga_dashboard_view.dart index 72b25d0..31a86ba 100644 --- a/lib/app/modules/warga/views/warga_dashboard_view.dart +++ b/lib/app/modules/warga/views/warga_dashboard_view.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:intl/intl.dart'; import 'package:penyaluran_app/app/modules/warga/controllers/warga_dashboard_controller.dart'; +import 'package:penyaluran_app/app/utils/format_helper.dart'; import 'package:penyaluran_app/app/widgets/section_header.dart'; class WargaDashboardView extends GetView { @@ -23,6 +23,54 @@ class WargaDashboardView extends GetView { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Header DisalurKita dengan logo dan slogan + Container( + padding: const EdgeInsets.all(16), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15), + boxShadow: [ + BoxShadow( + color: Colors.blue.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + Image.asset( + 'assets/images/logo-disalurkita.png', + width: 50, + height: 50, + ), + const SizedBox(width: 15), + const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'DisalurKita', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Color(0xFF1565C0), + ), + ), + SizedBox(height: 5), + Text( + 'Salurkan dengan Pasti, Pantau dengan Bukti', + style: TextStyle( + fontSize: 12, + color: Colors.grey, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ], + ), + ), _buildWelcomeSection(), const SizedBox(height: 24), _buildStatisticSection(), @@ -90,10 +138,17 @@ class WargaDashboardView extends GetView { ? NetworkImage(controller.profilePhotoUrl!) : null, child: controller.profilePhotoUrl == null - ? Icon( - Icons.person, - color: Colors.blue.shade700, - size: 30, + ? Text( + controller.nama.isNotEmpty + ? controller.nama + .substring(0, 1) + .toUpperCase() + : '?', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.blue.shade700, + fontSize: 24, + ), ) : null, ), @@ -417,12 +472,6 @@ class WargaDashboardView extends GetView { } Widget _buildPenerimaanSummary() { - final currencyFormat = NumberFormat.currency( - locale: 'id', - symbol: 'Rp ', - decimalDigits: 0, - ); - double totalUang = 0; Map totalNonUang = {}; @@ -494,7 +543,7 @@ class WargaDashboardView extends GetView { icon: Icons.attach_money, color: Colors.green, title: 'Total Bantuan Uang', - value: currencyFormat.format(totalUang), + value: FormatHelper.formatRupiah(totalUang), ), if (totalNonUang.isNotEmpty) ...[ if (totalUang > 0) diff --git a/lib/app/modules/warga/views/warga_detail_penerimaan_view.dart b/lib/app/modules/warga/views/warga_detail_penerimaan_view.dart index def6d62..ac1a853 100644 --- a/lib/app/modules/warga/views/warga_detail_penerimaan_view.dart +++ b/lib/app/modules/warga/views/warga_detail_penerimaan_view.dart @@ -1,9 +1,9 @@ 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/pengaduan_model.dart'; import 'package:penyaluran_app/app/modules/warga/controllers/warga_dashboard_controller.dart'; +import 'package:penyaluran_app/app/utils/format_helper.dart'; import 'package:penyaluran_app/app/widgets/status_badge.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:image_picker/image_picker.dart'; @@ -131,17 +131,11 @@ class WargaDetailPenerimaanView extends GetView { } Widget _buildHeaderSection(PenerimaPenyaluranModel penyaluran) { - final currencyFormat = NumberFormat.currency( - locale: 'id', - symbol: 'Rp ', - decimalDigits: 0, - ); - // Format jumlah bantuan berdasarkan tipe (uang atau bukan) String formattedJumlah = ''; if (penyaluran.jumlahBantuan != null) { if (penyaluran.isUang == true) { - formattedJumlah = currencyFormat.format(penyaluran.jumlahBantuan); + formattedJumlah = FormatHelper.formatRupiah(penyaluran.jumlahBantuan); } else { formattedJumlah = '${penyaluran.jumlahBantuan} ${penyaluran.satuan ?? ''}'; @@ -390,8 +384,7 @@ class WargaDetailPenerimaanView extends GetView { icon: Icons.calendar_today, title: 'Tanggal Penerimaan', value: penyaluran.tanggalPenerimaan != null - ? DateFormat('dd MMMM yyyy', 'id_ID') - .format(penyaluran.tanggalPenerimaan!) + ? FormatHelper.formatDateTime(penyaluran.tanggalPenerimaan!) : 'Belum diterima', statusColor: null, ), @@ -400,8 +393,7 @@ class WargaDetailPenerimaanView extends GetView { icon: Icons.access_time, title: 'Waktu Penerimaan', value: penyaluran.tanggalPenerimaan != null - ? DateFormat('HH:mm', 'id_ID') - .format(penyaluran.tanggalPenerimaan!) + ? FormatHelper.formatDateTime(penyaluran.tanggalPenerimaan!) : 'Belum diterima', statusColor: null, ), @@ -758,8 +750,7 @@ class WargaDetailPenerimaanView extends GetView { icon: Icons.update, title: 'Terakhir Diperbarui', value: penyaluran.tanggalPenerimaan != null - ? DateFormat('dd MMMM yyyy HH:mm', 'id_ID') - .format(penyaluran.tanggalPenerimaan!) + ? FormatHelper.formatDateTime(penyaluran.tanggalPenerimaan!) : 'Tidak tersedia', statusColor: null, ), @@ -1394,7 +1385,7 @@ class WargaDetailPenerimaanView extends GetView { ), const SizedBox(width: 8), const Text( - 'Pengaduan Terdaftar', + 'Pengaduan', style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, @@ -1547,8 +1538,7 @@ class WargaDetailPenerimaanView extends GetView { const SizedBox(width: 8), Text( pengaduan.tanggalPengaduan != null - ? DateFormat('dd MMMM yyyy HH:mm', 'id_ID') - .format(pengaduan.tanggalPengaduan!) + ? FormatHelper.formatDateTime(pengaduan.tanggalPengaduan!) : 'Tanggal tidak tersedia', style: TextStyle( fontSize: 12, diff --git a/lib/app/modules/warga/views/warga_pengaduan_view.dart b/lib/app/modules/warga/views/warga_pengaduan_view.dart index 018e3a6..882515e 100644 --- a/lib/app/modules/warga/views/warga_pengaduan_view.dart +++ b/lib/app/modules/warga/views/warga_pengaduan_view.dart @@ -1,12 +1,7 @@ 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/warga/controllers/warga_dashboard_controller.dart'; -import 'package:penyaluran_app/app/modules/warga/views/form_pengaduan_view.dart'; import 'package:penyaluran_app/app/utils/format_helper.dart'; -import 'dart:io'; -import 'package:image_picker/image_picker.dart'; class WargaPengaduanView extends GetView { const WargaPengaduanView({super.key}); @@ -380,7 +375,7 @@ class WargaPengaduanView extends GetView { Expanded( child: Text( item.tanggalPengaduan != null - ? DateTimeHelper.formatDateTime( + ? FormatHelper.formatDateTime( item.tanggalPengaduan!) : '-', style: TextStyle( diff --git a/lib/app/modules/warga/views/warga_view.dart b/lib/app/modules/warga/views/warga_view.dart index 0221b98..df4cf89 100644 --- a/lib/app/modules/warga/views/warga_view.dart +++ b/lib/app/modules/warga/views/warga_view.dart @@ -20,13 +20,13 @@ class WargaView extends GetView { title: Obx(() { switch (controller.activeTabIndex.value) { case 0: - return const Text('Dashboard Warga'); + return const Text('Dashboard'); case 1: return const Text('Penerimaan Bantuan'); case 2: return const Text('Pengaduan'); default: - return const Text('Dashboard Warga'); + return const Text('Dashboard'); } }), leading: IconButton( @@ -164,16 +164,19 @@ class WargaView extends GetView { child: CircleAvatar( radius: 40, backgroundColor: Colors.white70, - backgroundImage: controller.profilePhotoUrl != null && - controller.profilePhotoUrl!.isNotEmpty - ? NetworkImage(controller.profilePhotoUrl!) + backgroundImage: controller.fotoProfil.value.isNotEmpty + ? NetworkImage(controller.fotoProfil.value) : null, - child: controller.profilePhotoUrl == null || - controller.profilePhotoUrl!.isEmpty - ? Icon( - Icons.person, - color: Colors.white, - size: 40, + child: controller.fotoProfil.isEmpty + ? Text( + controller.nama.isNotEmpty + ? controller.nama.substring(0, 1).toUpperCase() + : '?', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.white, + fontSize: 24, + ), ) : null, ), @@ -292,6 +295,15 @@ class WargaView extends GetView { controller.refreshData(); }, ), + _buildMenuItem( + icon: Icons.info_outline, + activeIcon: Icons.info, + title: 'Tentang Kami', + onTap: () { + Navigator.pop(context); + Get.toNamed('/about'); + }, + ), _buildMenuItem( icon: Icons.logout, title: 'Keluar', @@ -307,7 +319,7 @@ class WargaView extends GetView { Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Text( - '© ${DateTime.now().year} Aplikasi Penyaluran Bantuan', + '© ${DateTime.now().year} DisalurKita', style: TextStyle( fontSize: 12, color: Colors.grey, diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index b4f2475..63e9de9 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -1,4 +1,5 @@ import 'package:get/get.dart'; +import 'package:penyaluran_app/app/modules/auth/views/forgot_password_view.dart'; import 'package:penyaluran_app/app/modules/auth/views/login_view.dart'; import 'package:penyaluran_app/app/modules/auth/views/register_donatur_view.dart'; import 'package:penyaluran_app/app/modules/auth/bindings/auth_binding.dart'; @@ -11,6 +12,7 @@ import 'package:penyaluran_app/app/modules/petugas_desa/views/riwayat_penitipan_ import 'package:penyaluran_app/app/modules/petugas_desa/views/daftar_donatur_view.dart'; import 'package:penyaluran_app/app/modules/petugas_desa/views/detail_donatur_view.dart'; import 'package:penyaluran_app/app/modules/petugas_desa/views/tambah_penyaluran_view.dart'; +import 'package:penyaluran_app/app/modules/petugas_desa/views/tambah_lokasi_penyaluran_view.dart'; import 'package:penyaluran_app/app/modules/petugas_desa/views/riwayat_penyaluran_view.dart'; import 'package:penyaluran_app/app/modules/petugas_desa/views/detail_penyaluran_page.dart'; import 'package:penyaluran_app/app/modules/petugas_desa/bindings/penyaluran_binding.dart'; @@ -18,7 +20,8 @@ import 'package:penyaluran_app/app/modules/petugas_desa/views/riwayat_pengaduan_ import 'package:penyaluran_app/app/modules/petugas_desa/bindings/riwayat_pengaduan_binding.dart'; import 'package:penyaluran_app/app/modules/petugas_desa/views/qr_scanner_page.dart'; import 'package:penyaluran_app/app/modules/petugas_desa/views/konfirmasi_penerima_page.dart'; - +import 'package:penyaluran_app/app/modules/about/views/about_view.dart'; +import 'package:penyaluran_app/app/modules/about/bindings/about_binding.dart'; import 'package:penyaluran_app/app/modules/petugas_desa/bindings/penerima_binding.dart'; import 'package:penyaluran_app/app/modules/petugas_desa/bindings/donatur_binding.dart'; import 'package:penyaluran_app/app/modules/profile/bindings/profile_binding.dart'; @@ -64,6 +67,11 @@ class AppPages { page: () => const RegisterDonaturView(), binding: AuthBinding(), ), + GetPage( + name: _Paths.forgotPassword, + page: () => const ForgotPasswordView(), + binding: AuthBinding(), + ), GetPage( name: Routes.wargaDashboard, page: () => WargaView(), @@ -92,6 +100,11 @@ class AppPages { page: () => const PetugasDesaView(), binding: PetugasDesaBinding(), ), + GetPage( + name: _Paths.about, + page: () => const AboutView(), + binding: AboutBinding(), + ), GetPage( name: _Paths.permintaanPenjadwalan, page: () => const PermintaanPenjadwalanView(), @@ -137,6 +150,11 @@ class AppPages { page: () => const TambahPenyaluranView(), binding: PetugasDesaBinding(), ), + GetPage( + name: _Paths.tambahLokasiPenyaluran, + page: () => const TambahLokasiPenyaluranView(), + binding: PetugasDesaBinding(), + ), GetPage( name: _Paths.detailPenyaluran, page: () => DetailPenyaluranPage(), diff --git a/lib/app/routes/app_routes.dart b/lib/app/routes/app_routes.dart index c9a6e67..a59a202 100644 --- a/lib/app/routes/app_routes.dart +++ b/lib/app/routes/app_routes.dart @@ -6,6 +6,7 @@ abstract class Routes { static const login = _Paths.login; static const register = _Paths.register; static const registerDonatur = _Paths.registerDonatur; + static const forgotPassword = _Paths.forgotPassword; static const wargaDashboard = _Paths.wargaDashboard; static const wargaPenerimaan = _Paths.wargaPenerimaan; static const wargaPengaduan = _Paths.wargaPengaduan; @@ -23,10 +24,12 @@ abstract class Routes { static const konfirmasiPenerima = _Paths.konfirmasiPenerima; static const pelaksanaanPenyaluran = _Paths.pelaksanaanPenyaluran; static const profile = _Paths.profile; + static const about = _Paths.about; static const riwayatPenitipan = _Paths.riwayatPenitipan; static const daftarDonatur = _Paths.daftarDonatur; static const detailDonatur = _Paths.detailDonatur; static const tambahPenyaluran = _Paths.tambahPenyaluran; + static const tambahLokasiPenyaluran = _Paths.tambahLokasiPenyaluran; static const daftarPenerimaPenyaluran = _Paths.daftarPenerimaPenyaluran; static const detailPenerimaPenyaluran = _Paths.detailPenerimaPenyaluran; static const laporanPenyaluran = _Paths.laporanPenyaluran; @@ -51,6 +54,7 @@ abstract class _Paths { static const login = '/login'; static const register = '/register'; static const registerDonatur = '/register-donatur'; + static const forgotPassword = '/forgot-password'; static const wargaDashboard = '/warga-dashboard'; static const wargaPenerimaan = '/warga-penerimaan'; static const wargaPengaduan = '/warga-pengaduan'; @@ -68,10 +72,12 @@ abstract class _Paths { static const konfirmasiPenerima = '/daftar-penerima/konfirmasi'; static const pelaksanaanPenyaluran = '/pelaksanaan-penyaluran'; static const profile = '/profile'; + static const about = '/about'; static const riwayatPenitipan = '/petugas-desa/riwayat-penitipan'; static const daftarDonatur = '/daftar-donatur'; static const detailDonatur = '/daftar-donatur/detail'; static const tambahPenyaluran = '/tambah-penyaluran'; + static const tambahLokasiPenyaluran = '/tambah-lokasi-penyaluran'; static const daftarPenerimaPenyaluran = '/daftar-penerima-penyaluran'; static const detailPenerimaPenyaluran = '/detail-penerima-penyaluran'; static const laporanPenyaluran = '/laporan-penyaluran'; diff --git a/lib/app/services/jadwal_update_service.dart b/lib/app/services/jadwal_update_service.dart new file mode 100644 index 0000000..dad80ae --- /dev/null +++ b/lib/app/services/jadwal_update_service.dart @@ -0,0 +1,216 @@ +import 'package:get/get.dart'; +import 'package:flutter/material.dart'; +import 'dart:async'; +import 'package:penyaluran_app/app/data/models/penyaluran_bantuan_model.dart'; +import 'package:penyaluran_app/app/services/supabase_service.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:penyaluran_app/app/modules/petugas_desa/controllers/counter_service.dart'; + +/// Service untuk menangani pembaruan jadwal real-time dan sinkronisasi antar halaman +class JadwalUpdateService extends GetxService { + static JadwalUpdateService get to => Get.find(); + + final SupabaseService _supabaseService = SupabaseService.to; + + // Stream controller untuk mengirim notifikasi pembaruan jadwal ke seluruh aplikasi + final _jadwalUpdateStream = + StreamController>.broadcast(); + Stream> get jadwalUpdateStream => + _jadwalUpdateStream.stream; + + // Digunakan untuk menyimpan status terakhir pembaruan jadwal + final RxMap lastUpdateTimestamp = {}.obs; + + // Map untuk melacak jadwal yang sedang dalam pengawasan intensif + final RxMap _watchedJadwal = {}.obs; + + // Timer untuk memeriksa jadwal yang sedang dalam pengawasan + Timer? _watchTimer; + + // Mencatat controller yang berlangganan untuk pembaruan + final List _subscribedControllers = []; + + // Channel untuk realtime subscription + RealtimeChannel? _channel; + + @override + void onInit() { + super.onInit(); + _setupRealtimeSubscription(); + _startWatchTimer(); + } + + @override + void onClose() { + _jadwalUpdateStream.close(); + _channel?.unsubscribe(); + _watchTimer?.cancel(); + super.onClose(); + } + + // Memulai timer untuk jadwal pengawasan + void _startWatchTimer() { + _watchTimer = Timer.periodic(const Duration(seconds: 3), (_) { + _checkWatchedJadwal(); + }); + } + + // Memeriksa jadwal yang sedang diawasi + void _checkWatchedJadwal() { + final now = DateTime.now(); + final List jadwalToUpdate = []; + final List expiredWatches = []; + + _watchedJadwal.forEach((jadwalId, targetTime) { + // Jika sudah mencapai atau melewati waktu target + if (now.isAtSameMomentAs(targetTime) || now.isAfter(targetTime)) { + jadwalToUpdate.add(jadwalId); + // Hentikan pengawasan karena sudah waktunya + expiredWatches.add(jadwalId); + } + + // Jika sudah lebih dari 5 menit dari waktu target, hentikan pengawasan + if (now.difference(targetTime).inMinutes > 5) { + expiredWatches.add(jadwalId); + } + }); + + // Hapus jadwal yang sudah tidak perlu diawasi + for (var jadwalId in expiredWatches) { + _watchedJadwal.remove(jadwalId); + } + + // Jika ada jadwal yang perlu diperbarui, kirim sinyal untuk memperbarui + if (jadwalToUpdate.isNotEmpty) { + print('Watched jadwal time reached: ${jadwalToUpdate.join(", ")}'); + notifyJadwalNeedsCheck(); + } + } + + // Setup langganan ke pembaruan real-time dari Supabase + void _setupRealtimeSubscription() { + try { + // Langganan pembaruan tabel penyaluran_bantuan + _channel = _supabaseService.client + .channel('penyaluran_bantuan_updates') + .onPostgresChanges( + event: PostgresChangeEvent.update, + schema: 'public', + table: 'penyaluran_bantuan', + callback: (payload) { + if (payload.newRecord != null) { + // Dapatkan data jadwal yang diperbarui + final jadwalId = payload.newRecord['id']; + final newStatus = payload.newRecord['status']; + + print( + 'Received realtime update for jadwal ID: $jadwalId with status: $newStatus'); + + // Kirim notifikasi ke seluruh aplikasi + _broadcastUpdate({ + 'type': 'status_update', + 'jadwal_id': jadwalId, + 'new_status': newStatus, + 'timestamp': DateTime.now().toIso8601String(), + }); + + // Update timestamp + lastUpdateTimestamp[jadwalId] = DateTime.now(); + } + }, + ); + + // Mulai berlangganan + _channel?.subscribe(); + + print( + 'Realtime subscription for penyaluran_bantuan_updates started successfully'); + } catch (e) { + print('Error setting up realtime subscription: $e'); + } + } + + // Mengirim pembaruan ke semua controller yang berlangganan + void _broadcastUpdate(Map updateData) { + _jadwalUpdateStream.add(updateData); + } + + // Controller dapat mendaftar untuk menerima pembaruan jadwal + void registerForUpdates(String controllerId) { + if (!_subscribedControllers.contains(controllerId)) { + _subscribedControllers.add(controllerId); + } + } + + // Controller berhenti menerima pembaruan + void unregisterFromUpdates(String controllerId) { + _subscribedControllers.remove(controllerId); + } + + // Menambahkan jadwal ke pengawasan intensif + void addJadwalToWatch(String jadwalId, DateTime targetTime) { + print('Adding jadwal $jadwalId to intensive watch for time $targetTime'); + _watchedJadwal[jadwalId] = targetTime; + } + + // Memicu pemeriksaan jadwal segera + void notifyJadwalNeedsCheck() { + try { + // Kirim notifikasi untuk memeriksa jadwal + _broadcastUpdate({ + 'type': 'check_required', + 'timestamp': DateTime.now().toIso8601String(), + }); + } catch (e) { + print('Error notifying jadwal check: $e'); + } + } + + // Muat ulang data jadwal di semua controller yang terdaftar + Future notifyJadwalUpdate() async { + try { + // Kirim notifikasi untuk memuat ulang data + _broadcastUpdate({ + 'type': 'reload_required', + 'timestamp': DateTime.now().toIso8601String(), + }); + + // Perbarui counter juga saat jadwal diperbarui + refreshCounters(); + + // Tampilkan notifikasi jika user sedang melihat aplikasi + if (Get.isDialogOpen != true && Get.context != null) { + Get.snackbar( + 'Jadwal Diperbarui', + 'Data jadwal telah diperbarui', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.blue.withOpacity(0.8), + colorText: Colors.white, + duration: const Duration(seconds: 2), + ); + } + } catch (e) { + print('Error notifying jadwal update: $e'); + } + } + + // Metode untuk menyegarkan semua counter terkait penyaluran + Future refreshCounters() async { + try { + // Perbarui counter jika CounterService telah terinisialisasi + if (Get.isRegistered()) { + final counterService = Get.find(); + + // Ambil data jumlah jadwal aktif + final jadwalAktifData = await _supabaseService.getJadwalAktif(); + if (jadwalAktifData != null) { + counterService.updateJadwalCounter(jadwalAktifData.length); + } + + print('Counters refreshed via JadwalUpdateService'); + } + } catch (e) { + print('Error refreshing counters: $e'); + } + } +} diff --git a/lib/app/services/notification_service.dart b/lib/app/services/notification_service.dart new file mode 100644 index 0000000..c5b6e57 --- /dev/null +++ b/lib/app/services/notification_service.dart @@ -0,0 +1,204 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:penyaluran_app/app/services/supabase_service.dart'; + +class NotificationService extends GetxService { + static NotificationService get to => Get.find(); + + final SupabaseService _supabaseService = SupabaseService.to; + + // Daftar notifikasi yang belum dibaca + final RxList> unreadNotifications = + >[].obs; + + // Mengontrol status loading + final RxBool isLoading = false.obs; + + // Jumlah notifikasi yang belum dibaca + final RxInt unreadCount = 0.obs; + + @override + void onInit() { + super.onInit(); + fetchNotifications(); + } + + // Mengambil notifikasi dari database + Future fetchNotifications() async { + try { + isLoading.value = true; + + // Ambil notifikasi dari tabel notifikasi di database + final userId = _supabaseService.currentUser?.id; + if (userId != null) { + final response = await _supabaseService.client + .from('notifikasi_jadwal') + .select('*') + .eq('user_id', userId) + .eq('is_read', false) + .order('created_at', ascending: false) + .limit(20); + + if (response != null) { + unreadNotifications.value = response; + unreadCount.value = unreadNotifications.length; + } + } + } catch (e) { + print('Error fetching notifications: $e'); + } finally { + isLoading.value = false; + } + } + + // Mengirim notifikasi untuk perubahan status jadwal + Future sendJadwalStatusNotification({ + required String jadwalId, + required String newStatus, + required String jadwalNama, + List? targetUserIds, + }) async { + try { + final currentUserId = _supabaseService.currentUser?.id; + if (currentUserId == null) return; + + // Buat pesan berdasarkan status + String message; + String title; + + switch (newStatus) { + case 'AKTIF': + title = 'Jadwal Aktif'; + message = + 'Jadwal "$jadwalNama" sekarang aktif dan siap dilaksanakan.'; + break; + case 'TERLAKSANA': + title = 'Jadwal Selesai'; + message = 'Jadwal "$jadwalNama" telah berhasil dilaksanakan.'; + break; + case 'BATALTERLAKSANA': + title = 'Jadwal Terlewat'; + message = + 'Jadwal "$jadwalNama" telah terlewat dan dibatalkan secara otomatis.'; + break; + default: + title = 'Perubahan Status Jadwal'; + message = 'Status jadwal "$jadwalNama" berubah menjadi $newStatus.'; + } + + // Jika tidak ada targetUserIds, notifikasi hanya untuk diri sendiri + final users = targetUserIds ?? [currentUserId]; + + // Simpan notifikasi ke database untuk setiap user + for (final userId in users) { + await _supabaseService.client.from('notifikasi_jadwal').insert({ + 'user_id': userId, + 'title': title, + 'message': message, + 'jadwal_id': jadwalId, + 'status': newStatus, + 'is_read': false, + 'created_at': DateTime.now().toUtc().toIso8601String(), + 'created_by': currentUserId, + }); + } + + // Jika perubahan status dari pengguna saat ini, tampilkan notifikasi + if (users.contains(currentUserId)) { + showStatusChangeNotification(title, message, newStatus); + } + + // Perbarui daftar notifikasi + await fetchNotifications(); + } catch (e) { + print('Error sending notification: $e'); + } + } + + // Menampilkan notifikasi status di UI + void showStatusChangeNotification( + String title, String message, String status) { + Color backgroundColor; + + // Pilih warna berdasarkan status + switch (status) { + case 'AKTIF': + backgroundColor = Colors.green; + break; + case 'TERLAKSANA': + backgroundColor = Colors.blue; + break; + case 'BATALTERLAKSANA': + backgroundColor = Colors.orange; + break; + default: + backgroundColor = Colors.grey; + } + + // Tampilkan notifikasi + Get.snackbar( + title, + message, + snackPosition: SnackPosition.TOP, + backgroundColor: backgroundColor.withOpacity(0.8), + colorText: Colors.white, + duration: const Duration(seconds: 4), + margin: const EdgeInsets.all(8), + borderRadius: 8, + icon: Icon( + _getIconForStatus(status), + color: Colors.white, + ), + ); + } + + // Menandai notifikasi sebagai telah dibaca + Future markAsRead(String notificationId) async { + try { + await _supabaseService.client + .from('notifikasi_jadwal') + .update({'is_read': true}).eq('id', notificationId); + + // Hapus dari daftar yang belum dibaca + unreadNotifications + .removeWhere((notification) => notification['id'] == notificationId); + unreadCount.value = unreadNotifications.length; + } catch (e) { + print('Error marking notification as read: $e'); + } + } + + // Menandai semua notifikasi sebagai telah dibaca + Future markAllAsRead() async { + try { + final userId = _supabaseService.currentUser?.id; + if (userId == null) return; + + await _supabaseService.client + .from('notifikasi_jadwal') + .update({'is_read': true}) + .eq('user_id', userId) + .eq('is_read', false); + + // Kosongkan daftar yang belum dibaca + unreadNotifications.clear(); + unreadCount.value = 0; + } catch (e) { + print('Error marking all notifications as read: $e'); + } + } + + // Mendapatkan ikon berdasarkan status + IconData _getIconForStatus(String status) { + switch (status) { + case 'AKTIF': + return Icons.event_available; + case 'TERLAKSANA': + return Icons.check_circle; + case 'BATALTERLAKSANA': + return Icons.event_busy; + default: + return Icons.notifications; + } + } +} diff --git a/lib/app/services/supabase_service.dart b/lib/app/services/supabase_service.dart index 655819c..d2a8a97 100644 --- a/lib/app/services/supabase_service.dart +++ b/lib/app/services/supabase_service.dart @@ -562,19 +562,15 @@ class SupabaseService extends GetxService { try { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); - final tomorrow = today.add(const Duration(days: 1)); final week = today.add(const Duration(days: 7)); - // Konversi ke UTC untuk query ke database - final tomorrowUtc = tomorrow.toUtc().toIso8601String(); - final weekUtc = week.toUtc().toIso8601String(); - final response = await client .from('penyaluran_bantuan') .select('*') - .gte('tanggal_penyaluran', tomorrowUtc) - .lt('tanggal_penyaluran', weekUtc) - .inFilter('status', ['DIJADWALKAN']); + .gte('tanggal_penyaluran', today) + .lt('tanggal_penyaluran', week) + .inFilter('status', ['DIJADWALKAN']).order('tanggal_penyaluran', + ascending: true); return response; } catch (e) { @@ -651,15 +647,128 @@ class SupabaseService extends GetxService { } // Metode untuk memperbarui status jadwal - Future updateJadwalStatus(String jadwalId, String status) async { + Future updateJadwalStatus(String jadwalId, String newStatus) async { try { await client.from('penyaluran_bantuan').update({ - 'status': status, - 'updated_at': DateTime.now().toUtc().toIso8601String(), + 'status': newStatus, + 'updated_at': DateTime.now().toUtc().toIso8601String() }).eq('id', jadwalId); + + print('Jadwal status updated: $jadwalId -> $newStatus'); } catch (e) { print('Error updating jadwal status: $e'); - throw e.toString(); + rethrow; + } + } + + // Update status jadwal penyaluran secara batch untuk efisiensi + Future batchUpdateJadwalStatus( + Map jadwalUpdates) async { + if (jadwalUpdates.isEmpty) return; + + try { + print('Attempting batch update for ${jadwalUpdates.length} jadwal'); + final timestamp = DateTime.now().toUtc().toIso8601String(); + + // Format data sesuai dengan yang diharapkan oleh SQL function + final List> formattedUpdates = jadwalUpdates.entries + .map((e) => {'id': e.key, 'status': e.value}) + .toList(); + + print('Formatted updates: $formattedUpdates'); + + try { + // Coba gunakan RPC dulu - kirim sebagai array dari objek JSON + final result = await client.rpc('batch_update_jadwal_status', params: { + 'jadwal_updates': formattedUpdates, + 'updated_timestamp': timestamp, + }); + + print('Batch update via RPC response: $result'); + + // Periksa hasil untuk mengkonfirmasi berapa banyak yang berhasil diupdate + if (result != null) { + final bool success = result['success'] == true; + final int updatedCount = result['updated_count'] ?? 0; + + if (success) { + print('Successfully updated $updatedCount records via RPC'); + + // Log ID yang berhasil diupdate + final List successIds = + List.from(result['success_ids'] ?? []); + if (successIds.isNotEmpty) { + print( + 'Successfully updated jadwal IDs: ${successIds.join(", ")}'); + } + + // Jika ada yang gagal, log untuk debugging + if (updatedCount < jadwalUpdates.length) { + print( + 'Warning: ${jadwalUpdates.length - updatedCount} records failed to update'); + + // Periksa apakah ada informasi error + if (result['errors'] != null) { + final int errorCount = result['errors']['count'] ?? 0; + if (errorCount > 0) { + final List errorIds = + List.from(result['errors']['ids'] ?? []); + final List errorMessages = + List.from(result['errors']['messages'] ?? []); + + for (int i = 0; i < errorCount; i++) { + if (i < errorIds.length && i < errorMessages.length) { + print( + 'Error updating jadwal ${errorIds[i]}: ${errorMessages[i]}'); + } + } + } + } + + // Update individual yang gagal menggunakan metode satu per satu + for (var entry in jadwalUpdates.entries) { + if (!successIds.contains(entry.key)) { + try { + await updateJadwalStatus(entry.key, entry.value); + print('Fallback update successful for jadwal ${entry.key}'); + } catch (e) { + print( + 'Fallback update also failed for jadwal ${entry.key}: $e'); + } + } + } + } + } else { + print( + 'Batch update reported failure. Falling back to individual updates.'); + _fallbackToIndividualUpdates(jadwalUpdates); + } + } else { + print( + 'Batch update returned null result. Falling back to individual updates.'); + _fallbackToIndividualUpdates(jadwalUpdates); + } + } catch (rpcError) { + print('RPC batch update failed: $rpcError'); + print('Falling back to individual updates'); + _fallbackToIndividualUpdates(jadwalUpdates); + } + } catch (e) { + print('Error in batch update process: $e'); + rethrow; + } + } + + // Helper function untuk fallback ke individual updates + Future _fallbackToIndividualUpdates( + Map jadwalUpdates) async { + for (var entry in jadwalUpdates.entries) { + try { + await updateJadwalStatus(entry.key, entry.value); + print('Individual update successful: ${entry.key} -> ${entry.value}'); + } catch (updateError) { + print('Failed to update jadwal ${entry.key}: $updateError'); + } } } @@ -874,7 +983,7 @@ class SupabaseService extends GetxService { .select('stok_bantuan_id, jumlah') .eq('id', penitipanId); - if (response == null || response.isEmpty) { + if (response.isEmpty) { throw 'Data penitipan tidak ditemukan'; } @@ -1930,8 +2039,8 @@ class SupabaseService extends GetxService { } if (jenisPerubahan != null) { - filterString += (filterString.isNotEmpty ? ',' : '') + - 'jenis_perubahan.eq.$jenisPerubahan'; + filterString += + '${filterString.isNotEmpty ? ',' : ''}jenis_perubahan.eq.$jenisPerubahan'; } final response = await client.from('riwayat_stok').select(''' @@ -2006,7 +2115,7 @@ class SupabaseService extends GetxService { print('Stok berhasil ditambahkan dari penitipan'); } catch (e) { print('Error adding stok from penitipan: $e'); - throw e; // Re-throw untuk penanganan di tingkat yang lebih tinggi + rethrow; // Re-throw untuk penanganan di tingkat yang lebih tinggi } } @@ -2058,7 +2167,7 @@ class SupabaseService extends GetxService { print('Stok berhasil dikurangi dari penyaluran'); } catch (e) { print('Error reducing stok from penyaluran: $e'); - throw e; // Re-throw untuk penanganan di tingkat yang lebih tinggi + rethrow; // Re-throw untuk penanganan di tingkat yang lebih tinggi } } @@ -2075,7 +2184,7 @@ class SupabaseService extends GetxService { String fotoBuktiUrl = ''; if (fotoBuktiPath.isNotEmpty) { final String fileName = - '${DateTime.now().millisecondsSinceEpoch}_${stokBantuanId}.jpg'; + '${DateTime.now().millisecondsSinceEpoch}_$stokBantuanId.jpg'; final fileResponse = await client.storage.from('stok_bukti').upload( fileName, File(fotoBuktiPath), @@ -2125,7 +2234,7 @@ class SupabaseService extends GetxService { print('Stok berhasil ditambahkan secara manual'); } catch (e) { print('Error adding stok manually: $e'); - throw e; // Re-throw untuk penanganan di tingkat yang lebih tinggi + rethrow; // Re-throw untuk penanganan di tingkat yang lebih tinggi } } @@ -2164,7 +2273,7 @@ class SupabaseService extends GetxService { String fotoBuktiUrl = ''; if (fotoBuktiPath.isNotEmpty) { final String fileName = - '${DateTime.now().millisecondsSinceEpoch}_${stokBantuanId}.jpg'; + '${DateTime.now().millisecondsSinceEpoch}_$stokBantuanId.jpg'; final fileResponse = await client.storage.from('stok_bukti').upload( fileName, File(fotoBuktiPath), @@ -2198,7 +2307,7 @@ class SupabaseService extends GetxService { print('Stok berhasil dikurangi secara manual'); } catch (e) { print('Error reducing stok manually: $e'); - throw e; // Re-throw untuk penanganan di tingkat yang lebih tinggi + rethrow; // Re-throw untuk penanganan di tingkat yang lebih tinggi } } diff --git a/lib/app/utils/date_helper.dart b/lib/app/utils/date_helper.dart deleted file mode 100644 index 8edb839..0000000 --- a/lib/app/utils/date_helper.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:intl/intl.dart'; - -/// Kelas pembantu untuk manipulasi tanggal dan waktu -class DateHelper { - /// Format tanggal ke format Indonesia (dd MMM yyyy) - static String formatDate( - DateTime? dateTime, { - String format = 'dd MMM yyyy', - String locale = 'id_ID', - String defaultValue = 'Belum ditentukan', - }) { - if (dateTime == null) return defaultValue; - try { - return DateFormat(format, locale).format(dateTime.toLocal()); - } catch (e) { - return dateTime.toString().split(' ')[0]; - } - } - - /// Format nilai ke dalam format mata uang Rupiah - static String formatRupiah( - num? value, { - String symbol = 'Rp', - int decimalDigits = 0, - String defaultValue = 'Rp 0', - }) { - if (value == null) return defaultValue; - try { - final formatter = NumberFormat.currency( - locale: 'id_ID', - symbol: '$symbol ', - decimalDigits: decimalDigits, - ); - return formatter.format(value); - } catch (e) { - // Format manual - return '$symbol ${value.toStringAsFixed(decimalDigits).replaceAllMapped( - RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), - (Match m) => '${m[1]}.', - )}'; - } - } -} diff --git a/lib/app/utils/format_helper.dart b/lib/app/utils/format_helper.dart index 854bfbd..7edaac7 100644 --- a/lib/app/utils/format_helper.dart +++ b/lib/app/utils/format_helper.dart @@ -4,7 +4,7 @@ import 'package:intl/intl.dart'; /// /// Kelas ini berisi fungsi-fungsi untuk memformat dan memanipulasi /// tanggal dan waktu. -class DateTimeHelper { +class FormatHelper { /// Mengkonversi DateTime dari UTC ke timezone lokal static DateTime toLocalDateTime(DateTime utcDateTime) { return utcDateTime.toLocal(); @@ -70,7 +70,6 @@ class DateTimeHelper { static String formatDateTime( DateTime? dateTime, { String format = 'dd MMM yyyy HH:mm', - String locale = 'id_ID', String defaultValue = 'Belum ditentukan', }) { if (dateTime == null) return defaultValue; @@ -78,7 +77,7 @@ class DateTimeHelper { // Pastikan tanggal dan waktu dalam timezone lokal final localDateTime = toLocalDateTime(dateTime); try { - return DateFormat(format, locale).format(localDateTime); + return DateFormat(format).format(localDateTime); } catch (e) { print('Error formatting date time: $e'); return localDateTime.toString(); // Fallback to basic format @@ -197,8 +196,10 @@ class DateTimeHelper { final String tanggal = localDateTime.day.toString().padLeft(2, '0'); final String bulan = namaBulan[localDateTime.month - 1]; final String tahun = localDateTime.year.toString(); + final String jam = localDateTime.hour.toString().padLeft(2, '0'); + final String menit = localDateTime.minute.toString().padLeft(2, '0'); - return '$hari, $tanggal $bulan $tahun'; + return '$hari, $tanggal $bulan $tahun $jam:$menit'; } /// Format angka dengan pemisah ribuan diff --git a/lib/app/widgets/app_drawer.dart b/lib/app/widgets/app_drawer.dart index b9b5487..2b1c2f5 100644 --- a/lib/app/widgets/app_drawer.dart +++ b/lib/app/widgets/app_drawer.dart @@ -38,14 +38,20 @@ class AppDrawer extends StatelessWidget { children: [ CircleAvatar( radius: 30, - backgroundColor: Colors.white, - backgroundImage: - avatar != null ? NetworkImage(avatar!) : null, - child: avatar == null - ? const Icon( - Icons.person, - size: 40, - color: AppTheme.primaryColor, + backgroundColor: AppTheme.primaryColor.withOpacity(0.2), + backgroundImage: avatar != null && avatar!.isNotEmpty + ? NetworkImage(avatar!) + : null, + child: (avatar == null || avatar!.isEmpty) + ? Text( + nama.isNotEmpty + ? nama.substring(0, 1).toUpperCase() + : '?', + style: const TextStyle( + fontWeight: FontWeight.bold, + color: AppTheme.primaryColor, + fontSize: 24, + ), ) : null, ), diff --git a/lib/app/widgets/bantuan_card.dart b/lib/app/widgets/bantuan_card.dart index 5f20ee1..f20457c 100644 --- a/lib/app/widgets/bantuan_card.dart +++ b/lib/app/widgets/bantuan_card.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; import 'package:penyaluran_app/app/data/models/penerima_penyaluran_model.dart'; +import 'package:penyaluran_app/app/utils/format_helper.dart'; import 'package:penyaluran_app/app/widgets/status_badge.dart'; class BantuanCard extends StatelessWidget { @@ -17,17 +17,11 @@ class BantuanCard extends StatelessWidget { @override Widget build(BuildContext context) { - final currencyFormat = NumberFormat.currency( - locale: 'id', - symbol: 'Rp ', - decimalDigits: 0, - ); - // Format jumlah bantuan berdasarkan tipe (uang atau bukan) String formattedJumlah = ''; if (item.jumlahBantuan != null) { if (item.isUang == true) { - formattedJumlah = currencyFormat.format(item.jumlahBantuan); + formattedJumlah = FormatHelper.formatRupiah(item.jumlahBantuan); } else { formattedJumlah = '${item.jumlahBantuan} ${item.satuan ?? ''}'; } @@ -120,8 +114,8 @@ class BantuanCard extends StatelessWidget { Flexible( child: Text( item.tanggalPenerimaan != null - ? DateFormat('dd MMMM yyyy', 'id_ID') - .format(item.tanggalPenerimaan!) + ? FormatHelper.formatDateTime( + item.tanggalPenerimaan!) : '-', style: TextStyle( color: Colors.grey.shade600, @@ -373,8 +367,8 @@ class BantuanCard extends StatelessWidget { Icons.calendar_today, 'Tanggal:', item.tanggalPenerimaan != null - ? DateFormat('dd MMMM yyyy', 'id_ID') - .format(item.tanggalPenerimaan!) + ? FormatHelper.formatDateTime( + item.tanggalPenerimaan!) : '-', ), const Divider(height: 16), diff --git a/lib/app/widgets/dialogs/detail_penitipan_dialog.dart b/lib/app/widgets/dialogs/detail_penitipan_dialog.dart index 9b0595b..ad6d051 100644 --- a/lib/app/widgets/dialogs/detail_penitipan_dialog.dart +++ b/lib/app/widgets/dialogs/detail_penitipan_dialog.dart @@ -3,6 +3,7 @@ import 'package:get/get.dart'; import 'package:penyaluran_app/app/data/models/penitipan_bantuan_model.dart'; import 'package:penyaluran_app/app/utils/format_helper.dart'; import 'package:penyaluran_app/app/theme/app_colors.dart'; +import 'package:penyaluran_app/app/widgets/dialogs/show_image_dialog.dart'; /// Dialog untuk menampilkan detail penitipan bantuan /// @@ -48,7 +49,7 @@ class DetailPenitipanDialog { ), _buildInfoRow( 'Tanggal Penitipan', - DateTimeHelper.formatDateTime( + FormatHelper.formatDateTime( item.tanggalPenitipan ?? item.createdAt), ), _buildInfoRow( @@ -63,7 +64,7 @@ class DetailPenitipanDialog { if (item.tanggalVerifikasi != null) _buildInfoRow( 'Tanggal Verifikasi', - DateTimeHelper.formatDateTime(item.tanggalVerifikasi), + FormatHelper.formatDateTime(item.tanggalVerifikasi), ), if (item.deskripsi != null && item.deskripsi!.isNotEmpty) _buildInfoRow('Deskripsi', item.deskripsi!), @@ -143,50 +144,7 @@ class DetailPenitipanDialog { /// Menampilkan gambar dalam layar penuh static void showFullScreenImage(BuildContext context, String imageUrl) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => Scaffold( - appBar: AppBar( - backgroundColor: Colors.black, - iconTheme: const IconThemeData(color: Colors.white), - ), - body: Container( - color: Colors.black, - child: Center( - child: InteractiveViewer( - panEnabled: true, - boundaryMargin: const EdgeInsets.all(20), - minScale: 0.5, - maxScale: 4, - child: Image.network( - imageUrl, - fit: BoxFit.contain, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return Center( - child: CircularProgressIndicator( - value: loadingProgress.expectedTotalBytes != null - ? loadingProgress.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null, - ), - ); - }, - errorBuilder: (context, error, stackTrace) { - return const Center( - child: Text( - 'Gagal memuat gambar', - style: TextStyle(color: Colors.white), - ), - ); - }, - ), - ), - ), - ), - ), - ), - ); + ShowImageDialog.showFullScreen(context, imageUrl); } /// Membangun baris informasi diff --git a/lib/app/widgets/dialogs/show_image_dialog.dart b/lib/app/widgets/dialogs/show_image_dialog.dart new file mode 100644 index 0000000..7b1fd13 --- /dev/null +++ b/lib/app/widgets/dialogs/show_image_dialog.dart @@ -0,0 +1,254 @@ +import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:penyaluran_app/app/theme/app_theme.dart'; +import 'dart:io'; +import 'package:path_provider/path_provider.dart'; +import 'package:http/http.dart' as http; +import 'package:share_plus/share_plus.dart'; + +/// Dialog untuk menampilkan gambar dalam ukuran besar +/// +/// Komponen ini dapat digunakan untuk menampilkan gambar dari URL +/// dengan kemampuan zoom dan pan pada gambar. +class ShowImageDialog { + /// Menampilkan dialog gambar + /// + /// [context] adalah BuildContext untuk menampilkan dialog + /// [imageUrl] adalah URL gambar yang akan ditampilkan + /// [title] adalah judul dari dialog, default 'Bukti Foto' + static void show( + BuildContext context, + String imageUrl, { + String title = 'Bukti Foto', + }) { + showDialog( + context: context, + builder: (BuildContext context) { + return Dialog( + insetPadding: const EdgeInsets.all(16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AppBar( + leading: IconButton( + icon: const Icon( + Icons.close, + color: Colors.white, + ), + onPressed: () => Navigator.of(context).pop(), + ), + title: Text( + title, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + actions: [ + IconButton( + icon: const Icon( + Icons.download, + color: Colors.white, + ), + onPressed: () => _downloadImage(context, imageUrl), + tooltip: 'Unduh Gambar', + ), + ], + elevation: 0, + backgroundColor: AppTheme.primaryColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + ), + SizedBox( + height: MediaQuery.of(context).size.height * 0.5, + child: InteractiveViewer( + panEnabled: true, + boundaryMargin: const EdgeInsets.all(16), + minScale: 0.5, + maxScale: 4, + child: CachedNetworkImage( + imageUrl: imageUrl, + placeholder: (context, url) => const Center( + child: CircularProgressIndicator(), + ), + errorWidget: (context, url, error) => Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error, color: Colors.red, size: 48), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + 'Gagal memuat gambar: $error', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.red), + ), + ), + ], + ), + fit: BoxFit.contain, + ), + ), + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.zoom_in, size: 20, color: Colors.grey), + const SizedBox(width: 8), + Text( + 'Cubit untuk memperbesar/memperkecil', + style: TextStyle( + color: Colors.grey[600], + fontSize: 14, + ), + ), + ], + ), + ), + ], + ), + ); + }, + ); + } + + /// Menampilkan dialog gambar layar penuh + /// + /// Versi layar penuh dari dialog gambar + /// [context] adalah BuildContext untuk menampilkan dialog + /// [imageUrl] adalah URL gambar yang akan ditampilkan + static void showFullScreen(BuildContext context, String imageUrl) { + showDialog( + context: context, + builder: (BuildContext context) { + return Dialog( + insetPadding: EdgeInsets.zero, + backgroundColor: Colors.transparent, + child: Stack( + alignment: Alignment.center, + children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + width: double.infinity, + height: double.infinity, + color: Colors.black87, + ), + ), + InteractiveViewer( + panEnabled: true, + boundaryMargin: const EdgeInsets.all(20), + minScale: 0.5, + maxScale: 4.0, + child: CachedNetworkImage( + imageUrl: imageUrl, + placeholder: (context, url) => const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ), + errorWidget: (context, url, error) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error, color: Colors.white, size: 32), + const SizedBox(height: 8), + Text( + 'Gagal memuat gambar', + style: const TextStyle(color: Colors.white), + ), + ], + ), + fit: BoxFit.contain, + ), + ), + Positioned( + top: 20, + right: 20, + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.download, + color: Colors.white, size: 30), + onPressed: () => _downloadImage(context, imageUrl), + tooltip: 'Unduh Gambar', + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.close, + color: Colors.white, size: 30), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + ], + ), + ); + }, + ); + } + + /// Mengunduh gambar dari URL dan menyimpannya ke penyimpanan lokal + /// + /// [context] adalah BuildContext untuk menampilkan snackbar + /// [imageUrl] adalah URL gambar yang akan diunduh + static Future _downloadImage( + BuildContext context, String imageUrl) async { + try { + // Tampilkan indikator loading + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Mengunduh gambar...'), + duration: Duration(seconds: 1), + ), + ); + + // Ambil data gambar dari URL + final response = await http.get(Uri.parse(imageUrl)); + + if (response.statusCode != 200) { + throw Exception('Gagal mengunduh gambar'); + } + + // Dapatkan direktori penyimpanan sementara + final tempDir = await getTemporaryDirectory(); + final fileName = + 'bukti_foto_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final file = File('${tempDir.path}/$fileName'); + + // Tulis data ke file + await file.writeAsBytes(response.bodyBytes); + + // Bagikan file + await Share.shareXFiles( + [XFile(file.path)], + text: 'Bukti Foto', + ); + + // Tampilkan pesan sukses + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Gambar berhasil diunduh dan siap dibagikan'), + backgroundColor: Colors.green, + ), + ); + } catch (e) { + // Tampilkan pesan error + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Gagal mengunduh gambar: $e'), + backgroundColor: Colors.red, + ), + ); + } + } +} diff --git a/lib/app/widgets/widgets.dart b/lib/app/widgets/widgets.dart index 3e73c29..36886a3 100644 --- a/lib/app/widgets/widgets.dart +++ b/lib/app/widgets/widgets.dart @@ -14,6 +14,7 @@ export 'cards/info_card.dart'; // Dialogs export 'dialogs/detail_penitipan_dialog.dart'; export 'dialogs/confirmation_dialog.dart'; +export 'dialogs/show_image_dialog.dart'; // Indicators export 'indicators/loading_indicator.dart'; diff --git a/lib/main.dart b/lib/main.dart index ebac19b..6d98513 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ import 'package:penyaluran_app/app/modules/auth/controllers/auth_controller.dart import 'package:intl/date_symbol_data_local.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:syncfusion_localizations/syncfusion_localizations.dart'; +import 'package:penyaluran_app/app/services/notification_service.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -27,11 +28,26 @@ void main() async { // Inisialisasi service Future initServices() async { - await Get.putAsync(() => SupabaseService().init()); - await Get.putAsync(() => AuthService().init()); + print('Initializing services...'); + // Inisialisasi SupabaseService dengan pendekatan async + final supabaseService = + await Get.putAsync(() => SupabaseService().init(), permanent: true); + print('SupabaseService initialized: ${supabaseService != null}'); + + // Inisialisasi AuthService + final authService = + await Get.putAsync(() => AuthService().init(), permanent: true); + print('AuthService initialized: ${authService != null}'); // Inisialisasi AuthController secara global - Get.put(AuthController(), permanent: true); + final authController = Get.put(AuthController(), permanent: true); + print('AuthController initialized: ${authController != null}'); + + // Register NotificationService + final notificationService = Get.put(NotificationService(), permanent: true); + print('NotificationService initialized: ${notificationService != null}'); + + print('All services initialized'); } class MyApp extends StatelessWidget { @@ -40,7 +56,7 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return GetMaterialApp( - title: 'Penerimaan App', + title: 'DisalurKita', theme: AppTheme.lightTheme, darkTheme: AppTheme.darkTheme, themeMode: ThemeMode.light, // Default ke tema terang @@ -60,6 +76,7 @@ class MyApp extends StatelessWidget { Locale('id', 'ID'), // Indonesia Locale('en', 'US'), // English ], + // initialBinding tidak diperlukan lagi karena service sudah diinisialisasi di initServices() ); } } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 32b752d..c60dbc2 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -11,6 +11,7 @@ import file_selector_macos import flutter_secure_storage_macos import open_file_mac import path_provider_foundation +import share_plus import shared_preferences_foundation import sqflite_darwin import url_launcher_macos @@ -22,6 +23,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 8e647f9..dc1c0b3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -670,7 +670,7 @@ packages: source: hosted version: "0.0.3" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" @@ -837,6 +837,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da + url: "https://pub.dev" + source: hosted + version: "10.1.4" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b + url: "https://pub.dev" + source: hosted + version: "5.0.2" shared_preferences: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index d1f86df..b245574 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -94,6 +94,8 @@ dependencies: uuid: ^4.5.1 # Library untuk cached image cached_network_image: ^3.3.1 + share_plus: ^10.1.4 + path: ^1.9.1 dev_dependencies: flutter_test: diff --git a/temp.txt b/temp.txt index 584bc5d4e54146b39f046019ee994d0a9d8b2caf..1e1f9a54ea198a451ce13ec2cbd9368198afdf15 100644 GIT binary patch delta 100 zcmdmRf@uQ~{o&$vW5{PHV#sAkWGI=;@29+3k2}i@O;CFCA;$=AG(qLbdfXh7eSEiI L6W?s)_b(Ix#l{?R delta 78 zcmdmRf@uQ~{o$Hy #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -18,6 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b4be188..9a293ae 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links file_selector_windows flutter_secure_storage_windows + share_plus url_launcher_windows )