h-1 lebaran

This commit is contained in:
Khafidh Fuadi
2025-03-30 14:45:16 +07:00
parent c008020705
commit 5aaeb58d2b
91 changed files with 9448 additions and 3756 deletions

View File

@ -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  <08>Õ<EFBFBD><EFBFBD>2<18> <20><><EFBFBD><EFBFBD><EFBFBD>2<EFBFBD>
?com.android.build.gradle.internal.cxx.io.EncodedFileFingerPrint  <08><EFBFBD><EFBFBD><EFBFBD><EFBFBD>2<18> <20><><EFBFBD><EFBFBD><EFBFBD>2<EFBFBD>

}D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\additional_project_files.txt  <08>Õ<EFBFBD><EFBFBD>2  <20><><EFBFBD><EFBFBD><EFBFBD>2~
}D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\additional_project_files.txt  <08><EFBFBD><EFBFBD><EFBFBD><EFBFBD>2  <20><><EFBFBD><EFBFBD><EFBFBD>2~
|
zD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\android_gradle_build.json  <08>Õ<EFBFBD><EFBFBD>2<18> <20><><EFBFBD><EFBFBD><EFBFBD>2<EFBFBD>
zD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\android_gradle_build.json  <08><EFBFBD><EFBFBD><EFBFBD><EFBFBD>2<18> <20><><EFBFBD><EFBFBD><EFBFBD>2<EFBFBD>
<EFBFBD>
D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\android_gradle_build_mini.json  <08>Õ<EFBFBD><EFBFBD>2<18> <20><><EFBFBD><EFBFBD><EFBFBD>2p
D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\android_gradle_build_mini.json  <08><EFBFBD><EFBFBD><EFBFBD><EFBFBD>2<18> <20><><EFBFBD><EFBFBD><EFBFBD>2p
n
lD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\build.ninja  <08>Õ<EFBFBD><EFBFBD>2<18><> <20><><EFBFBD><EFBFBD><EFBFBD>2t
lD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\build.ninja  <08><EFBFBD><EFBFBD><EFBFBD><EFBFBD>2<18><> <20><><EFBFBD><EFBFBD><EFBFBD>2t
r
pD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\build.ninja.txt  <08>Õ<EFBFBD><EFBFBD>2y
pD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\build.ninja.txt  <08><EFBFBD><EFBFBD><EFBFBD><EFBFBD>2y
w
uD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\build_file_index.txt  <08>Õ<EFBFBD><EFBFBD>2
uD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\build_file_index.txt  <08><EFBFBD><EFBFBD><EFBFBD><EFBFBD>2
K <20><><EFBFBD><EFBFBD><EFBFBD>2z
x
x
vD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\compile_commands.json  <08><><EFBFBD><EFBFBD><EFBFBD>2 ~
|
|
zD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\compile_commands.json.bin  <08><><EFBFBD><EFBFBD><EFBFBD>2
<EFBFBD>
<EFBFBD>
<EFBFBD>
<EFBFBD>D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\metadata_generation_command.txt  <08><><EFBFBD><EFBFBD><EFBFBD>2 <18> <20><><EFBFBD><EFBFBD><EFBFBD>2w
u
u
sD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\prefab_config.json  <08><><EFBFBD><EFBFBD><EFBFBD>2
 ( <20><><EFBFBD><EFBFBD><EFBFBD>2|
 ( <20><><EFBFBD><EFBFBD><EFBFBD>2|

View File

@ -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  <08>ȕ<EFBFBD><EFBFBD>2<18> <20><><EFBFBD><EFBFBD><EFBFBD>2<EFBFBD>
?com.android.build.gradle.internal.cxx.io.EncodedFileFingerPrint  <08><EFBFBD><EFBFBD><EFBFBD><EFBFBD>2<18> <20><><EFBFBD><EFBFBD><EFBFBD>2<EFBFBD>
<EFBFBD>
D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\additional_project_files.txt  <08>ȕ<EFBFBD><EFBFBD>2  <20><><EFBFBD><EFBFBD><EFBFBD>2<EFBFBD>
D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\additional_project_files.txt  <08><EFBFBD><EFBFBD><EFBFBD><EFBFBD>2  <20><><EFBFBD><EFBFBD><EFBFBD>2<EFBFBD>
~
|D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\android_gradle_build.json  <08>ȕ<EFBFBD><EFBFBD>2<18> <20><><EFBFBD><EFBFBD><EFBFBD>2<EFBFBD>
|D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\android_gradle_build.json  <08><EFBFBD><EFBFBD><EFBFBD><EFBFBD>2<18> <20><><EFBFBD><EFBFBD><EFBFBD>2<EFBFBD>
<EFBFBD>
<EFBFBD>D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\android_gradle_build_mini.json  <08>ȕ<EFBFBD><EFBFBD>2<18> <20><><EFBFBD><EFBFBD><EFBFBD>2r
<EFBFBD>D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\android_gradle_build_mini.json  <08><EFBFBD><EFBFBD><EFBFBD><EFBFBD>2<18> <20><><EFBFBD><EFBFBD><EFBFBD>2r
p
nD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\build.ninja  <08>ȕ<EFBFBD><EFBFBD>2<18><> <20><><EFBFBD><EFBFBD><EFBFBD>2v
nD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\build.ninja  <08><EFBFBD><EFBFBD><EFBFBD><EFBFBD>2<18><> <20><><EFBFBD><EFBFBD><EFBFBD>2v
t
rD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\build.ninja.txt  <08>ȕ<EFBFBD><EFBFBD>2{
rD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\build.ninja.txt  <08><EFBFBD><EFBFBD><EFBFBD><EFBFBD>2{
y
wD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\build_file_index.txt  <08>ȕ<EFBFBD><EFBFBD>2
wD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\build_file_index.txt  <08><EFBFBD><EFBFBD><EFBFBD><EFBFBD>2
K <20><><EFBFBD><EFBFBD><EFBFBD>2|
z
z
xD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\compile_commands.json  <08><><EFBFBD><EFBFBD><EFBFBD>2 <09>
~
~
|D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\compile_commands.json.bin  <08><><EFBFBD><EFBFBD><EFBFBD>2
<EFBFBD>
<EFBFBD>
<EFBFBD>
<EFBFBD>D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\metadata_generation_command.txt  <08><><EFBFBD><EFBFBD><EFBFBD>2 <18> <20><><EFBFBD><EFBFBD><EFBFBD>2y
w
w
uD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\prefab_config.json  <08><><EFBFBD><EFBFBD><EFBFBD>2
 ( <20><><EFBFBD><EFBFBD><EFBFBD>2~
 ( <20><><EFBFBD><EFBFBD><EFBFBD>2~

View File

@ -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  <08>͕<EFBFBD><EFBFBD>2<18> <20><><EFBFBD><EFBFBD><EFBFBD>2{
?com.android.build.gradle.internal.cxx.io.EncodedFileFingerPrint  <08><EFBFBD><EFBFBD><EFBFBD><EFBFBD>2<18> <20><><EFBFBD><EFBFBD><EFBFBD>2{
y
wD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\additional_project_files.txt  <08>͕<EFBFBD><EFBFBD>2  <20><><EFBFBD><EFBFBD><EFBFBD>2x
wD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\additional_project_files.txt  <08><EFBFBD><EFBFBD><EFBFBD><EFBFBD>2  <20><><EFBFBD><EFBFBD><EFBFBD>2x
v
tD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\android_gradle_build.json  <08>͕<EFBFBD><EFBFBD>2<18> <20><><EFBFBD><EFBFBD><EFBFBD>2}
tD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\android_gradle_build.json  <08><EFBFBD><EFBFBD><EFBFBD><EFBFBD>2<18> <20><><EFBFBD><EFBFBD><EFBFBD>2}
{
yD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\android_gradle_build_mini.json  <08>͕<EFBFBD><EFBFBD>2<18> <20><><EFBFBD><EFBFBD><EFBFBD>2j
yD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\android_gradle_build_mini.json  <08><EFBFBD><EFBFBD><EFBFBD><EFBFBD>2<18> <20><><EFBFBD><EFBFBD><EFBFBD>2j
h
fD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\build.ninja  <08>͕<EFBFBD><EFBFBD>2<18><> <20><><EFBFBD><EFBFBD><EFBFBD>2n
fD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\build.ninja  <08><EFBFBD><EFBFBD><EFBFBD><EFBFBD>2<18><> <20><><EFBFBD><EFBFBD><EFBFBD>2n
l
jD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\build.ninja.txt  <08>͕<EFBFBD><EFBFBD>2s
jD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\build.ninja.txt  <08><EFBFBD><EFBFBD><EFBFBD><EFBFBD>2s
q
oD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\build_file_index.txt  <08>͕<EFBFBD><EFBFBD>2
oD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\build_file_index.txt  <08><EFBFBD><EFBFBD><EFBFBD><EFBFBD>2
K <20><><EFBFBD><EFBFBD><EFBFBD>2t
r
r
pD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\compile_commands.json  <08><><EFBFBD><EFBFBD><EFBFBD>2 x
v
v
tD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\compile_commands.json.bin  <08><><EFBFBD><EFBFBD><EFBFBD>2
~
|
|
zD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\metadata_generation_command.txt  <08><><EFBFBD><EFBFBD><EFBFBD>2 <18> <20><><EFBFBD><EFBFBD><EFBFBD>2q
o
o
mD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\prefab_config.json  <08><><EFBFBD><EFBFBD><EFBFBD>2
 ( <20><><EFBFBD><EFBFBD><EFBFBD>2v
 ( <20><><EFBFBD><EFBFBD><EFBFBD>2v

View File

@ -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  <08>ϕ<EFBFBD><EFBFBD>2<18> <20><><EFBFBD><EFBFBD><EFBFBD>2~
?com.android.build.gradle.internal.cxx.io.EncodedFileFingerPrint  Ұ<EFBFBD><EFBFBD><EFBFBD>2<18> <20><><EFBFBD><EFBFBD><EFBFBD>2~
|
zD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\additional_project_files.txt  <08>ϕ<EFBFBD><EFBFBD>2  <20><><EFBFBD><EFBFBD><EFBFBD>2{
zD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\additional_project_files.txt  Ұ<EFBFBD><EFBFBD><EFBFBD>2  <20><><EFBFBD><EFBFBD><EFBFBD>2{
y
wD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\android_gradle_build.json  <08>ϕ<EFBFBD><EFBFBD>2<18> <20><><EFBFBD><EFBFBD><EFBFBD>2<EFBFBD>
wD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\android_gradle_build.json  Ұ<EFBFBD><EFBFBD><EFBFBD>2<18> <20><><EFBFBD><EFBFBD><EFBFBD>2<EFBFBD>
~
|D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\android_gradle_build_mini.json  <08>ϕ<EFBFBD><EFBFBD>2<18> <20><><EFBFBD><EFBFBD><EFBFBD>2m
|D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\android_gradle_build_mini.json  Ұ<EFBFBD><EFBFBD><EFBFBD>2<18> <20><><EFBFBD><EFBFBD><EFBFBD>2m
k
iD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\build.ninja  <08>ϕ<EFBFBD><EFBFBD>2<18><> <20><><EFBFBD><EFBFBD><EFBFBD>2q
iD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\build.ninja  Ұ<EFBFBD><EFBFBD><EFBFBD>2<18><> <20><><EFBFBD><EFBFBD><EFBFBD>2q
o
mD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\build.ninja.txt  <08>ϕ<EFBFBD><EFBFBD>2v
mD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\build.ninja.txt  Ұ<EFBFBD><EFBFBD><EFBFBD>2v
t
rD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\build_file_index.txt  <08>ϕ<EFBFBD><EFBFBD>2
rD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\build_file_index.txt  Ұ<EFBFBD><EFBFBD><EFBFBD>2
K <20><><EFBFBD><EFBFBD><EFBFBD>2w
u
u
sD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\compile_commands.json  Ұ<><D2B0><EFBFBD>2 {
y
y
wD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\compile_commands.json.bin  Ұ<><D2B0><EFBFBD>2
<EFBFBD>


}D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\metadata_generation_command.txt  Ұ<><D2B0><EFBFBD>2 <18> <20><><EFBFBD><EFBFBD><EFBFBD>2t
r
r
pD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\prefab_config.json  Ұ<><D2B0><EFBFBD>2
 ( <20><><EFBFBD><EFBFBD><EFBFBD>2y
 ( <20><><EFBFBD><EFBFBD><EFBFBD>2y

View File

@ -6,7 +6,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="penyaluran_app"
android:label="DisalurKita"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
@ -47,5 +47,24 @@
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<!-- Konfigurasi untuk url_launcher -->
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="application/pdf" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="image/*" />
</intent>
</queries>
</manifest>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

108
assets/sql/README.md Normal file
View File

@ -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';
```

View File

@ -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;
$$;

View File

@ -51,5 +51,11 @@
<string>Aplikasi memerlukan akses galeri untuk memilih foto bukti serah terima</string>
<key>NSMicrophoneUsageDescription</key>
<string>Aplikasi memerlukan akses mikrofon untuk merekam video</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>http</string>
<string>https</string>
<string>file</string>
</array>
</dict>
</plist>

View File

@ -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(),
};

View File

@ -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<String, dynamic>? lokasiPenyaluran,
Map<String, dynamic>? kategori,
Map<String, dynamic>? 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,
);
}
}

View File

@ -457,4 +457,12 @@ class AuthProvider {
Future<void> markNotificationAsRead(int notificationId) async {
await _supabaseService.markNotificationAsRead(notificationId);
}
// Metode untuk reset password
Future<void> resetPasswordForEmail(String email, {String? redirectTo}) async {
await _supabaseService.client.auth.resetPasswordForEmail(
email,
redirectTo: redirectTo,
);
}
}

View File

@ -0,0 +1,8 @@
import 'package:get/get.dart';
class AboutBinding extends Bindings {
@override
void dependencies() {
// Tidak perlu controller khusus untuk halaman Tentang Kami
}
}

View File

@ -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,
),
],
),
),
],
);
}
}

View File

@ -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<void> 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<void> 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;
}
}

View File

@ -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<AuthController> {
const ForgotPasswordView({super.key});
@override
Widget build(BuildContext context) {
final TextEditingController emailController = TextEditingController();
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
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,
),
),
],
),
),
],
),
),
),
),
),
),
);
}
}

View File

@ -10,140 +10,360 @@ class LoginView extends GetView<AuthController> {
@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),
),
),
),
],
),
),
),
),

View File

@ -11,8 +11,16 @@ class RegisterDonaturView extends GetView<AuthController> {
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<AuthController> {
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<AuthController> {
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<AuthController> {
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<AuthController> {
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<AuthController> {
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<AuthController> {
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<AuthController> {
const SizedBox(height: 15),
// Jenis Donatur (Dropdown)
DropdownButtonFormField<String>(
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<String>(
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),
],
),
),

View File

@ -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<StokBantuanModel> stokBantuan = <StokBantuanModel>[].obs;
// Data untuk lokasi penyaluran
final RxList<LokasiPenyaluranModel> lokasiPenyaluran =
<LokasiPenyaluranModel>[].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<void> fetchLokasiPenyaluran() async {
try {
final response = await _supabaseService.client
.from('lokasi_penyaluran')
.select()
.eq('is_lokasi_titip', true)
.order('nama');
lokasiPenyaluran.value = (response as List<dynamic>)
.map((data) => LokasiPenyaluranModel.fromJson(data))
.toList();
} catch (e) {
print('Error fetching lokasi penyaluran: $e');
}
}
// Ambil data notifikasi
Future<void> 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;

View File

@ -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<DonaturDashboardController> {
@ -36,13 +36,57 @@ class DonaturDashboardView extends GetView<DonaturDashboardController> {
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<DonaturDashboardController> {
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<DonaturDashboardController> {
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<DonaturDashboardController> {
);
}
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<DonaturDashboardController> {
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<DonaturDashboardController> {
),
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<DonaturDashboardController> {
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;

View File

@ -1,12 +1,12 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:penyaluran_app/app/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<DonaturDashboardController> {
const DonaturJadwalDetailView({Key? key}) : super(key: key);
const DonaturJadwalDetailView({super.key});
@override
DonaturDashboardController get controller {
@ -35,7 +35,6 @@ class DonaturJadwalDetailView extends GetView<DonaturDashboardController> {
_buildDetailSection(jadwal),
_buildPelaksanaSection(jadwal),
_buildStatusSection(jadwal),
_buildActionSection(jadwal),
],
),
),
@ -126,8 +125,7 @@ class DonaturJadwalDetailView extends GetView<DonaturDashboardController> {
),
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<DonaturDashboardController> {
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<DonaturDashboardController> {
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<DonaturDashboardController> {
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<DonaturDashboardController> {
);
}
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,

View File

@ -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<DonaturDashboardController> {
@ -97,7 +98,7 @@ class DonaturJadwalView extends GetView<DonaturDashboardController> {
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<DonaturDashboardController> {
// Urutkan kunci (bulan) secara kronologis
List<String> 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<DonaturDashboardController> {
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<DonaturDashboardController> {
),
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<DonaturDashboardController> {
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<DonaturDashboardController> {
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;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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<DonaturDashboardController> {
DonaturRiwayatPenitipanView({super.key});
@ -60,8 +61,7 @@ class DonaturRiwayatPenitipanView extends GetView<DonaturDashboardController> {
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<DonaturDashboardController> {
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<DonaturDashboardController> {
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<DonaturDashboardController> {
_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<DonaturDashboardController> {
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<DonaturDashboardController> {
),
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<DonaturDashboardController> {
),
const SizedBox(height: 8),
GestureDetector(
onTap: () =>
showFullScreenImage(penitipan.fotoBuktiSerahTerima!),
onTap: () => ShowImageDialog.showFullScreen(
context,
penitipan.fotoBuktiSerahTerima!,
),
child: Container(
height: 200,
width: double.infinity,

View File

@ -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<DonaturDashboardController> {
const DonaturSkemaView({super.key});
@ -549,14 +548,14 @@ class DonaturSkemaView extends GetView<DonaturDashboardController> {
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<DonaturDashboardController> {
}
}
// 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]}.')}';
}

View File

@ -34,7 +34,7 @@ class DonaturView extends GetView<DonaturDashboardController> {
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<DonaturDashboardController> {
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<DonaturDashboardController> {
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<DonaturDashboardController> {
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,
),
);
}
}

View File

@ -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(

View File

@ -65,7 +65,7 @@ class LaporanPenyaluranCreateView extends GetView<LaporanPenyaluranController> {
controller.selectedPenyaluran.value!
.tanggalPenyaluran !=
null
? DateTimeHelper.formatDateTime(controller
? FormatHelper.formatDateTime(controller
.selectedPenyaluran.value!.tanggalPenyaluran!)
: '-',
),
@ -73,7 +73,7 @@ class LaporanPenyaluranCreateView extends GetView<LaporanPenyaluranController> {
'Tanggal Selesai',
controller.selectedPenyaluran.value!.tanggalSelesai !=
null
? DateTimeHelper.formatDateTime(controller
? FormatHelper.formatDateTime(controller
.selectedPenyaluran.value!.tanggalSelesai!)
: '-',
),

View File

@ -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<LaporanPenyaluranController> {
const LaporanPenyaluranView({super.key});
@ -255,8 +254,8 @@ class LaporanPenyaluranView extends GetView<LaporanPenyaluranController> {
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<LaporanPenyaluranController> {
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<LaporanPenyaluranController> {
const SizedBox(width: 4),
Text(
penyaluran.tanggalSelesai != null
? DateFormat('dd/MM/yyyy')
.format(penyaluran.tanggalSelesai!)
? FormatHelper.formatDateTime(
penyaluran.tanggalSelesai!)
: '-',
style: TextStyle(
fontSize: 12,

View File

@ -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>(
() => JadwalPenyaluranController(),
);
// Register service untuk komunikasi pembaruan jadwal
if (!Get.isRegistered<JadwalUpdateService>()) {
Get.lazyPut<JadwalUpdateService>(
() => JadwalUpdateService(),
fenix: true, // Pastikan service tetap aktif selama aplikasi berjalan
);
}
}
}

View File

@ -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);

View File

@ -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 =

View File

@ -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

View File

@ -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<AuthController>();
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>()) {
_jadwalUpdateService = Get.find<JadwalUpdateService>();
} 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<String, dynamic> 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<CounterService>()) {
final counterService = Get.find<CounterService>();
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<void> 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<PenyaluranBantuanModel> jadwalToUpdate = [];
List<PenyaluranBantuanModel> jadwalTerlewat = [];
// Kelompokkan jadwal yang perlu diperbarui untuk mengurangi jumlah operasi database
final Map<String, String> jadwalUpdates = {};
final List<PenyaluranBantuanModel> jadwalToUpdate = [];
final List<PenyaluranBantuanModel> 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<NotificationService>();
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<void> 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<void> 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<NotificationService>();
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<void> 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

View File

@ -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,

View File

@ -289,7 +289,7 @@ class PenerimaController extends GetxController {
);
if (picked != null) {
tanggalPenyaluran.value = DateTimeHelper.formatDate(picked);
tanggalPenyaluran.value = FormatHelper.formatDateTime(picked);
}
}

View File

@ -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<AuthController>();
@ -182,10 +183,22 @@ class PetugasDesaController extends GetxController {
}
_counterService = Get.find<CounterService>();
// Pastikan JadwalUpdateService juga tersedia
JadwalUpdateService jadwalUpdateService;
if (Get.isRegistered<JadwalUpdateService>()) {
jadwalUpdateService = Get.find<JadwalUpdateService>();
} else {
jadwalUpdateService = Get.put(JadwalUpdateService());
}
// Perbarui counter pada saat aplikasi dimulai
jadwalUpdateService.refreshCounters();
// Muat data awal
loadUserProfile();
loadNotifikasiData();
loadJadwalData();
loadPenitipanData();
loadJadwalData();
loadNotifikasiData();
loadPengaduanData();
}

View File

@ -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<AuthController>();
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<CounterService>();
// Inisialisasi JadwalUpdateService untuk pembaruan realtime
if (Get.isRegistered<JadwalUpdateService>()) {
_jadwalUpdateService = Get.find<JadwalUpdateService>();
} 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<String, dynamic> 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<void> loadUserProfile() async {
try {
@ -155,14 +188,14 @@ class PetugasDesaDashboardController extends GetxController {
}
}
Future<void> loadJadwalAktif() async {
Future<void> 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');

View File

@ -221,15 +221,25 @@ class DaftarPenerimaView extends GetView<PenerimaController> {
),
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,
),

View File

@ -33,6 +33,58 @@ class DashboardView extends GetView<PetugasDesaDashboardController> {
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<PetugasDesaDashboardController> {
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<PetugasDesaDashboardController> {
final DateTime tanggal =
DateTime.parse(jadwal['tanggal_penyaluran']);
final String formattedDate =
DateTimeHelper.formatDateTime(tanggal);
FormatHelper.formatDateTime(tanggal);
final kategoriBantuan =
jadwal['kategori_bantuan'] as Map<String, dynamic>;
final lokasiPenyaluran =
jadwal['lokasi_penyaluran'] as Map<String, dynamic>;
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<PetugasDesaDashboardController> {
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<PetugasDesaDashboardController> {
);
}
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<PetugasDesaDashboardController> {
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(

View File

@ -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<DonaturController> {
const DetailDonaturView({super.key});
@ -359,7 +360,7 @@ class DetailDonaturView extends GetView<DonaturController> {
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<DonaturController> {
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<DonaturController> {
getPetugasDesaNama: (String? id) =>
controller.getPetugasDesaNama(id) ?? 'Petugas tidak diketahui',
showFullScreenImage: (String imageUrl) {
DetailPenitipanDialog.showFullScreenImage(Get.context!, imageUrl);
ShowImageDialog.showFullScreen(Get.context!, imageUrl);
},
);
}

View File

@ -107,14 +107,24 @@ class DetailPenerimaView extends GetView<PenerimaController> {
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<PenerimaController> {
child: _buildInfoItem(
Icons.calendar_today,
'Tanggal Penerimaan',
DateTimeHelper.formatDateTime(tanggalPenerimaan),
FormatHelper.formatDateTime(tanggalPenerimaan),
),
),
Expanded(

View File

@ -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<PengaduanController> {
const DetailPengaduanView({super.key});
@ -1092,8 +1091,8 @@ class DetailPengaduanView extends GetView<PengaduanController> {
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<PengaduanController> {
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<PengaduanController> {
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<PengaduanController> {
);
}
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<PengaduanController> {
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<PengaduanController> {
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<PengaduanController> {
);
}
}
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),
),
),
],
),
);
},
);
}
}

View File

@ -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!)
: '-',
),

View File

@ -198,7 +198,7 @@ class _KonfirmasiPenerimaPageState extends State<KonfirmasiPenerimaPage> {
'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<KonfirmasiPenerimaPage> {
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 {

View File

@ -44,7 +44,7 @@ class PengaduanView extends GetView<PengaduanController> {
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<PengaduanController> {
),
),
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<PengaduanController> {
// Format tanggal menggunakan DateTimeHelper
String formattedDate = '';
if (item.tanggalPengaduan != null) {
formattedDate = DateTimeHelper.formatDate(item.tanggalPengaduan);
formattedDate = FormatHelper.formatDateTime(item.tanggalPengaduan);
}
return Card(

View File

@ -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<PenitipanBantuanController> {
@ -72,7 +73,7 @@ class PenitipanView extends GetView<PenitipanBantuanController> {
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<PenitipanBantuanController> {
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<PenitipanBantuanController> {
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<PenitipanBantuanController> {
),
),
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<PenitipanBantuanController> {
],
),
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<PenitipanBantuanController> {
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<PenitipanBantuanController> {
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<PenitipanBantuanController> {
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<PenitipanBantuanController> {
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),

View File

@ -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<JadwalPenyaluranController> {
const PenyaluranView({super.key});
@ -41,13 +42,20 @@ class PenyaluranView extends GetView<JadwalPenyaluranController> {
),
],
),
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<JadwalPenyaluranController> {
// 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<JadwalPenyaluranController> {
],
);
}
// 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,
),
),
],
),
],
),
),
),
);
}
}

View File

@ -39,7 +39,7 @@ class PetugasDesaView extends GetView<PetugasDesaController> {
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<PetugasDesaController> {
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<PetugasDesaController> {
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<PetugasDesaController> {
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,

View File

@ -43,7 +43,7 @@ class RiwayatPengaduanView extends GetView<RiwayatPengaduanController> {
// 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<RiwayatPengaduanController> {
),
),
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<RiwayatPengaduanController> {
// 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;

View File

@ -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<PenitipanBantuanController> {
const RiwayatPenitipanView({super.key});
@ -47,7 +48,7 @@ class RiwayatPenitipanView extends GetView<PenitipanBantuanController> {
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<PenitipanBantuanController> {
),
),
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<PenitipanBantuanController> {
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<PenitipanBantuanController> {
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<PenitipanBantuanController> {
],
),
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<PenitipanBantuanController> {
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<PenitipanBantuanController> {
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<PenitipanBantuanController> {
_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<PenitipanBantuanController> {
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<PenitipanBantuanController> {
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<PenitipanBantuanController> {
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<PenitipanBantuanController> {
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<PenitipanBantuanController> {
);
}
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),

View File

@ -52,7 +52,7 @@ class RiwayatPenyaluranView extends GetView<JadwalPenyaluranController> {
.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<JadwalPenyaluranController> {
),
),
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<JadwalPenyaluranController> {
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<JadwalPenyaluranController> {
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<JadwalPenyaluranController> {
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<JadwalPenyaluranController> {
_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),

View File

@ -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<RiwayatStokController> {
const RiwayatStokView({super.key});
@ -353,7 +354,7 @@ class RiwayatStokView extends GetView<RiwayatStokController> {
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
}),
],
onChanged: (value) {
if (value != null) {
@ -543,7 +544,7 @@ class RiwayatStokView extends GetView<RiwayatStokController> {
const SizedBox(height: 4),
Text(
riwayat.createdAt != null
? DateTimeHelper.formatDateTime(
? FormatHelper.formatDateTime(
riwayat.createdAt!)
: '-',
style: TextStyle(
@ -598,7 +599,7 @@ class RiwayatStokView extends GetView<RiwayatStokController> {
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<RiwayatStokController> {
);
}
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<RiwayatStokController> {
Widget _buildPenitipanDetail(
BuildContext context, Map<String, dynamic> 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<RiwayatStokController> {
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<RiwayatStokController> {
Widget _buildPenerimaanDetail(
BuildContext context, Map<String, dynamic> 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<RiwayatStokController> {
),
const SizedBox(height: 12),
InkWell(
onTap: () => _showImageDialog(context, buktiPenerimaan),
onTap: () => ShowImageDialog.show(context, buktiPenerimaan),
child: Container(
height: 180,
width: double.infinity,

View File

@ -156,7 +156,7 @@ class StokBantuanView extends GetView<StokBantuanController> {
),
),
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<StokBantuanController> {
),
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<StokBantuanController> {
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<StokBantuanController> {
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<StokBantuanController> {
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<StokBantuanController> {
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),

View File

@ -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<JadwalPenyaluranController> {
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<FormState>();
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<void> _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<String, dynamic> 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,
),
);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -231,12 +231,39 @@ class ProfileView extends GetView<ProfileController> {
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,
),
);
}),
);
}

View File

@ -33,23 +33,20 @@ class _SplashViewState extends State<SplashView> {
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<SplashView> {
),
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<Color>(Colors.white),
valueColor:
AlwaysStoppedAnimation<Color>(AppTheme.primaryColor),
),
],
),

View File

@ -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<WargaDashboardController> {
const WargaDetailPengaduanView({super.key});
@ -670,8 +670,7 @@ class WargaDetailPengaduanView extends GetView<WargaDashboardController> {
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<WargaDashboardController> {
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<WargaDashboardController> {
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<WargaDashboardController> {
);
}
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<Color>(
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<WargaDetailPengaduanView>();
wargaDetailView.showFullScreenImage(context, imagePath);
ShowImageDialog.showFullScreen(context, imagePath);
}
Future<void> _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<String> 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(

View File

@ -11,12 +11,12 @@ class FormPengaduanView extends StatefulWidget {
final List<File>? selectedImages;
const FormPengaduanView({
Key? key,
super.key,
required this.uidPenerimaan,
this.judul,
this.deskripsi,
this.selectedImages,
}) : super(key: key);
});
@override
State<FormPengaduanView> createState() => _FormPengaduanViewState();
@ -219,7 +219,7 @@ class _FormPengaduanViewState extends State<FormPengaduanView> {
),
),
const SizedBox(height: 8),
Container(
SizedBox(
height: 120,
child: ListView.builder(
scrollDirection: Axis.horizontal,

View File

@ -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<WargaDashboardController> {
@ -23,6 +23,54 @@ class WargaDashboardView extends GetView<WargaDashboardController> {
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<WargaDashboardController> {
? 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<WargaDashboardController> {
}
Widget _buildPenerimaanSummary() {
final currencyFormat = NumberFormat.currency(
locale: 'id',
symbol: 'Rp ',
decimalDigits: 0,
);
double totalUang = 0;
Map<String, double> totalNonUang = {};
@ -494,7 +543,7 @@ class WargaDashboardView extends GetView<WargaDashboardController> {
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)

View File

@ -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<WargaDashboardController> {
}
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<WargaDashboardController> {
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<WargaDashboardController> {
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<WargaDashboardController> {
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<WargaDashboardController> {
),
const SizedBox(width: 8),
const Text(
'Pengaduan Terdaftar',
'Pengaduan',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
@ -1547,8 +1538,7 @@ class WargaDetailPenerimaanView extends GetView<WargaDashboardController> {
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,

View File

@ -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<WargaDashboardController> {
const WargaPengaduanView({super.key});
@ -380,7 +375,7 @@ class WargaPengaduanView extends GetView<WargaDashboardController> {
Expanded(
child: Text(
item.tanggalPengaduan != null
? DateTimeHelper.formatDateTime(
? FormatHelper.formatDateTime(
item.tanggalPengaduan!)
: '-',
style: TextStyle(

View File

@ -20,13 +20,13 @@ class WargaView extends GetView<WargaDashboardController> {
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<WargaDashboardController> {
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<WargaDashboardController> {
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<WargaDashboardController> {
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,

View File

@ -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(),

View File

@ -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';

View File

@ -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<JadwalUpdateService>();
final SupabaseService _supabaseService = SupabaseService.to;
// Stream controller untuk mengirim notifikasi pembaruan jadwal ke seluruh aplikasi
final _jadwalUpdateStream =
StreamController<Map<String, dynamic>>.broadcast();
Stream<Map<String, dynamic>> get jadwalUpdateStream =>
_jadwalUpdateStream.stream;
// Digunakan untuk menyimpan status terakhir pembaruan jadwal
final RxMap<String, DateTime> lastUpdateTimestamp = <String, DateTime>{}.obs;
// Map untuk melacak jadwal yang sedang dalam pengawasan intensif
final RxMap<String, DateTime> _watchedJadwal = <String, DateTime>{}.obs;
// Timer untuk memeriksa jadwal yang sedang dalam pengawasan
Timer? _watchTimer;
// Mencatat controller yang berlangganan untuk pembaruan
final List<String> _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<String> jadwalToUpdate = [];
final List<String> 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<String, dynamic> 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<void> 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<void> refreshCounters() async {
try {
// Perbarui counter jika CounterService telah terinisialisasi
if (Get.isRegistered<CounterService>()) {
final counterService = Get.find<CounterService>();
// 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');
}
}
}

View File

@ -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<NotificationService>();
final SupabaseService _supabaseService = SupabaseService.to;
// Daftar notifikasi yang belum dibaca
final RxList<Map<String, dynamic>> unreadNotifications =
<Map<String, dynamic>>[].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<void> 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<void> sendJadwalStatusNotification({
required String jadwalId,
required String newStatus,
required String jadwalNama,
List<String>? 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<void> 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<void> 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;
}
}
}

View File

@ -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<void> updateJadwalStatus(String jadwalId, String status) async {
Future<void> 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<void> batchUpdateJadwalStatus(
Map<String, String> 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<Map<String, dynamic>> 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<String> successIds =
List<String>.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<String> errorIds =
List<String>.from(result['errors']['ids'] ?? []);
final List<String> errorMessages =
List<String>.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<void> _fallbackToIndividualUpdates(
Map<String, String> 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
}
}

View File

@ -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]}.',
)}';
}
}
}

View File

@ -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

View File

@ -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,
),

View File

@ -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),

View File

@ -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

View File

@ -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<Color>(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<void> _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,
),
);
}
}
}

View File

@ -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';

View File

@ -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<void> 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()
);
}
}

View File

@ -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"))

View File

@ -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:

View File

@ -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:

BIN
temp.txt

Binary file not shown.

View File

@ -9,6 +9,7 @@
#include <app_links/app_links_plugin_c_api.h>
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <share_plus/share_plus_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
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"));
}

View File

@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
app_links
file_selector_windows
flutter_secure_storage_windows
share_plus
url_launcher_windows
)