From e0839a7237cd129f2e2e97ab70fe8e4e014338bc Mon Sep 17 00:00:00 2001 From: 1rHub Date: Thu, 6 Aug 2026 15:22:07 +0700 Subject: [PATCH] Menambahkan project TA --- .gitignore | 14 + .../main/AndroidManifest_20260608222936.xml | 51 + .../main/AndroidManifest_20260626234244.xml | 51 + .../lib/copilot_20260701235309.dart | 0 .../lib/copilot_20260701235342.dart | 3 + .../lib/copilot_20260701235642.dart | 0 .../lib/copilot_20260701235646.dart | 6 + .../lib/copilot_20260701235650.dart | 0 .../lib/copilot_20260701235651.dart | 6 + .../lib/copilot_20260702000412.dart | 0 .../lib/fitur/auth/auth_20260621154154.dart | 707 +++++ .../lib/fitur/auth/auth_20260623225159.dart | 707 +++++ .../lib/fitur/auth/auth_20260623225315.dart | 707 +++++ .../lib/fitur/auth/auth_20260624054320.dart | 707 +++++ .../lib/fitur/auth/auth_20260624223837.dart | 707 +++++ .../lib/fitur/auth/auth_20260701235026.dart | 709 +++++ .../lib/fitur/auth/auth_20260701235027.dart | 709 +++++ .../lib/fitur/auth/auth_20260701235256.dart | 707 +++++ .../prediction_page_20260621152900.dart | 950 ++++++ .../prediction_page_20260621155257.dart | 950 ++++++ .../prediction_page_20260621155258.dart | 950 ++++++ .../prediction_page_20260621155259.dart | 950 ++++++ .../prediction_page_20260621155303.dart | 950 ++++++ .../prediction_page_20260621155311.dart | 950 ++++++ .../mobile_app/lib/main_20260615201942.dart | 2004 ++++++++++++ .../mobile_app/lib/main_20260615202635.dart | 2004 ++++++++++++ .../mobile_app/lib/main_20260615202727.dart | 2004 ++++++++++++ .../mobile_app/lib/main_20260615204019.dart | 2087 +++++++++++++ .../mobile_app/lib/main_20260615204022.dart | 2088 +++++++++++++ .../mobile_app/lib/main_20260615204035.dart | 2088 +++++++++++++ .../mobile_app/lib/main_20260615204040.dart | 2088 +++++++++++++ .../mobile_app/lib/main_20260615204048.dart | 2087 +++++++++++++ .../mobile_app/lib/main_20260615204050.dart | 2087 +++++++++++++ .../mobile_app/lib/main_20260615204051.dart | 2087 +++++++++++++ .../mobile_app/lib/main_20260615204054.dart | 2088 +++++++++++++ .../mobile_app/lib/main_20260615204139.dart | 2088 +++++++++++++ .../mobile_app/lib/main_20260615204209.dart | 2088 +++++++++++++ .../mobile_app/lib/main_20260615204211.dart | 2088 +++++++++++++ .../mobile_app/lib/main_20260615204214.dart | 2088 +++++++++++++ .../mobile_app/lib/main_20260615204218.dart | 2088 +++++++++++++ .../mobile_app/lib/main_20260615204221.dart | 2088 +++++++++++++ .../mobile_app/lib/main_20260615223258.dart | 2395 ++++++++++++++ .../mobile_app/lib/main_20260615223308.dart | 2395 ++++++++++++++ .../mobile_app/lib/main_20260615223311.dart | 2395 ++++++++++++++ .../mobile_app/lib/main_20260615223346.dart | 2395 ++++++++++++++ .../mobile_app/lib/main_20260615223416.dart | 2395 ++++++++++++++ .../mobile_app/lib/main_20260615223418.dart | 2395 ++++++++++++++ .../mobile_app/lib/main_20260615223432.dart | 2395 ++++++++++++++ .../mobile_app/lib/main_20260615223437.dart | 2395 ++++++++++++++ .../mobile_app/lib/main_20260615223442.dart | 2395 ++++++++++++++ .../mobile_app/lib/main_20260615223449.dart | 2395 ++++++++++++++ .../mobile_app/lib/main_20260618122315.dart | 2777 +++++++++++++++++ .../mobile_app/lib/main_20260620005846.dart | 2777 +++++++++++++++++ .../mobile_app/lib/main_20260620010031.dart | 2777 +++++++++++++++++ .../mobile_app/lib/main_20260620010032.dart | 2777 +++++++++++++++++ .../mobile_app/lib/main_20260620010035.dart | 2777 +++++++++++++++++ .../mobile_app/lib/main_20260620010042.dart | 2777 +++++++++++++++++ .../mobile_app/lib/main_20260620010044.dart | 2777 +++++++++++++++++ .../mobile_app/lib/main_20260620010045.dart | 2777 +++++++++++++++++ .../mobile_app/lib/main_20260620010046.dart | 2777 +++++++++++++++++ .../mobile_app/lib/main_20260621041203.dart | 21 + .history/cv_web/app_20260615001457.py | 600 ++++ .history/cv_web/app_20260615001816.py | 600 ++++ .history/cv_web/app_20260615001819.py | 600 ++++ .history/cv_web/app_20260615001824.py | 600 ++++ .history/cv_web/app_20260615001825.py | 600 ++++ .history/cv_web/app_20260615001828.py | 600 ++++ .history/cv_web/app_20260615001833.py | 600 ++++ .history/cv_web/app_20260615001835.py | 600 ++++ .history/cv_web/app_20260615003441.py | 600 ++++ .history/cv_web/app_20260615003446.py | 600 ++++ .history/cv_web/app_20260615003447.py | 600 ++++ .history/cv_web/app_20260618000217.py | 600 ++++ .history/cv_web/app_20260618000256.py | 600 ++++ .history/cv_web/app_20260618014739.py | 1416 +++++++++ .history/cv_web/app_20260618014757.py | 1416 +++++++++ .history/cv_web/app_20260618014941.py | 1426 +++++++++ .history/cv_web/app_20260618014956.py | 1416 +++++++++ .history/cv_web/app_20260618015004.py | 1426 +++++++++ .history/cv_web/app_20260618015018.py | 1416 +++++++++ .history/cv_web/app_20260618021000.py | 1981 ++++++++++++ .history/cv_web/app_20260618021004.py | 1981 ++++++++++++ .history/cv_web/app_20260618021006.py | 1981 ++++++++++++ .history/cv_web/app_20260618021008.py | 1981 ++++++++++++ .history/cv_web/app_20260618021109.py | 1981 ++++++++++++ .history/cv_web/app_20260618021131.py | 1981 ++++++++++++ .history/cv_web/app_20260618021132.py | 1981 ++++++++++++ .history/cv_web/app_20260618021134.py | 1981 ++++++++++++ .history/cv_web/app_20260618021137.py | 1981 ++++++++++++ .history/cv_web/app_20260618021139.py | 1981 ++++++++++++ .history/cv_web/app_20260618021141.py | 1981 ++++++++++++ .history/cv_web/app_20260618021143.py | 1981 ++++++++++++ .history/cv_web/app_20260618021209.py | 1981 ++++++++++++ .history/cv_web/app_20260618021216.py | 1981 ++++++++++++ .history/cv_web/app_20260618021420.py | 1981 ++++++++++++ .history/cv_web/app_20260618021440.py | 1981 ++++++++++++ .history/cv_web/app_20260618021614.py | 1981 ++++++++++++ .history/cv_web/app_20260618021620.py | 1975 ++++++++++++ .history/cv_web/app_20260618021623.py | 1981 ++++++++++++ .history/cv_web/app_20260618021625.py | 1975 ++++++++++++ .history/cv_web/app_20260618021629.py | 1981 ++++++++++++ .history/cv_web/app_20260618021632.py | 1975 ++++++++++++ .history/cv_web/app_20260618021634.py | 1981 ++++++++++++ .history/cv_web/app_20260618021639.py | 1975 ++++++++++++ .history/cv_web/app_20260618031442.py | 1975 ++++++++++++ .history/cv_web/app_20260622073911.py | 1993 ++++++++++++ .../project_svm_suara/app_20260524001201.py | 120 + .../project_svm_suara/app_20260524002356.py | 121 + .../project_svm_suara/app_20260524002543.py | 116 + .../project_svm_suara/app_20260524002610.py | 113 + .../project_svm_suara/app_20260524005657.py | 145 + .../project_svm_suara/app_20260524005709.py | 144 + .../project_svm_suara/app_20260524005725.py | 145 + .../project_svm_suara/app_20260524005728.py | 144 + .../project_svm_suara/app_20260524005741.py | 144 + .../project_svm_suara/app_20260524005752.py | 144 + .../project_svm_suara/app_20260524005804.py | 144 + .../project_svm_suara/app_20260524005816.py | 144 + .../project_svm_suara/app_20260524005826.py | 144 + .../project_svm_suara/app_20260524005833.py | 144 + .../project_svm_suara/app_20260524005852.py | 144 + .../project_svm_suara/app_20260524005856.py | 144 + .../project_svm_suara/app_20260524005946.py | 139 + .../project_svm_suara/app_20260524005949.py | 144 + .../project_svm_suara/app_20260524005955.py | 137 + .../project_svm_suara/app_20260524010002.py | 138 + .../project_svm_suara/app_20260524010003.py | 144 + .../project_svm_suara/app_20260524010330.py | 146 + .../project_svm_suara/app_20260524010333.py | 145 + .../project_svm_suara/app_20260524010359.py | 144 + .../project_svm_suara/app_20260524010406.py | 140 + .../project_svm_suara/app_20260524010408.py | 145 + .../project_svm_suara/app_20260524010409.py | 140 + .../project_svm_suara/app_20260524010411.py | 140 + .../project_svm_suara/app_20260524010412.py | 146 + .../project_svm_suara/app_20260524010415.py | 144 + .../project_svm_suara/app_20260524010658.py | 146 + .../project_svm_suara/app_20260524010707.py | 147 + .../project_svm_suara/app_20260524010708.py | 147 + .../project_svm_suara/app_20260524010710.py | 146 + .../project_svm_suara/app_20260524010712.py | 144 + .../project_svm_suara/app_20260524010723.py | 137 + .../project_svm_suara/app_20260524010725.py | 137 + .../project_svm_suara/app_20260524010727.py | 145 + .../project_svm_suara/app_20260524010731.py | 145 + .../project_svm_suara/app_20260524010733.py | 145 + .../project_svm_suara/app_20260524010736.py | 145 + .../project_svm_suara/app_20260524010737.py | 145 + .../project_svm_suara/app_20260524010739.py | 145 + .../project_svm_suara/app_20260524010743.py | 145 + .../project_svm_suara/app_20260524010746.py | 145 + .../project_svm_suara/app_20260524010749.py | 145 + .../project_svm_suara/app_20260524010751.py | 145 + .../project_svm_suara/app_20260524010753.py | 145 + .../project_svm_suara/app_20260524010756.py | 145 + .../features_20260524003158.py | 267 ++ .../features_20260524031911.py | 267 ++ README.md | 144 + cv_app/README.md | 122 + cv_app/api_app.py | 25 + cv_app/app.py | 267 ++ cv_app/audio_utils_app.py | 86 + cv_app/database.py | 285 ++ cv_app/mobile_app/.gitignore | 45 + cv_app/mobile_app/.metadata | 30 + cv_app/mobile_app/README.md | 88 + cv_app/mobile_app/analysis_options.yaml | 28 + cv_app/mobile_app/android/.gitignore | 14 + .../mobile_app/android/app/build.gradle.kts | 44 + .../android/app/src/debug/AndroidManifest.xml | 7 + .../android/app/src/main/AndroidManifest.xml | 51 + .../com/confivoice/mobile/MainActivity.kt | 5 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 7409 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 3677 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 12474 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 25833 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 43495 bytes .../app/src/main/res/values-night/styles.xml | 18 + .../app/src/main/res/values/styles.xml | 18 + .../app/src/profile/AndroidManifest.xml | 7 + cv_app/mobile_app/android/build.gradle.kts | 21 + cv_app/mobile_app/android/gradle.properties | 3 + .../gradle/wrapper/gradle-wrapper.properties | 5 + cv_app/mobile_app/android/settings.gradle.kts | 25 + cv_app/mobile_app/devtools_options.yaml | 3 + cv_app/mobile_app/lib/app/app.dart | 143 + cv_app/mobile_app/lib/audio_source.dart | 3 + cv_app/mobile_app/lib/audio_source_io.dart | 74 + cv_app/mobile_app/lib/audio_source_stub.dart | 28 + cv_app/mobile_app/lib/audio_source_web.dart | 76 + cv_app/mobile_app/lib/fitur/auth/auth.dart | 707 +++++ .../lib/fitur/home/confivoice_shell.dart | 790 +++++ .../lib/fitur/prediction/prediction_page.dart | 950 ++++++ .../saved_analyses/saved_analyses_page.dart | 219 ++ cv_app/mobile_app/lib/main.dart | 36 + .../lib/models/analysis_models.dart | 138 + cv_app/mobile_app/pubspec.lock | 466 +++ cv_app/mobile_app/pubspec.yaml | 91 + cv_app/mobile_app/test/widget_test.dart | 11 + cv_app/mobile_app/web/favicon.png | Bin 0 -> 917 bytes cv_app/mobile_app/web/icons/Icon-192.png | Bin 0 -> 5292 bytes cv_app/mobile_app/web/icons/Icon-512.png | Bin 0 -> 8252 bytes .../web/icons/Icon-maskable-192.png | Bin 0 -> 5594 bytes .../web/icons/Icon-maskable-512.png | Bin 0 -> 20998 bytes cv_app/mobile_app/web/index.html | 38 + cv_app/mobile_app/web/manifest.json | 35 + cv_app/predict_app.py | 173 + cv_app/requirements.txt | 16 + cv_web/README.md | 69 + cv_web/app.py | 1993 ++++++++++++ cv_web/mysql.env.example | 6 + cv_web/schema_mysql.sql | 34 + .../Pengujian_Blackbox_ConfiVoice.html | 387 +++ .../check/Pengujian_Blackbox_ConfiVoice.txt | 176 ++ docs/diagrams/ERD(1M).drawio.png | Bin 0 -> 125853 bytes docs/diagrams/ERD.drawio.png | Bin 0 -> 124391 bytes docs/diagrams/Flowchart_app.drawio.png | Bin 0 -> 161990 bytes docs/diagrams/Flowchart_web_admin.drawio.png | Bin 0 -> 133861 bytes docs/diagrams/Hyperplane SVM.JPEG | Bin 0 -> 315474 bytes docs/diagrams/Pengenalan Suara.JPEG | Bin 0 -> 183340 bytes docs/diagrams/Use_Case_cv.png | Bin 0 -> 117648 bytes docs/gambar/Waterfall.drawio.png | Bin 0 -> 40707 bytes docs/gambar/dfd_confivoice.png | Bin 0 -> 192371 bytes docs/gambar/dfd_confivoice.svg | 6 + docs/ringkasan_model_metode.md | 21 + ml/.gitignore | 5 + .../Untitled-checkpoint.ipynb | 6 + ml/.ipynb_checkpoints/app-checkpoint.py | 312 ++ .../train_model-checkpoint.py | 168 + ml/.python-version | 1 + ml/.streamlit/config.toml | 5 + ml/README.md | 86 + ml/Untitled.ipynb | 866 +++++ ml/api_app.py | 181 ++ ml/app.py | 312 ++ ml/audio_utils.py | 83 + ml/audio_utils_api.py | 99 + ml/data/a_pd.wav | Bin 0 -> 751848 bytes ml/data/a_tpd.wav | Bin 0 -> 409140 bytes ml/data/b_pd.wav | Bin 0 -> 445370 bytes ml/data/b_tpd.wav | Bin 0 -> 419742 bytes ml/data/c_pd.wav | Bin 0 -> 408426 bytes ml/data/c_tpd.wav | Bin 0 -> 418038 bytes ml/data/d_pd.wav | Bin 0 -> 416178 bytes ml/data/d_tpd.wav | Bin 0 -> 368136 bytes ml/data/e_pd.wav | Bin 0 -> 443404 bytes ml/data/e_tpd.wav | Bin 0 -> 372936 bytes ml/data/f_pd.wav | Bin 0 -> 441366 bytes ml/data/f_tpd.wav | Bin 0 -> 379318 bytes ml/data/g_pd.wav | Bin 0 -> 408110 bytes ml/data/g_tpd.wav | Bin 0 -> 385770 bytes ml/data/h_pd.wav | Bin 0 -> 363836 bytes ml/data/h_tpd.wav | Bin 0 -> 389468 bytes ml/data/i_pd.wav | Bin 0 -> 367582 bytes ml/data/i_tpd.wav | Bin 0 -> 351570 bytes ml/data/j_pd.wav | Bin 0 -> 405910 bytes ml/data/j_tpd.wav | Bin 0 -> 377100 bytes ml/data/k_pd.wav | Bin 0 -> 367540 bytes ml/data/k_tpd.wav | Bin 0 -> 383564 bytes ml/data/l_pd.wav | Bin 0 -> 409330 bytes ml/data/l_tpd.wav | Bin 0 -> 383712 bytes ml/data/m_pd.wav | Bin 0 -> 353688 bytes ml/data/m_tpd.wav | Bin 0 -> 353656 bytes ml/data/n_pd.wav | Bin 0 -> 403738 bytes ml/data/n_tpd.wav | Bin 0 -> 403722 bytes ml/data/o_pd.wav | Bin 0 -> 393116 bytes ml/data/o_tpd.wav | Bin 0 -> 367502 bytes ml/data/p10_pd.wav | Bin 0 -> 221299 bytes ml/data/p10_tpd.wav | Bin 0 -> 190279 bytes ml/data/p11_pd.wav | Bin 0 -> 204380 bytes ml/data/p11_tpd.wav | Bin 0 -> 214313 bytes ml/data/p1_pd.wav | Bin 0 -> 178791 bytes ml/data/p1_tpd.wav | Bin 0 -> 202824 bytes ml/data/p2_pd.wav | Bin 0 -> 208360 bytes ml/data/p2_tpd.wav | Bin 0 -> 219825 bytes ml/data/p3_pd.wav | Bin 0 -> 234673 bytes ml/data/p3_tpd.wav | Bin 0 -> 191047 bytes ml/data/p4_pd.wav | Bin 0 -> 166979 bytes ml/data/p4_tpd.wav | Bin 0 -> 221198 bytes ml/data/p5_pd.wav | Bin 0 -> 164595 bytes ml/data/p5_tpd.wav | Bin 0 -> 195839 bytes ml/data/p6_pd.wav | Bin 0 -> 209121 bytes ml/data/p6_tpd.wav | Bin 0 -> 183179 bytes ml/data/p7_pd.wav | Bin 0 -> 210206 bytes ml/data/p7_tpd.wav | Bin 0 -> 213152 bytes ml/data/p8_pd.wav | Bin 0 -> 182142 bytes ml/data/p8_tpd.wav | Bin 0 -> 176158 bytes ml/data/p9_pd.wav | Bin 0 -> 216977 bytes ml/data/p9_tpd.wav | Bin 0 -> 177004 bytes ml/data/p_pd.wav | Bin 0 -> 431558 bytes ml/data/p_tpd.wav | Bin 0 -> 396326 bytes ml/data/q_pd.wav | Bin 0 -> 389388 bytes ml/data/q_tpd.wav | Bin 0 -> 386182 bytes ml/data/r_pd.wav | Bin 0 -> 386588 bytes ml/data/r_tpd.wav | Bin 0 -> 386582 bytes ml/data/s_pd.wav | Bin 0 -> 400464 bytes ml/data/s_tpd.wav | Bin 0 -> 384454 bytes ml/data/t_pd.wav | Bin 0 -> 402702 bytes ml/data/t_tpd.wav | Bin 0 -> 899200 bytes ml/data/u_pd.wav | Bin 0 -> 443674 bytes ml/data/u_tpd.wav | Bin 0 -> 421282 bytes ml/data/v_pd.wav | Bin 0 -> 400048 bytes ml/data/v_tpd.wav | Bin 0 -> 404418 bytes ml/data/w1_pd.wav | Bin 0 -> 222898 bytes ml/data/w1_tpd.wav | Bin 0 -> 240646 bytes ml/data/w2_pd.wav | Bin 0 -> 212441 bytes ml/data/w2_tpd.wav | Bin 0 -> 202488 bytes ml/data/w3_pd.wav | Bin 0 -> 200737 bytes ml/data/w3_tpd.wav | Bin 0 -> 218773 bytes ml/data/w4_pd.wav | Bin 0 -> 184596 bytes ml/data/w4_tpd.wav | Bin 0 -> 206769 bytes ml/data/w5_pd.wav | Bin 0 -> 207068 bytes ml/data/w5_tpd.wav | Bin 0 -> 247775 bytes ml/data/w6_pd.wav | Bin 0 -> 196429 bytes ml/data/w6_tpd.wav | Bin 0 -> 223097 bytes ml/data/w7_pd.wav | Bin 0 -> 235632 bytes ml/data/w7_tpd.wav | Bin 0 -> 187343 bytes ml/data/w8_pd.wav | Bin 0 -> 185532 bytes ml/data/w8_tpd.wav | Bin 0 -> 215712 bytes ml/data/w_pd.wav | Bin 0 -> 447946 bytes ml/data/w_tpd.wav | Bin 0 -> 367878 bytes ml/data/x_pd.wav | Bin 0 -> 392740 bytes ml/data/x_tpd.wav | Bin 0 -> 447276 bytes ml/database.py | 681 ++++ ml/evaluasi_model.txt | 118 + ml/features.py | 549 ++++ ml/migrate_sqlite_to_mysql.py | 68 + ml/models/svm_voice_confidence_model.joblib | Bin 0 -> 56154 bytes ml/predict.py | 88 + ml/predict_api.py | 162 + ml/requirements.txt | 14 + ml/train_model.py | 168 + tools/create_blackbox_docx.py | 240 ++ tools/create_dfd_image.py | 260 ++ tools/patch_report_ooxml.py | 214 ++ tools/rebuild_bab4_bab5_ooxml.py | 418 +++ tools/revise_report_docx_html.py | 486 +++ 339 files changed, 184610 insertions(+) create mode 100644 .gitignore create mode 100644 .history/cv_app/mobile_app/android/app/src/main/AndroidManifest_20260608222936.xml create mode 100644 .history/cv_app/mobile_app/android/app/src/main/AndroidManifest_20260626234244.xml create mode 100644 .history/cv_app/mobile_app/lib/copilot_20260701235309.dart create mode 100644 .history/cv_app/mobile_app/lib/copilot_20260701235342.dart create mode 100644 .history/cv_app/mobile_app/lib/copilot_20260701235642.dart create mode 100644 .history/cv_app/mobile_app/lib/copilot_20260701235646.dart create mode 100644 .history/cv_app/mobile_app/lib/copilot_20260701235650.dart create mode 100644 .history/cv_app/mobile_app/lib/copilot_20260701235651.dart create mode 100644 .history/cv_app/mobile_app/lib/copilot_20260702000412.dart create mode 100644 .history/cv_app/mobile_app/lib/fitur/auth/auth_20260621154154.dart create mode 100644 .history/cv_app/mobile_app/lib/fitur/auth/auth_20260623225159.dart create mode 100644 .history/cv_app/mobile_app/lib/fitur/auth/auth_20260623225315.dart create mode 100644 .history/cv_app/mobile_app/lib/fitur/auth/auth_20260624054320.dart create mode 100644 .history/cv_app/mobile_app/lib/fitur/auth/auth_20260624223837.dart create mode 100644 .history/cv_app/mobile_app/lib/fitur/auth/auth_20260701235026.dart create mode 100644 .history/cv_app/mobile_app/lib/fitur/auth/auth_20260701235027.dart create mode 100644 .history/cv_app/mobile_app/lib/fitur/auth/auth_20260701235256.dart create mode 100644 .history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621152900.dart create mode 100644 .history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155257.dart create mode 100644 .history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155258.dart create mode 100644 .history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155259.dart create mode 100644 .history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155303.dart create mode 100644 .history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155311.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260615201942.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260615202635.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260615202727.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260615204019.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260615204022.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260615204035.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260615204040.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260615204048.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260615204050.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260615204051.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260615204054.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260615204139.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260615204209.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260615204211.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260615204214.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260615204218.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260615204221.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260615223258.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260615223308.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260615223311.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260615223346.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260615223416.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260615223418.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260615223432.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260615223437.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260615223442.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260615223449.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260618122315.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260620005846.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260620010031.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260620010032.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260620010035.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260620010042.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260620010044.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260620010045.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260620010046.dart create mode 100644 .history/cv_app/mobile_app/lib/main_20260621041203.dart create mode 100644 .history/cv_web/app_20260615001457.py create mode 100644 .history/cv_web/app_20260615001816.py create mode 100644 .history/cv_web/app_20260615001819.py create mode 100644 .history/cv_web/app_20260615001824.py create mode 100644 .history/cv_web/app_20260615001825.py create mode 100644 .history/cv_web/app_20260615001828.py create mode 100644 .history/cv_web/app_20260615001833.py create mode 100644 .history/cv_web/app_20260615001835.py create mode 100644 .history/cv_web/app_20260615003441.py create mode 100644 .history/cv_web/app_20260615003446.py create mode 100644 .history/cv_web/app_20260615003447.py create mode 100644 .history/cv_web/app_20260618000217.py create mode 100644 .history/cv_web/app_20260618000256.py create mode 100644 .history/cv_web/app_20260618014739.py create mode 100644 .history/cv_web/app_20260618014757.py create mode 100644 .history/cv_web/app_20260618014941.py create mode 100644 .history/cv_web/app_20260618014956.py create mode 100644 .history/cv_web/app_20260618015004.py create mode 100644 .history/cv_web/app_20260618015018.py create mode 100644 .history/cv_web/app_20260618021000.py create mode 100644 .history/cv_web/app_20260618021004.py create mode 100644 .history/cv_web/app_20260618021006.py create mode 100644 .history/cv_web/app_20260618021008.py create mode 100644 .history/cv_web/app_20260618021109.py create mode 100644 .history/cv_web/app_20260618021131.py create mode 100644 .history/cv_web/app_20260618021132.py create mode 100644 .history/cv_web/app_20260618021134.py create mode 100644 .history/cv_web/app_20260618021137.py create mode 100644 .history/cv_web/app_20260618021139.py create mode 100644 .history/cv_web/app_20260618021141.py create mode 100644 .history/cv_web/app_20260618021143.py create mode 100644 .history/cv_web/app_20260618021209.py create mode 100644 .history/cv_web/app_20260618021216.py create mode 100644 .history/cv_web/app_20260618021420.py create mode 100644 .history/cv_web/app_20260618021440.py create mode 100644 .history/cv_web/app_20260618021614.py create mode 100644 .history/cv_web/app_20260618021620.py create mode 100644 .history/cv_web/app_20260618021623.py create mode 100644 .history/cv_web/app_20260618021625.py create mode 100644 .history/cv_web/app_20260618021629.py create mode 100644 .history/cv_web/app_20260618021632.py create mode 100644 .history/cv_web/app_20260618021634.py create mode 100644 .history/cv_web/app_20260618021639.py create mode 100644 .history/cv_web/app_20260618031442.py create mode 100644 .history/cv_web/app_20260622073911.py create mode 100644 .history/project_svm_suara/app_20260524001201.py create mode 100644 .history/project_svm_suara/app_20260524002356.py create mode 100644 .history/project_svm_suara/app_20260524002543.py create mode 100644 .history/project_svm_suara/app_20260524002610.py create mode 100644 .history/project_svm_suara/app_20260524005657.py create mode 100644 .history/project_svm_suara/app_20260524005709.py create mode 100644 .history/project_svm_suara/app_20260524005725.py create mode 100644 .history/project_svm_suara/app_20260524005728.py create mode 100644 .history/project_svm_suara/app_20260524005741.py create mode 100644 .history/project_svm_suara/app_20260524005752.py create mode 100644 .history/project_svm_suara/app_20260524005804.py create mode 100644 .history/project_svm_suara/app_20260524005816.py create mode 100644 .history/project_svm_suara/app_20260524005826.py create mode 100644 .history/project_svm_suara/app_20260524005833.py create mode 100644 .history/project_svm_suara/app_20260524005852.py create mode 100644 .history/project_svm_suara/app_20260524005856.py create mode 100644 .history/project_svm_suara/app_20260524005946.py create mode 100644 .history/project_svm_suara/app_20260524005949.py create mode 100644 .history/project_svm_suara/app_20260524005955.py create mode 100644 .history/project_svm_suara/app_20260524010002.py create mode 100644 .history/project_svm_suara/app_20260524010003.py create mode 100644 .history/project_svm_suara/app_20260524010330.py create mode 100644 .history/project_svm_suara/app_20260524010333.py create mode 100644 .history/project_svm_suara/app_20260524010359.py create mode 100644 .history/project_svm_suara/app_20260524010406.py create mode 100644 .history/project_svm_suara/app_20260524010408.py create mode 100644 .history/project_svm_suara/app_20260524010409.py create mode 100644 .history/project_svm_suara/app_20260524010411.py create mode 100644 .history/project_svm_suara/app_20260524010412.py create mode 100644 .history/project_svm_suara/app_20260524010415.py create mode 100644 .history/project_svm_suara/app_20260524010658.py create mode 100644 .history/project_svm_suara/app_20260524010707.py create mode 100644 .history/project_svm_suara/app_20260524010708.py create mode 100644 .history/project_svm_suara/app_20260524010710.py create mode 100644 .history/project_svm_suara/app_20260524010712.py create mode 100644 .history/project_svm_suara/app_20260524010723.py create mode 100644 .history/project_svm_suara/app_20260524010725.py create mode 100644 .history/project_svm_suara/app_20260524010727.py create mode 100644 .history/project_svm_suara/app_20260524010731.py create mode 100644 .history/project_svm_suara/app_20260524010733.py create mode 100644 .history/project_svm_suara/app_20260524010736.py create mode 100644 .history/project_svm_suara/app_20260524010737.py create mode 100644 .history/project_svm_suara/app_20260524010739.py create mode 100644 .history/project_svm_suara/app_20260524010743.py create mode 100644 .history/project_svm_suara/app_20260524010746.py create mode 100644 .history/project_svm_suara/app_20260524010749.py create mode 100644 .history/project_svm_suara/app_20260524010751.py create mode 100644 .history/project_svm_suara/app_20260524010753.py create mode 100644 .history/project_svm_suara/app_20260524010756.py create mode 100644 .history/project_svm_suara/features_20260524003158.py create mode 100644 .history/project_svm_suara/features_20260524031911.py create mode 100644 README.md create mode 100644 cv_app/README.md create mode 100644 cv_app/api_app.py create mode 100644 cv_app/app.py create mode 100644 cv_app/audio_utils_app.py create mode 100644 cv_app/database.py create mode 100644 cv_app/mobile_app/.gitignore create mode 100644 cv_app/mobile_app/.metadata create mode 100644 cv_app/mobile_app/README.md create mode 100644 cv_app/mobile_app/analysis_options.yaml create mode 100644 cv_app/mobile_app/android/.gitignore create mode 100644 cv_app/mobile_app/android/app/build.gradle.kts create mode 100644 cv_app/mobile_app/android/app/src/debug/AndroidManifest.xml create mode 100644 cv_app/mobile_app/android/app/src/main/AndroidManifest.xml create mode 100644 cv_app/mobile_app/android/app/src/main/kotlin/com/confivoice/mobile/MainActivity.kt create mode 100644 cv_app/mobile_app/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 cv_app/mobile_app/android/app/src/main/res/drawable/launch_background.xml create mode 100644 cv_app/mobile_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 cv_app/mobile_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 cv_app/mobile_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 cv_app/mobile_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 cv_app/mobile_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 cv_app/mobile_app/android/app/src/main/res/values-night/styles.xml create mode 100644 cv_app/mobile_app/android/app/src/main/res/values/styles.xml create mode 100644 cv_app/mobile_app/android/app/src/profile/AndroidManifest.xml create mode 100644 cv_app/mobile_app/android/build.gradle.kts create mode 100644 cv_app/mobile_app/android/gradle.properties create mode 100644 cv_app/mobile_app/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 cv_app/mobile_app/android/settings.gradle.kts create mode 100644 cv_app/mobile_app/devtools_options.yaml create mode 100644 cv_app/mobile_app/lib/app/app.dart create mode 100644 cv_app/mobile_app/lib/audio_source.dart create mode 100644 cv_app/mobile_app/lib/audio_source_io.dart create mode 100644 cv_app/mobile_app/lib/audio_source_stub.dart create mode 100644 cv_app/mobile_app/lib/audio_source_web.dart create mode 100644 cv_app/mobile_app/lib/fitur/auth/auth.dart create mode 100644 cv_app/mobile_app/lib/fitur/home/confivoice_shell.dart create mode 100644 cv_app/mobile_app/lib/fitur/prediction/prediction_page.dart create mode 100644 cv_app/mobile_app/lib/fitur/saved_analyses/saved_analyses_page.dart create mode 100644 cv_app/mobile_app/lib/main.dart create mode 100644 cv_app/mobile_app/lib/models/analysis_models.dart create mode 100644 cv_app/mobile_app/pubspec.lock create mode 100644 cv_app/mobile_app/pubspec.yaml create mode 100644 cv_app/mobile_app/test/widget_test.dart create mode 100644 cv_app/mobile_app/web/favicon.png create mode 100644 cv_app/mobile_app/web/icons/Icon-192.png create mode 100644 cv_app/mobile_app/web/icons/Icon-512.png create mode 100644 cv_app/mobile_app/web/icons/Icon-maskable-192.png create mode 100644 cv_app/mobile_app/web/icons/Icon-maskable-512.png create mode 100644 cv_app/mobile_app/web/index.html create mode 100644 cv_app/mobile_app/web/manifest.json create mode 100644 cv_app/predict_app.py create mode 100644 cv_app/requirements.txt create mode 100644 cv_web/README.md create mode 100644 cv_web/app.py create mode 100644 cv_web/mysql.env.example create mode 100644 cv_web/schema_mysql.sql create mode 100644 docs/blackbox/Pengujian_Blackbox_ConfiVoice.html create mode 100644 docs/blackbox/check/Pengujian_Blackbox_ConfiVoice.txt create mode 100644 docs/diagrams/ERD(1M).drawio.png create mode 100644 docs/diagrams/ERD.drawio.png create mode 100644 docs/diagrams/Flowchart_app.drawio.png create mode 100644 docs/diagrams/Flowchart_web_admin.drawio.png create mode 100644 docs/diagrams/Hyperplane SVM.JPEG create mode 100644 docs/diagrams/Pengenalan Suara.JPEG create mode 100644 docs/diagrams/Use_Case_cv.png create mode 100644 docs/gambar/Waterfall.drawio.png create mode 100644 docs/gambar/dfd_confivoice.png create mode 100644 docs/gambar/dfd_confivoice.svg create mode 100644 docs/ringkasan_model_metode.md create mode 100644 ml/.gitignore create mode 100644 ml/.ipynb_checkpoints/Untitled-checkpoint.ipynb create mode 100644 ml/.ipynb_checkpoints/app-checkpoint.py create mode 100644 ml/.ipynb_checkpoints/train_model-checkpoint.py create mode 100644 ml/.python-version create mode 100644 ml/.streamlit/config.toml create mode 100644 ml/README.md create mode 100644 ml/Untitled.ipynb create mode 100644 ml/api_app.py create mode 100644 ml/app.py create mode 100644 ml/audio_utils.py create mode 100644 ml/audio_utils_api.py create mode 100644 ml/data/a_pd.wav create mode 100644 ml/data/a_tpd.wav create mode 100644 ml/data/b_pd.wav create mode 100644 ml/data/b_tpd.wav create mode 100644 ml/data/c_pd.wav create mode 100644 ml/data/c_tpd.wav create mode 100644 ml/data/d_pd.wav create mode 100644 ml/data/d_tpd.wav create mode 100644 ml/data/e_pd.wav create mode 100644 ml/data/e_tpd.wav create mode 100644 ml/data/f_pd.wav create mode 100644 ml/data/f_tpd.wav create mode 100644 ml/data/g_pd.wav create mode 100644 ml/data/g_tpd.wav create mode 100644 ml/data/h_pd.wav create mode 100644 ml/data/h_tpd.wav create mode 100644 ml/data/i_pd.wav create mode 100644 ml/data/i_tpd.wav create mode 100644 ml/data/j_pd.wav create mode 100644 ml/data/j_tpd.wav create mode 100644 ml/data/k_pd.wav create mode 100644 ml/data/k_tpd.wav create mode 100644 ml/data/l_pd.wav create mode 100644 ml/data/l_tpd.wav create mode 100644 ml/data/m_pd.wav create mode 100644 ml/data/m_tpd.wav create mode 100644 ml/data/n_pd.wav create mode 100644 ml/data/n_tpd.wav create mode 100644 ml/data/o_pd.wav create mode 100644 ml/data/o_tpd.wav create mode 100644 ml/data/p10_pd.wav create mode 100644 ml/data/p10_tpd.wav create mode 100644 ml/data/p11_pd.wav create mode 100644 ml/data/p11_tpd.wav create mode 100644 ml/data/p1_pd.wav create mode 100644 ml/data/p1_tpd.wav create mode 100644 ml/data/p2_pd.wav create mode 100644 ml/data/p2_tpd.wav create mode 100644 ml/data/p3_pd.wav create mode 100644 ml/data/p3_tpd.wav create mode 100644 ml/data/p4_pd.wav create mode 100644 ml/data/p4_tpd.wav create mode 100644 ml/data/p5_pd.wav create mode 100644 ml/data/p5_tpd.wav create mode 100644 ml/data/p6_pd.wav create mode 100644 ml/data/p6_tpd.wav create mode 100644 ml/data/p7_pd.wav create mode 100644 ml/data/p7_tpd.wav create mode 100644 ml/data/p8_pd.wav create mode 100644 ml/data/p8_tpd.wav create mode 100644 ml/data/p9_pd.wav create mode 100644 ml/data/p9_tpd.wav create mode 100644 ml/data/p_pd.wav create mode 100644 ml/data/p_tpd.wav create mode 100644 ml/data/q_pd.wav create mode 100644 ml/data/q_tpd.wav create mode 100644 ml/data/r_pd.wav create mode 100644 ml/data/r_tpd.wav create mode 100644 ml/data/s_pd.wav create mode 100644 ml/data/s_tpd.wav create mode 100644 ml/data/t_pd.wav create mode 100644 ml/data/t_tpd.wav create mode 100644 ml/data/u_pd.wav create mode 100644 ml/data/u_tpd.wav create mode 100644 ml/data/v_pd.wav create mode 100644 ml/data/v_tpd.wav create mode 100644 ml/data/w1_pd.wav create mode 100644 ml/data/w1_tpd.wav create mode 100644 ml/data/w2_pd.wav create mode 100644 ml/data/w2_tpd.wav create mode 100644 ml/data/w3_pd.wav create mode 100644 ml/data/w3_tpd.wav create mode 100644 ml/data/w4_pd.wav create mode 100644 ml/data/w4_tpd.wav create mode 100644 ml/data/w5_pd.wav create mode 100644 ml/data/w5_tpd.wav create mode 100644 ml/data/w6_pd.wav create mode 100644 ml/data/w6_tpd.wav create mode 100644 ml/data/w7_pd.wav create mode 100644 ml/data/w7_tpd.wav create mode 100644 ml/data/w8_pd.wav create mode 100644 ml/data/w8_tpd.wav create mode 100644 ml/data/w_pd.wav create mode 100644 ml/data/w_tpd.wav create mode 100644 ml/data/x_pd.wav create mode 100644 ml/data/x_tpd.wav create mode 100644 ml/database.py create mode 100644 ml/evaluasi_model.txt create mode 100644 ml/features.py create mode 100644 ml/migrate_sqlite_to_mysql.py create mode 100644 ml/models/svm_voice_confidence_model.joblib create mode 100644 ml/predict.py create mode 100644 ml/predict_api.py create mode 100644 ml/requirements.txt create mode 100644 ml/train_model.py create mode 100644 tools/create_blackbox_docx.py create mode 100644 tools/create_dfd_image.py create mode 100644 tools/patch_report_ooxml.py create mode 100644 tools/rebuild_bab4_bab5_ooxml.py create mode 100644 tools/revise_report_docx_html.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..01fc373 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.DS_Store +__pycache__/ +*.py[cod] +.venv/ +.streamlit/secrets.toml +build/ +.dart_tool/ +.gradle/ +*.iml +cv_app/mobile_app/build/ +cv_app/mobile_app/.dart_tool/ +cv_app/mobile_app/android/.gradle/ +cv_web/data/*.db +cv_app/data/*.db diff --git a/.history/cv_app/mobile_app/android/app/src/main/AndroidManifest_20260608222936.xml b/.history/cv_app/mobile_app/android/app/src/main/AndroidManifest_20260608222936.xml new file mode 100644 index 0000000..df64f9e --- /dev/null +++ b/.history/cv_app/mobile_app/android/app/src/main/AndroidManifest_20260608222936.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.history/cv_app/mobile_app/android/app/src/main/AndroidManifest_20260626234244.xml b/.history/cv_app/mobile_app/android/app/src/main/AndroidManifest_20260626234244.xml new file mode 100644 index 0000000..df64f9e --- /dev/null +++ b/.history/cv_app/mobile_app/android/app/src/main/AndroidManifest_20260626234244.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.history/cv_app/mobile_app/lib/copilot_20260701235309.dart b/.history/cv_app/mobile_app/lib/copilot_20260701235309.dart new file mode 100644 index 0000000..e69de29 diff --git a/.history/cv_app/mobile_app/lib/copilot_20260701235342.dart b/.history/cv_app/mobile_app/lib/copilot_20260701235342.dart new file mode 100644 index 0000000..53cb8ef --- /dev/null +++ b/.history/cv_app/mobile_app/lib/copilot_20260701235342.dart @@ -0,0 +1,3 @@ +class LoginScreen extends StatefulWidget { + + \ No newline at end of file diff --git a/.history/cv_app/mobile_app/lib/copilot_20260701235642.dart b/.history/cv_app/mobile_app/lib/copilot_20260701235642.dart new file mode 100644 index 0000000..e69de29 diff --git a/.history/cv_app/mobile_app/lib/copilot_20260701235646.dart b/.history/cv_app/mobile_app/lib/copilot_20260701235646.dart new file mode 100644 index 0000000..3064a65 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/copilot_20260701235646.dart @@ -0,0 +1,6 @@ +import 'package:flutter/material.dart'; + +class LoginScreen extends StatelessWidget { + @override + Widget build(BuildContext context) { + \ No newline at end of file diff --git a/.history/cv_app/mobile_app/lib/copilot_20260701235650.dart b/.history/cv_app/mobile_app/lib/copilot_20260701235650.dart new file mode 100644 index 0000000..e69de29 diff --git a/.history/cv_app/mobile_app/lib/copilot_20260701235651.dart b/.history/cv_app/mobile_app/lib/copilot_20260701235651.dart new file mode 100644 index 0000000..3064a65 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/copilot_20260701235651.dart @@ -0,0 +1,6 @@ +import 'package:flutter/material.dart'; + +class LoginScreen extends StatelessWidget { + @override + Widget build(BuildContext context) { + \ No newline at end of file diff --git a/.history/cv_app/mobile_app/lib/copilot_20260702000412.dart b/.history/cv_app/mobile_app/lib/copilot_20260702000412.dart new file mode 100644 index 0000000..e69de29 diff --git a/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260621154154.dart b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260621154154.dart new file mode 100644 index 0000000..75b7fd3 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260621154154.dart @@ -0,0 +1,707 @@ +part of '../../main.dart'; + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar(context, message: 'Berhasil logout'); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: defaultApiEndpoint(), + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = normalizeApiEndpoint( + preferences.getString(_apiEndpointPreferenceKey) ?? '', + ); + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + await _saveApiEndpoint(); + if (_isRegister) { + if (!mounted) { + return; + } + final registeredUsername = username; + setState(() { + _isRegister = false; + _fullNameController.clear(); + _usernameController.text = registeredUsername; + _passwordController.clear(); + _confirmPasswordController.clear(); + _errorMessage = null; + }); + showCompactSnackBar( + context, + message: 'Registrasi berhasil. Silakan login.', + icon: Icons.check_circle_outline, + ); + return; + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = + _isRegister + ? _cleanAuthErrorMessage(error) + : 'Username atau password salah.'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + String _cleanAuthErrorMessage(Object error) { + final message = error.toString(); + const exceptionPrefix = 'Exception: '; + return message.startsWith(exceptionPrefix) + ? message.substring(exceptionPrefix.length) + : message; + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} diff --git a/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260623225159.dart b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260623225159.dart new file mode 100644 index 0000000..75b7fd3 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260623225159.dart @@ -0,0 +1,707 @@ +part of '../../main.dart'; + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar(context, message: 'Berhasil logout'); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: defaultApiEndpoint(), + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = normalizeApiEndpoint( + preferences.getString(_apiEndpointPreferenceKey) ?? '', + ); + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + await _saveApiEndpoint(); + if (_isRegister) { + if (!mounted) { + return; + } + final registeredUsername = username; + setState(() { + _isRegister = false; + _fullNameController.clear(); + _usernameController.text = registeredUsername; + _passwordController.clear(); + _confirmPasswordController.clear(); + _errorMessage = null; + }); + showCompactSnackBar( + context, + message: 'Registrasi berhasil. Silakan login.', + icon: Icons.check_circle_outline, + ); + return; + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = + _isRegister + ? _cleanAuthErrorMessage(error) + : 'Username atau password salah.'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + String _cleanAuthErrorMessage(Object error) { + final message = error.toString(); + const exceptionPrefix = 'Exception: '; + return message.startsWith(exceptionPrefix) + ? message.substring(exceptionPrefix.length) + : message; + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} diff --git a/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260623225315.dart b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260623225315.dart new file mode 100644 index 0000000..75b7fd3 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260623225315.dart @@ -0,0 +1,707 @@ +part of '../../main.dart'; + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar(context, message: 'Berhasil logout'); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: defaultApiEndpoint(), + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = normalizeApiEndpoint( + preferences.getString(_apiEndpointPreferenceKey) ?? '', + ); + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + await _saveApiEndpoint(); + if (_isRegister) { + if (!mounted) { + return; + } + final registeredUsername = username; + setState(() { + _isRegister = false; + _fullNameController.clear(); + _usernameController.text = registeredUsername; + _passwordController.clear(); + _confirmPasswordController.clear(); + _errorMessage = null; + }); + showCompactSnackBar( + context, + message: 'Registrasi berhasil. Silakan login.', + icon: Icons.check_circle_outline, + ); + return; + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = + _isRegister + ? _cleanAuthErrorMessage(error) + : 'Username atau password salah.'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + String _cleanAuthErrorMessage(Object error) { + final message = error.toString(); + const exceptionPrefix = 'Exception: '; + return message.startsWith(exceptionPrefix) + ? message.substring(exceptionPrefix.length) + : message; + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} diff --git a/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260624054320.dart b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260624054320.dart new file mode 100644 index 0000000..75b7fd3 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260624054320.dart @@ -0,0 +1,707 @@ +part of '../../main.dart'; + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar(context, message: 'Berhasil logout'); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: defaultApiEndpoint(), + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = normalizeApiEndpoint( + preferences.getString(_apiEndpointPreferenceKey) ?? '', + ); + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + await _saveApiEndpoint(); + if (_isRegister) { + if (!mounted) { + return; + } + final registeredUsername = username; + setState(() { + _isRegister = false; + _fullNameController.clear(); + _usernameController.text = registeredUsername; + _passwordController.clear(); + _confirmPasswordController.clear(); + _errorMessage = null; + }); + showCompactSnackBar( + context, + message: 'Registrasi berhasil. Silakan login.', + icon: Icons.check_circle_outline, + ); + return; + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = + _isRegister + ? _cleanAuthErrorMessage(error) + : 'Username atau password salah.'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + String _cleanAuthErrorMessage(Object error) { + final message = error.toString(); + const exceptionPrefix = 'Exception: '; + return message.startsWith(exceptionPrefix) + ? message.substring(exceptionPrefix.length) + : message; + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} diff --git a/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260624223837.dart b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260624223837.dart new file mode 100644 index 0000000..75b7fd3 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260624223837.dart @@ -0,0 +1,707 @@ +part of '../../main.dart'; + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar(context, message: 'Berhasil logout'); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: defaultApiEndpoint(), + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = normalizeApiEndpoint( + preferences.getString(_apiEndpointPreferenceKey) ?? '', + ); + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + await _saveApiEndpoint(); + if (_isRegister) { + if (!mounted) { + return; + } + final registeredUsername = username; + setState(() { + _isRegister = false; + _fullNameController.clear(); + _usernameController.text = registeredUsername; + _passwordController.clear(); + _confirmPasswordController.clear(); + _errorMessage = null; + }); + showCompactSnackBar( + context, + message: 'Registrasi berhasil. Silakan login.', + icon: Icons.check_circle_outline, + ); + return; + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = + _isRegister + ? _cleanAuthErrorMessage(error) + : 'Username atau password salah.'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + String _cleanAuthErrorMessage(Object error) { + final message = error.toString(); + const exceptionPrefix = 'Exception: '; + return message.startsWith(exceptionPrefix) + ? message.substring(exceptionPrefix.length) + : message; + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} diff --git a/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260701235026.dart b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260701235026.dart new file mode 100644 index 0000000..cdd68f1 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260701235026.dart @@ -0,0 +1,709 @@ +part of '../../main.dart'; + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar(context, message: 'Berhasil logout'); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: defaultApiEndpoint(), + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = normalizeApiEndpoint( + preferences.getString(_apiEndpointPreferenceKey) ?? '', + ); + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + await _saveApiEndpoint(); + if (_isRegister) { + if (!mounted) { + return; + } + final registeredUsername = username; + setState(() { + _isRegister = false; + _fullNameController.clear(); + _usernameController.text = registeredUsername; + _passwordController.clear(); + _confirmPasswordController.clear(); + _errorMessage = null; + }); + showCompactSnackBar( + context, + message: 'Registrasi berhasil. Silakan login.', + icon: Icons.check_circle_outline, + ); + return; + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = + _isRegister + ? _cleanAuthErrorMessage(error) + : 'Username atau password salah.'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + String _cleanAuthErrorMessage(Object error) { + final message = error.toString(); + const exceptionPrefix = 'Exception: '; + return message.startsWith(exceptionPrefix) + ? message.substring(exceptionPrefix.length) + : message; + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +// create a login form with email and password validation \ No newline at end of file diff --git a/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260701235027.dart b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260701235027.dart new file mode 100644 index 0000000..66164f9 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260701235027.dart @@ -0,0 +1,709 @@ +part of '../../main.dart'; + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar(context, message: 'Berhasil logout'); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: defaultApiEndpoint(), + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = normalizeApiEndpoint( + preferences.getString(_apiEndpointPreferenceKey) ?? '', + ); + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + await _saveApiEndpoint(); + if (_isRegister) { + if (!mounted) { + return; + } + final registeredUsername = username; + setState(() { + _isRegister = false; + _fullNameController.clear(); + _usernameController.text = registeredUsername; + _passwordController.clear(); + _confirmPasswordController.clear(); + _errorMessage = null; + }); + showCompactSnackBar( + context, + message: 'Registrasi berhasil. Silakan login.', + icon: Icons.check_circle_outline, + ); + return; + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = + _isRegister + ? _cleanAuthErrorMessage(error) + : 'Username atau password salah.'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + String _cleanAuthErrorMessage(Object error) { + final message = error.toString(); + const exceptionPrefix = 'Exception: '; + return message.startsWith(exceptionPrefix) + ? message.substring(exceptionPrefix.length) + : message; + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +// create a login form with email and password validation diff --git a/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260701235256.dart b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260701235256.dart new file mode 100644 index 0000000..75b7fd3 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260701235256.dart @@ -0,0 +1,707 @@ +part of '../../main.dart'; + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar(context, message: 'Berhasil logout'); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: defaultApiEndpoint(), + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = normalizeApiEndpoint( + preferences.getString(_apiEndpointPreferenceKey) ?? '', + ); + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + await _saveApiEndpoint(); + if (_isRegister) { + if (!mounted) { + return; + } + final registeredUsername = username; + setState(() { + _isRegister = false; + _fullNameController.clear(); + _usernameController.text = registeredUsername; + _passwordController.clear(); + _confirmPasswordController.clear(); + _errorMessage = null; + }); + showCompactSnackBar( + context, + message: 'Registrasi berhasil. Silakan login.', + icon: Icons.check_circle_outline, + ); + return; + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = + _isRegister + ? _cleanAuthErrorMessage(error) + : 'Username atau password salah.'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + String _cleanAuthErrorMessage(Object error) { + final message = error.toString(); + const exceptionPrefix = 'Exception: '; + return message.startsWith(exceptionPrefix) + ? message.substring(exceptionPrefix.length) + : message; + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} diff --git a/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621152900.dart b/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621152900.dart new file mode 100644 index 0000000..ae4881c --- /dev/null +++ b/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621152900.dart @@ -0,0 +1,950 @@ +part of '../../main.dart'; + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.hasStoredStudentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onSelectStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final bool hasStoredStudentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onSelectStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + onSelectName: onSelectStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + isStored: hasStoredStudentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + required this.onSelectName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + final ValueChanged onSelectName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({ + required this.value, + required this.isStored, + required this.onChanged, + }); + + final String value; + final bool isStored; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + if (isStored && value.isNotEmpty) { + return InputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + child: Row( + children: [ + Expanded(child: Text(value)), + const Text( + 'Tersimpan', + style: TextStyle(color: AppColors.blueSoft), + ), + ], + ), + ); + } + + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + widget.onSelectName(name); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155257.dart b/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155257.dart new file mode 100644 index 0000000..ae4881c --- /dev/null +++ b/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155257.dart @@ -0,0 +1,950 @@ +part of '../../main.dart'; + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.hasStoredStudentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onSelectStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final bool hasStoredStudentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onSelectStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + onSelectName: onSelectStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + isStored: hasStoredStudentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + required this.onSelectName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + final ValueChanged onSelectName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({ + required this.value, + required this.isStored, + required this.onChanged, + }); + + final String value; + final bool isStored; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + if (isStored && value.isNotEmpty) { + return InputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + child: Row( + children: [ + Expanded(child: Text(value)), + const Text( + 'Tersimpan', + style: TextStyle(color: AppColors.blueSoft), + ), + ], + ), + ); + } + + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + widget.onSelectName(name); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155258.dart b/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155258.dart new file mode 100644 index 0000000..ae4881c --- /dev/null +++ b/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155258.dart @@ -0,0 +1,950 @@ +part of '../../main.dart'; + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.hasStoredStudentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onSelectStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final bool hasStoredStudentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onSelectStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + onSelectName: onSelectStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + isStored: hasStoredStudentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + required this.onSelectName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + final ValueChanged onSelectName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({ + required this.value, + required this.isStored, + required this.onChanged, + }); + + final String value; + final bool isStored; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + if (isStored && value.isNotEmpty) { + return InputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + child: Row( + children: [ + Expanded(child: Text(value)), + const Text( + 'Tersimpan', + style: TextStyle(color: AppColors.blueSoft), + ), + ], + ), + ); + } + + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + widget.onSelectName(name); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155259.dart b/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155259.dart new file mode 100644 index 0000000..ae4881c --- /dev/null +++ b/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155259.dart @@ -0,0 +1,950 @@ +part of '../../main.dart'; + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.hasStoredStudentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onSelectStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final bool hasStoredStudentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onSelectStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + onSelectName: onSelectStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + isStored: hasStoredStudentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + required this.onSelectName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + final ValueChanged onSelectName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({ + required this.value, + required this.isStored, + required this.onChanged, + }); + + final String value; + final bool isStored; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + if (isStored && value.isNotEmpty) { + return InputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + child: Row( + children: [ + Expanded(child: Text(value)), + const Text( + 'Tersimpan', + style: TextStyle(color: AppColors.blueSoft), + ), + ], + ), + ); + } + + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + widget.onSelectName(name); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155303.dart b/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155303.dart new file mode 100644 index 0000000..ae4881c --- /dev/null +++ b/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155303.dart @@ -0,0 +1,950 @@ +part of '../../main.dart'; + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.hasStoredStudentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onSelectStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final bool hasStoredStudentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onSelectStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + onSelectName: onSelectStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + isStored: hasStoredStudentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + required this.onSelectName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + final ValueChanged onSelectName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({ + required this.value, + required this.isStored, + required this.onChanged, + }); + + final String value; + final bool isStored; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + if (isStored && value.isNotEmpty) { + return InputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + child: Row( + children: [ + Expanded(child: Text(value)), + const Text( + 'Tersimpan', + style: TextStyle(color: AppColors.blueSoft), + ), + ], + ), + ); + } + + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + widget.onSelectName(name); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155311.dart b/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155311.dart new file mode 100644 index 0000000..ae4881c --- /dev/null +++ b/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155311.dart @@ -0,0 +1,950 @@ +part of '../../main.dart'; + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.hasStoredStudentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onSelectStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final bool hasStoredStudentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onSelectStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + onSelectName: onSelectStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + isStored: hasStoredStudentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + required this.onSelectName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + final ValueChanged onSelectName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({ + required this.value, + required this.isStored, + required this.onChanged, + }); + + final String value; + final bool isStored; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + if (isStored && value.isNotEmpty) { + return InputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + child: Row( + children: [ + Expanded(child: Text(value)), + const Text( + 'Tersimpan', + style: TextStyle(color: AppColors.blueSoft), + ), + ], + ), + ); + } + + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + widget.onSelectName(name); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615201942.dart b/.history/cv_app/mobile_app/lib/main_20260615201942.dart new file mode 100644 index 0000000..d4c9dc2 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615201942.dart @@ -0,0 +1,2004 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final VoidCallback onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: widget.onLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615202635.dart b/.history/cv_app/mobile_app/lib/main_20260615202635.dart new file mode 100644 index 0000000..ae902d7 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615202635.dart @@ -0,0 +1,2004 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = '$error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final VoidCallback onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: widget.onLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615202727.dart b/.history/cv_app/mobile_app/lib/main_20260615202727.dart new file mode 100644 index 0000000..d4c9dc2 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615202727.dart @@ -0,0 +1,2004 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final VoidCallback onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: widget.onLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615204019.dart b/.history/cv_app/mobile_app/lib/main_20260615204019.dart new file mode 100644 index 0000000..66e098a --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615204019.dart @@ -0,0 +1,2087 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + required IconData icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil logout', + ); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 18), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615204022.dart b/.history/cv_app/mobile_app/lib/main_20260615204022.dart new file mode 100644 index 0000000..e7e0c90 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615204022.dart @@ -0,0 +1,2088 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + required IconData icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil logout', + icon: Icons.logout, + ); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 18), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615204035.dart b/.history/cv_app/mobile_app/lib/main_20260615204035.dart new file mode 100644 index 0000000..f0e4a46 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615204035.dart @@ -0,0 +1,2088 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + required IconData icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil logout', + + ); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 18), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615204040.dart b/.history/cv_app/mobile_app/lib/main_20260615204040.dart new file mode 100644 index 0000000..e7e0c90 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615204040.dart @@ -0,0 +1,2088 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + required IconData icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil logout', + icon: Icons.logout, + ); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 18), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615204048.dart b/.history/cv_app/mobile_app/lib/main_20260615204048.dart new file mode 100644 index 0000000..66e098a --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615204048.dart @@ -0,0 +1,2087 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + required IconData icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil logout', + ); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 18), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615204050.dart b/.history/cv_app/mobile_app/lib/main_20260615204050.dart new file mode 100644 index 0000000..a28e08d --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615204050.dart @@ -0,0 +1,2087 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + required IconData icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil logout' + ); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 18), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615204051.dart b/.history/cv_app/mobile_app/lib/main_20260615204051.dart new file mode 100644 index 0000000..66e098a --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615204051.dart @@ -0,0 +1,2087 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + required IconData icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil logout', + ); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 18), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615204054.dart b/.history/cv_app/mobile_app/lib/main_20260615204054.dart new file mode 100644 index 0000000..e7e0c90 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615204054.dart @@ -0,0 +1,2088 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + required IconData icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil logout', + icon: Icons.logout, + ); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 18), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615204139.dart b/.history/cv_app/mobile_app/lib/main_20260615204139.dart new file mode 100644 index 0000000..7acf447 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615204139.dart @@ -0,0 +1,2088 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + required IconData icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil logout', + icon: Icons.logout, + ); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615204209.dart b/.history/cv_app/mobile_app/lib/main_20260615204209.dart new file mode 100644 index 0000000..923b6ee --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615204209.dart @@ -0,0 +1,2088 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + required IconData icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil logout', + + ); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615204211.dart b/.history/cv_app/mobile_app/lib/main_20260615204211.dart new file mode 100644 index 0000000..cd4f433 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615204211.dart @@ -0,0 +1,2088 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + required IconData icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil logout', + x + ); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615204214.dart b/.history/cv_app/mobile_app/lib/main_20260615204214.dart new file mode 100644 index 0000000..7acf447 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615204214.dart @@ -0,0 +1,2088 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + required IconData icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil logout', + icon: Icons.logout, + ); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615204218.dart b/.history/cv_app/mobile_app/lib/main_20260615204218.dart new file mode 100644 index 0000000..0714eb5 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615204218.dart @@ -0,0 +1,2088 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + required IconData icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil logout', + icon: Icons.logout, + ); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 1), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615204221.dart b/.history/cv_app/mobile_app/lib/main_20260615204221.dart new file mode 100644 index 0000000..7acf447 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615204221.dart @@ -0,0 +1,2088 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + required IconData icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil logout', + icon: Icons.logout, + ); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615223258.dart b/.history/cv_app/mobile_app/lib/main_20260615223258.dart new file mode 100644 index 0000000..a6e4d18 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615223258.dart @@ -0,0 +1,2395 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar(context, message: 'Berhasil logout'); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + _loadStudentNames(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => + 'confivoice_student_names_${widget.session.id}'; + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final names = preferences.getStringList(_studentNamesPreferenceKey) ?? []; + if (!mounted) { + return; + } + setState(() { + _studentNames = _normalizeStudentNames(names); + }); + } catch (_) {} + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + _focusNode.unfocus(); + setState(() { + _query = name; + }); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + ListTile( + dense: true, + leading: const Icon( + Icons.person_outline, + color: AppColors.blueSoft, + ), + title: Text(name), + onTap: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + ListTile( + dense: true, + leading: const Icon( + Icons.add_circle_outline, + color: AppColors.blueSoft, + ), + title: Text('Tambah "$cleanQuery"'), + onTap: _addCurrentName, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + Widget build(BuildContext context) { + final emptyText = + isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: values, + color: + isLive + ? AppColors.blueSoft + : Theme.of(context).colorScheme.primary, + isLive: isLive, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({ + required this.values, + required this.color, + required this.isLive, + }); + + final List values; + final Color color; + final bool isLive; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = + isLive ? AppColors.blue.withValues(alpha: 0.18) : AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final glowPaint = + Paint() + ..color = color.withValues(alpha: isLive ? 0.18 : 0.0) + ..strokeWidth = max(6, size.width / values.length * 0.85) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + if (isLive) { + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + } + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || + oldDelegate.color != color || + oldDelegate.isLive != isLive; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615223308.dart b/.history/cv_app/mobile_app/lib/main_20260615223308.dart new file mode 100644 index 0000000..69c752f --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615223308.dart @@ -0,0 +1,2395 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar(context, message: 'Berhasil logout'); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + _loadStudentNames(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => + 'confivoice_student_names_${widget.session.id}'; + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final names = preferences.getStringList(_studentNamesPreferenceKey) ?? []; + if (!mounted) { + return; + } + setState(() { + _studentNames = _normalizeStudentNames(names); + }); + } catch (_) {} + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + _focusNode.unfocus(); + setState(() { + _query = name; + }); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + ListTile( + dense: true, + leading: const Icon( + Icons.person_outline, + color: AppColors.blueSoft, + ), + title: Text(name), + onTap: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + ListTile( + dense: true, + leading: const Icon( + Icons.add_circle_outline, + color: AppColors.blueSoft, + ), + title: Text('Tambah "$cleanQuery"'), + onTap: _addCurrentName, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).titleLargzzzzze, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + Widget build(BuildContext context) { + final emptyText = + isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: values, + color: + isLive + ? AppColors.blueSoft + : Theme.of(context).colorScheme.primary, + isLive: isLive, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({ + required this.values, + required this.color, + required this.isLive, + }); + + final List values; + final Color color; + final bool isLive; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = + isLive ? AppColors.blue.withValues(alpha: 0.18) : AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final glowPaint = + Paint() + ..color = color.withValues(alpha: isLive ? 0.18 : 0.0) + ..strokeWidth = max(6, size.width / values.length * 0.85) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + if (isLive) { + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + } + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || + oldDelegate.color != color || + oldDelegate.isLive != isLive; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615223311.dart b/.history/cv_app/mobile_app/lib/main_20260615223311.dart new file mode 100644 index 0000000..572a84f --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615223311.dart @@ -0,0 +1,2395 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar(context, message: 'Berhasil logout'); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + _loadStudentNames(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => + 'confivoice_student_names_${widget.session.id}'; + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final names = preferences.getStringList(_studentNamesPreferenceKey) ?? []; + if (!mounted) { + return; + } + setState(() { + _studentNames = _normalizeStudentNames(names); + }); + } catch (_) {} + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + _focusNode.unfocus(); + setState(() { + _query = name; + }); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + ListTile( + dense: true, + leading: const Icon( + Icons.person_outline, + color: AppColors.blueSoft, + ), + title: Text(name), + onTap: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + ListTile( + dense: true, + leading: const Icon( + Icons.add_circle_outline, + color: AppColors.blueSoft, + ), + title: Text('Tambah "$cleanQuery"'), + onTap: _addCurrentName, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + Widget build(BuildContext context) { + final emptyText = + isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: values, + color: + isLive + ? AppColors.blueSoft + : Theme.of(context).colorScheme.primary, + isLive: isLive, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({ + required this.values, + required this.color, + required this.isLive, + }); + + final List values; + final Color color; + final bool isLive; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = + isLive ? AppColors.blue.withValues(alpha: 0.18) : AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final glowPaint = + Paint() + ..color = color.withValues(alpha: isLive ? 0.18 : 0.0) + ..strokeWidth = max(6, size.width / values.length * 0.85) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + if (isLive) { + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + } + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || + oldDelegate.color != color || + oldDelegate.isLive != isLive; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615223346.dart b/.history/cv_app/mobile_app/lib/main_20260615223346.dart new file mode 100644 index 0000000..358bea5 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615223346.dart @@ -0,0 +1,2395 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar(context, message: 'Berhasil logout'); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + _loadStudentNames(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => + 'confivoice_student_names_${widget.session.id}'; + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final names = preferences.getStringList(_studentNamesPreferenceKey) ?? []; + if (!mounted) { + return; + } + setState(() { + _studentNames = _normalizeStudentNames(names); + }); + } catch (_) {} + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + _focusNode.unfocus(); + setState(() { + _query = name; + }); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + ListTile( + dense: true, + leading: const Icon( + Icons.person_outline, + color: AppColors.blueSoft, + ), + title: Text(name), + onTap: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + ListTile( + dense: true, + leading: const Icon( + Icons.add_circle_outline, + color: AppColors.blueSoft, + ), + title: Text('Tambah "$cleanQuery"'), + onTap: _addCurrentName, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + Widget build(BuildContext context) { + final emptyText = + isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: values, + color: + isLive + ? AppColors.blueSoft + : Theme.of(context).colorScheme.primary, + isLive: isLive, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({ + required this.values, + required this.color, + required this.isLive, + }); + + final List values; + final Color color; + final bool isLive; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = + isLive ? AppColors.blue.withValues(alpha: 0.18) : AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final glowPaint = + Paint() + ..color = color.withValues(alpha: isLive ? 0.18 : 0.0) + ..strokeWidth = max(6, size.width / values.length * 0.85) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + if (isLive) { + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + } + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || + oldDelegate.color != color || + oldDelegate.isLive != isLive; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615223416.dart b/.history/cv_app/mobile_app/lib/main_20260615223416.dart new file mode 100644 index 0000000..572a84f --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615223416.dart @@ -0,0 +1,2395 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar(context, message: 'Berhasil logout'); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + _loadStudentNames(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => + 'confivoice_student_names_${widget.session.id}'; + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final names = preferences.getStringList(_studentNamesPreferenceKey) ?? []; + if (!mounted) { + return; + } + setState(() { + _studentNames = _normalizeStudentNames(names); + }); + } catch (_) {} + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + _focusNode.unfocus(); + setState(() { + _query = name; + }); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + ListTile( + dense: true, + leading: const Icon( + Icons.person_outline, + color: AppColors.blueSoft, + ), + title: Text(name), + onTap: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + ListTile( + dense: true, + leading: const Icon( + Icons.add_circle_outline, + color: AppColors.blueSoft, + ), + title: Text('Tambah "$cleanQuery"'), + onTap: _addCurrentName, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + Widget build(BuildContext context) { + final emptyText = + isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: values, + color: + isLive + ? AppColors.blueSoft + : Theme.of(context).colorScheme.primary, + isLive: isLive, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({ + required this.values, + required this.color, + required this.isLive, + }); + + final List values; + final Color color; + final bool isLive; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = + isLive ? AppColors.blue.withValues(alpha: 0.18) : AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final glowPaint = + Paint() + ..color = color.withValues(alpha: isLive ? 0.18 : 0.0) + ..strokeWidth = max(6, size.width / values.length * 0.85) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + if (isLive) { + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + } + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || + oldDelegate.color != color || + oldDelegate.isLive != isLive; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615223418.dart b/.history/cv_app/mobile_app/lib/main_20260615223418.dart new file mode 100644 index 0000000..572a84f --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615223418.dart @@ -0,0 +1,2395 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar(context, message: 'Berhasil logout'); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + _loadStudentNames(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => + 'confivoice_student_names_${widget.session.id}'; + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final names = preferences.getStringList(_studentNamesPreferenceKey) ?? []; + if (!mounted) { + return; + } + setState(() { + _studentNames = _normalizeStudentNames(names); + }); + } catch (_) {} + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + _focusNode.unfocus(); + setState(() { + _query = name; + }); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + ListTile( + dense: true, + leading: const Icon( + Icons.person_outline, + color: AppColors.blueSoft, + ), + title: Text(name), + onTap: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + ListTile( + dense: true, + leading: const Icon( + Icons.add_circle_outline, + color: AppColors.blueSoft, + ), + title: Text('Tambah "$cleanQuery"'), + onTap: _addCurrentName, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + Widget build(BuildContext context) { + final emptyText = + isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: values, + color: + isLive + ? AppColors.blueSoft + : Theme.of(context).colorScheme.primary, + isLive: isLive, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({ + required this.values, + required this.color, + required this.isLive, + }); + + final List values; + final Color color; + final bool isLive; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = + isLive ? AppColors.blue.withValues(alpha: 0.18) : AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final glowPaint = + Paint() + ..color = color.withValues(alpha: isLive ? 0.18 : 0.0) + ..strokeWidth = max(6, size.width / values.length * 0.85) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + if (isLive) { + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + } + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || + oldDelegate.color != color || + oldDelegate.isLive != isLive; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615223432.dart b/.history/cv_app/mobile_app/lib/main_20260615223432.dart new file mode 100644 index 0000000..358bea5 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615223432.dart @@ -0,0 +1,2395 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar(context, message: 'Berhasil logout'); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + _loadStudentNames(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => + 'confivoice_student_names_${widget.session.id}'; + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final names = preferences.getStringList(_studentNamesPreferenceKey) ?? []; + if (!mounted) { + return; + } + setState(() { + _studentNames = _normalizeStudentNames(names); + }); + } catch (_) {} + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + _focusNode.unfocus(); + setState(() { + _query = name; + }); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + ListTile( + dense: true, + leading: const Icon( + Icons.person_outline, + color: AppColors.blueSoft, + ), + title: Text(name), + onTap: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + ListTile( + dense: true, + leading: const Icon( + Icons.add_circle_outline, + color: AppColors.blueSoft, + ), + title: Text('Tambah "$cleanQuery"'), + onTap: _addCurrentName, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + Widget build(BuildContext context) { + final emptyText = + isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: values, + color: + isLive + ? AppColors.blueSoft + : Theme.of(context).colorScheme.primary, + isLive: isLive, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({ + required this.values, + required this.color, + required this.isLive, + }); + + final List values; + final Color color; + final bool isLive; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = + isLive ? AppColors.blue.withValues(alpha: 0.18) : AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final glowPaint = + Paint() + ..color = color.withValues(alpha: isLive ? 0.18 : 0.0) + ..strokeWidth = max(6, size.width / values.length * 0.85) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + if (isLive) { + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + } + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || + oldDelegate.color != color || + oldDelegate.isLive != isLive; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615223437.dart b/.history/cv_app/mobile_app/lib/main_20260615223437.dart new file mode 100644 index 0000000..572a84f --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615223437.dart @@ -0,0 +1,2395 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar(context, message: 'Berhasil logout'); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + _loadStudentNames(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => + 'confivoice_student_names_${widget.session.id}'; + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final names = preferences.getStringList(_studentNamesPreferenceKey) ?? []; + if (!mounted) { + return; + } + setState(() { + _studentNames = _normalizeStudentNames(names); + }); + } catch (_) {} + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + _focusNode.unfocus(); + setState(() { + _query = name; + }); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + ListTile( + dense: true, + leading: const Icon( + Icons.person_outline, + color: AppColors.blueSoft, + ), + title: Text(name), + onTap: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + ListTile( + dense: true, + leading: const Icon( + Icons.add_circle_outline, + color: AppColors.blueSoft, + ), + title: Text('Tambah "$cleanQuery"'), + onTap: _addCurrentName, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + Widget build(BuildContext context) { + final emptyText = + isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: values, + color: + isLive + ? AppColors.blueSoft + : Theme.of(context).colorScheme.primary, + isLive: isLive, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({ + required this.values, + required this.color, + required this.isLive, + }); + + final List values; + final Color color; + final bool isLive; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = + isLive ? AppColors.blue.withValues(alpha: 0.18) : AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final glowPaint = + Paint() + ..color = color.withValues(alpha: isLive ? 0.18 : 0.0) + ..strokeWidth = max(6, size.width / values.length * 0.85) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + if (isLive) { + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + } + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || + oldDelegate.color != color || + oldDelegate.isLive != isLive; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615223442.dart b/.history/cv_app/mobile_app/lib/main_20260615223442.dart new file mode 100644 index 0000000..3f17bfd --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615223442.dart @@ -0,0 +1,2395 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar(context, message: 'Berhasil logout'); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + _loadStudentNames(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => + 'confivoice_student_names_${widget.session.id}'; + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final names = preferences.getStringList(_studentNamesPreferenceKey) ?? []; + if (!mounted) { + return; + } + setState(() { + _studentNames = _normalizeStudentNames(names); + }); + } catch (_) {} + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + _focusNode.unfocus(); + setState(() { + _query = name; + }); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + ListTile( + dense: true, + leading: const Icon( + Icons.person_outline, + color: AppColors.blueSoft, + ), + title: Text(name), + onTap: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + ListTile( + dense: true, + leading: const Icon( + Icons.add_circle_outline, + color: AppColors.blueSoft, + ), + title: Text('Tambah "$cleanQuery"'), + onTap: _addCurrentName, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12) + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + Widget build(BuildContext context) { + final emptyText = + isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: values, + color: + isLive + ? AppColors.blueSoft + : Theme.of(context).colorScheme.primary, + isLive: isLive, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({ + required this.values, + required this.color, + required this.isLive, + }); + + final List values; + final Color color; + final bool isLive; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = + isLive ? AppColors.blue.withValues(alpha: 0.18) : AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final glowPaint = + Paint() + ..color = color.withValues(alpha: isLive ? 0.18 : 0.0) + ..strokeWidth = max(6, size.width / values.length * 0.85) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + if (isLive) { + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + } + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || + oldDelegate.color != color || + oldDelegate.isLive != isLive; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615223449.dart b/.history/cv_app/mobile_app/lib/main_20260615223449.dart new file mode 100644 index 0000000..572a84f --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615223449.dart @@ -0,0 +1,2395 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar(context, message: 'Berhasil logout'); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + _loadStudentNames(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => + 'confivoice_student_names_${widget.session.id}'; + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final names = preferences.getStringList(_studentNamesPreferenceKey) ?? []; + if (!mounted) { + return; + } + setState(() { + _studentNames = _normalizeStudentNames(names); + }); + } catch (_) {} + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + _focusNode.unfocus(); + setState(() { + _query = name; + }); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + ListTile( + dense: true, + leading: const Icon( + Icons.person_outline, + color: AppColors.blueSoft, + ), + title: Text(name), + onTap: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + ListTile( + dense: true, + leading: const Icon( + Icons.add_circle_outline, + color: AppColors.blueSoft, + ), + title: Text('Tambah "$cleanQuery"'), + onTap: _addCurrentName, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + Widget build(BuildContext context) { + final emptyText = + isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: values, + color: + isLive + ? AppColors.blueSoft + : Theme.of(context).colorScheme.primary, + isLive: isLive, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({ + required this.values, + required this.color, + required this.isLive, + }); + + final List values; + final Color color; + final bool isLive; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = + isLive ? AppColors.blue.withValues(alpha: 0.18) : AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final glowPaint = + Paint() + ..color = color.withValues(alpha: isLive ? 0.18 : 0.0) + ..strokeWidth = max(6, size.width / values.length * 0.85) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + if (isLive) { + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + } + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || + oldDelegate.color != color || + oldDelegate.isLive != isLive; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260618122315.dart b/.history/cv_app/mobile_app/lib/main_20260618122315.dart new file mode 100644 index 0000000..2a06f2b --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260618122315.dart @@ -0,0 +1,2777 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar(context, message: 'Berhasil logout'); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + await _saveApiEndpoint(); + if (_isRegister) { + if (!mounted) { + return; + } + final registeredUsername = username; + setState(() { + _isRegister = false; + _fullNameController.clear(); + _usernameController.text = registeredUsername; + _passwordController.clear(); + _confirmPasswordController.clear(); + _errorMessage = null; + }); + showCompactSnackBar( + context, + message: 'Registrasi berhasil. Silakan login.', + icon: Icons.check_circle_outline, + ); + return; + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + String _studentGender = ''; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _initializeShell(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => 'confivoice_student_names_shared'; + + Future _initializeShell() async { + await _loadApiEndpoint(); + await _loadStudentNames(); + } + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final localNames = + preferences.getStringList(_studentNamesPreferenceKey) ?? []; + final savedResultNames = await _loadStudentNamesFromSavedResults(); + final names = _normalizeStudentNames([ + ...localNames, + ...savedResultNames, + ]); + await preferences.setStringList(_studentNamesPreferenceKey, names); + if (!mounted) { + return; + } + setState(() { + _studentNames = names; + }); + } catch (_) {} + } + + Future> _loadStudentNamesFromSavedResults() async { + try { + final response = await http + .get(_apiEndpoint('/predictions')) + .timeout(const Duration(seconds: 12)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + return const []; + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map((item) => (item as Map)['student_name']?.toString() ?? '') + .where((name) => _cleanStudentName(name).isNotEmpty) + .toList(); + } catch (_) { + return const []; + } + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + if (_studentGender.isEmpty) { + setState(() { + _errorMessage = 'Pilih jenis kelamin siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.fields['student_gender'] = _studentGender; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _studentGender = ''; + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'limit': '1000'}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + final displayUserName = + widget.session.fullName.trim().isEmpty + ? widget.session.username + : widget.session.fullName.trim(); + + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + Padding( + padding: const EdgeInsets.only(right: 12), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 92), + child: Text( + displayUserName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.text, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + studentGender: _studentGender, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onGenderChanged: (value) { + setState(() { + _studentGender = value ?? ''; + _prediction = null; + _errorMessage = null; + }); + }, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Pengaturan'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () { + Navigator.pop(context); + _confirmLogout(); + }, + child: const Text('Logout'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + final groups = StudentAnalysisGroup.fromAnalyses(analyses); + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: groups.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final group = groups[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: const Icon(Icons.folder_outlined), + ), + title: Text(group.studentName), + subtitle: Text( + '${group.latest.genderLabel} | ${group.count} hasil analisis\nRata-rata PD: ${group.averagePdPercent}%', + ), + isThreeLine: true, + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => + StudentAnalysisDetailPage(group: group), + ), + ); + }, + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class StudentAnalysisGroup { + const StudentAnalysisGroup({ + required this.studentName, + required this.analyses, + }); + + final String studentName; + final List analyses; + + SavedAnalysis get latest => analyses.first; + int get count => analyses.length; + String get averagePdPercent { + if (analyses.isEmpty) { + return '0.0'; + } + final average = + analyses.fold( + 0, + (total, analysis) => total + analysis.probabilityPd, + ) / + analyses.length; + return (average * 100).toStringAsFixed(1); + } + + static List fromAnalyses(List analyses) { + final grouped = >{}; + final displayNames = {}; + + for (final analysis in analyses) { + final name = + analysis.studentName.trim().isEmpty + ? '-' + : analysis.studentName.trim(); + final key = name.toLowerCase(); + displayNames.putIfAbsent(key, () => name); + grouped.putIfAbsent(key, () => []).add(analysis); + } + + final groups = + grouped.entries + .map( + (entry) => StudentAnalysisGroup( + studentName: displayNames[entry.key] ?? entry.key, + analyses: entry.value, + ), + ) + .toList(); + groups.sort( + (a, b) => + a.studentName.toLowerCase().compareTo(b.studentName.toLowerCase()), + ); + return groups; + } +} + +class StudentAnalysisDetailPage extends StatelessWidget { + const StudentAnalysisDetailPage({super.key, required this.group}); + + final StudentAnalysisGroup group; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(group.studentName)), + body: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: group.analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = group.analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text('Prediksi ${index + 1}'), + subtitle: Text( + '${analysis.genderLabel} | ${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.studentGender, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + studentGender: json['student_gender']?.toString() ?? '', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String studentGender; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.studentGender, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + studentGender: json['student_gender']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId, String studentGender) { + return { + ...rawJson, + 'student_name': studentName, + 'student_gender': studentGender, + 'user_id': userId, + }; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final String studentGender; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; + + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({required this.value, required this.onChanged}); + + final String value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260620005846.dart b/.history/cv_app/mobile_app/lib/main_20260620005846.dart new file mode 100644 index 0000000..26835bc --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260620005846.dart @@ -0,0 +1,2777 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar(context, message: 'Berhasil logout'); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + await _saveApiEndpoint(); + if (_isRegister) { + if (!mounted) { + return; + } + final registeredUsername = username; + setState(() { + _isRegister = false; + _fullNameController.clear(); + _usernameController.text = registeredUsername; + _passwordController.clear(); + _confirmPasswordController.clear(); + _errorMessage = null; + }); + showCompactSnackBar( + context, + message: 'Registrasi berhasil. Silakan login.', + icon: Icons.check_circle_outline, + ); + return; + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + String _studentGender = ''; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _initializeShell(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => 'confivoice_student_names_shared'; + + Future _initializeShell() async { + await _loadApiEndpoint(); + await _loadStudentNames(); + } + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final localNames = + preferences.getStringList(_studentNamesPreferenceKey) ?? []; + final savedResultNames = await _loadStudentNamesFromSavedResults(); + final names = _normalizeStudentNames([ + ...localNames, + ...savedResultNames, + ]); + await preferences.setStringList(_studentNamesPreferenceKey, names); + if (!mounted) { + return; + } + setState(() { + _studentNames = names; + }); + } catch (_) {} + } + + Future> _loadStudentNamesFromSavedResults() async { + try { + final response = await http + .get(_apiEndpoint('/predictions')) + .timeout(const Duration(seconds: 12)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + return const []; + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map((item) => (item as Map)['student_name']?.toString() ?? '') + .where((name) => _cleanStudentName(name).isNotEmpty) + .toList(); + } catch (_) { + return const []; + } + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + if (_studentGender.isEmpty) { + setState(() { + _errorMessage = 'Pilih jenis kelamin siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.fields['student_gender'] = _studentGender; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _studentGender = ''; + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'limit': '1000'}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + final displayUserName = + widget.session.fullName.trim().isEmpty + ? widget.session.username + : widget.session.fullName.trim(); + + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + Padding( + padding: const EdgeInsets.only(right: 12), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 92), + child: Text( + displayUserName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.text, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + studentGender: _studentGender, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onGenderChanged: (value) { + setState(() { + _studentGender = value ?? ''; + _prediction = null; + _errorMessage = null; + }); + }, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Pengaturan'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () { + Navigator.pop(context); + _confirmLogout(); + }, + child: const Text('Logout'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + final groups = StudentAnalysisGroup.fromAnalyses(analyses); + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: groups.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final group = groups[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: const Icon(Icons.folder_outlined), + ), + title: Text(group.studentName), + subtitle: Text( + '${group.latest.genderLabel} | ${group.count} hasil analisis\nRata-rata PD: ${group.averagePdPercent}%', + ), + isThreeLine: true, + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => + StudentAnalysisDetailPage(group: group), + ), + ); + }, + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class StudentAnalysisGroup { + const StudentAnalysisGroup({ + required this.studentName, + required this.analyses, + }); + + final String studentName; + final List analyses; + + SavedAnalysis get latest => analyses.first; + int get count => analyses.length; + String get averagePdPercent { + if (analyses.isEmpty) { + return '0.0'; + } + final average = + analyses.fold( + 0, + (total, analysis) => total + analysis.probabilityPd, + ) / + analyses.length; + return (average * 100).toStringAsFixed(1); + } + + static List fromAnalyses(List analyses) { + final grouped = >{}; + final displayNames = {}; + + for (final analysis in analyses) { + final name = + analysis.studentName.trim().isEmpty + ? '-' + : analysis.studentName.trim(); + final key = name.toLowerCase(); + displayNames.putIfAbsent(key, () => name); + grouped.putIfAbsent(key, () => []).add(analysis); + } + + final groups = + grouped.entries + .map( + (entry) => StudentAnalysisGroup( + studentName: displayNames[entry.key] ?? entry.key, + analyses: entry.value, + ), + ) + .toList(); + groups.sort( + (a, b) => + a.studentName.toLowerCase().compareTo(b.studentName.toLowerCase()), + ); + return groups; + } +} + +class StudentAnalysisDetailPage extends StatelessWidget { + const StudentAnalysisDetailPage({super.key, required this.group}); + + final StudentAnalysisGroup group; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(group.studentName)), + body: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: group.analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = group.analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text('Prediksi ${index + 1}'), + subtitle: Text( + '${analysis.genderLabel} | ${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.studentGender, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + studentGender: json['student_gender']?.toString() ?? '', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String studentGender; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.studentGender, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + studentGender: json['student_gender']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId, String studentGender) { + return { + ...rawJson, + 'student_name': studentName, + 'student_gender': studentGender, + 'user_id': userId, + }; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final String studentGender; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; + + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({required this.value, required this.onChanged}); + + final String value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent%',f + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260620010031.dart b/.history/cv_app/mobile_app/lib/main_20260620010031.dart new file mode 100644 index 0000000..2a06f2b --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260620010031.dart @@ -0,0 +1,2777 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar(context, message: 'Berhasil logout'); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + await _saveApiEndpoint(); + if (_isRegister) { + if (!mounted) { + return; + } + final registeredUsername = username; + setState(() { + _isRegister = false; + _fullNameController.clear(); + _usernameController.text = registeredUsername; + _passwordController.clear(); + _confirmPasswordController.clear(); + _errorMessage = null; + }); + showCompactSnackBar( + context, + message: 'Registrasi berhasil. Silakan login.', + icon: Icons.check_circle_outline, + ); + return; + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + String _studentGender = ''; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _initializeShell(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => 'confivoice_student_names_shared'; + + Future _initializeShell() async { + await _loadApiEndpoint(); + await _loadStudentNames(); + } + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final localNames = + preferences.getStringList(_studentNamesPreferenceKey) ?? []; + final savedResultNames = await _loadStudentNamesFromSavedResults(); + final names = _normalizeStudentNames([ + ...localNames, + ...savedResultNames, + ]); + await preferences.setStringList(_studentNamesPreferenceKey, names); + if (!mounted) { + return; + } + setState(() { + _studentNames = names; + }); + } catch (_) {} + } + + Future> _loadStudentNamesFromSavedResults() async { + try { + final response = await http + .get(_apiEndpoint('/predictions')) + .timeout(const Duration(seconds: 12)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + return const []; + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map((item) => (item as Map)['student_name']?.toString() ?? '') + .where((name) => _cleanStudentName(name).isNotEmpty) + .toList(); + } catch (_) { + return const []; + } + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + if (_studentGender.isEmpty) { + setState(() { + _errorMessage = 'Pilih jenis kelamin siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.fields['student_gender'] = _studentGender; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _studentGender = ''; + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'limit': '1000'}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + final displayUserName = + widget.session.fullName.trim().isEmpty + ? widget.session.username + : widget.session.fullName.trim(); + + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + Padding( + padding: const EdgeInsets.only(right: 12), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 92), + child: Text( + displayUserName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.text, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + studentGender: _studentGender, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onGenderChanged: (value) { + setState(() { + _studentGender = value ?? ''; + _prediction = null; + _errorMessage = null; + }); + }, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Pengaturan'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () { + Navigator.pop(context); + _confirmLogout(); + }, + child: const Text('Logout'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + final groups = StudentAnalysisGroup.fromAnalyses(analyses); + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: groups.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final group = groups[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: const Icon(Icons.folder_outlined), + ), + title: Text(group.studentName), + subtitle: Text( + '${group.latest.genderLabel} | ${group.count} hasil analisis\nRata-rata PD: ${group.averagePdPercent}%', + ), + isThreeLine: true, + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => + StudentAnalysisDetailPage(group: group), + ), + ); + }, + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class StudentAnalysisGroup { + const StudentAnalysisGroup({ + required this.studentName, + required this.analyses, + }); + + final String studentName; + final List analyses; + + SavedAnalysis get latest => analyses.first; + int get count => analyses.length; + String get averagePdPercent { + if (analyses.isEmpty) { + return '0.0'; + } + final average = + analyses.fold( + 0, + (total, analysis) => total + analysis.probabilityPd, + ) / + analyses.length; + return (average * 100).toStringAsFixed(1); + } + + static List fromAnalyses(List analyses) { + final grouped = >{}; + final displayNames = {}; + + for (final analysis in analyses) { + final name = + analysis.studentName.trim().isEmpty + ? '-' + : analysis.studentName.trim(); + final key = name.toLowerCase(); + displayNames.putIfAbsent(key, () => name); + grouped.putIfAbsent(key, () => []).add(analysis); + } + + final groups = + grouped.entries + .map( + (entry) => StudentAnalysisGroup( + studentName: displayNames[entry.key] ?? entry.key, + analyses: entry.value, + ), + ) + .toList(); + groups.sort( + (a, b) => + a.studentName.toLowerCase().compareTo(b.studentName.toLowerCase()), + ); + return groups; + } +} + +class StudentAnalysisDetailPage extends StatelessWidget { + const StudentAnalysisDetailPage({super.key, required this.group}); + + final StudentAnalysisGroup group; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(group.studentName)), + body: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: group.analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = group.analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text('Prediksi ${index + 1}'), + subtitle: Text( + '${analysis.genderLabel} | ${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.studentGender, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + studentGender: json['student_gender']?.toString() ?? '', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String studentGender; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.studentGender, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + studentGender: json['student_gender']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId, String studentGender) { + return { + ...rawJson, + 'student_name': studentName, + 'student_gender': studentGender, + 'user_id': userId, + }; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final String studentGender; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; + + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({required this.value, required this.onChanged}); + + final String value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260620010032.dart b/.history/cv_app/mobile_app/lib/main_20260620010032.dart new file mode 100644 index 0000000..f34c494 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260620010032.dart @@ -0,0 +1,2777 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar(context, message: 'Berhasil logout'); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + await _saveApiEndpoint(); + if (_isRegister) { + if (!mounted) { + return; + } + final registeredUsername = username; + setState(() { + _isRegister = false; + _fullNameController.clear(); + _usernameController.text = registeredUsername; + _passwordController.clear(); + _confirmPasswordController.clear(); + _errorMessage = null; + }); + showCompactSnackBar( + context, + message: 'Registrasi berhasil. Silakan login.', + icon: Icons.check_circle_outline, + ); + return; + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + String _studentGender = ''; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _initializeShell(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => 'confivoice_student_names_shared'; + + Future _initializeShell() async { + await _loadApiEndpoint(); + await _loadStudentNames(); + } + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final localNames = + preferences.getStringList(_studentNamesPreferenceKey) ?? []; + final savedResultNames = await _loadStudentNamesFromSavedResults(); + final names = _normalizeStudentNames([ + ...localNames, + ...savedResultNames, + ]); + await preferences.setStringList(_studentNamesPreferenceKey, names); + if (!mounted) { + return; + } + setState(() { + _studentNames = names; + }); + } catch (_) {} + } + + Future> _loadStudentNamesFromSavedResults() async { + try { + final response = await http + .get(_apiEndpoint('/predictions')) + .timeout(const Duration(seconds: 12)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + return const []; + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map((item) => (item as Map)['student_name']?.toString() ?? '') + .where((name) => _cleanStudentName(name).isNotEmpty) + .toList(); + } catch (_) { + return const []; + } + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + if (_studentGender.isEmpty) { + setState(() { + _errorMessage = 'Pilih jenis kelamin siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.fields['student_gender'] = _studentGender; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _studentGender = ''; + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'limit': '1000'}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + final displayUserName = + widget.session.fullName.trim().isEmpty + ? widget.session.username + : widget.session.fullName.trim(); + + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + Padding( + padding: const EdgeInsets.only(right: 12), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 92), + child: Text( + displayUserName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.text, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + studentGender: _studentGender, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onGenderChanged: (value) { + setState(() { + _studentGender = value ?? ''; + _prediction = null; + _errorMessage = null; + }); + }, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Pengaturan'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () { + Navigator.pop(context); + _confirmLogout(); + }, + child: const Text('Logout'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + final groups = StudentAnalysisGroup.fromAnalyses(analyses); + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: groups.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final group = groups[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: const Icon(Icons.folder_outlined), + ), + title: Text(group.studentName), + subtitle: Text( + '${group.latest.genderLabel} | ${group.count} hasil analisis\nRata-rata PD: ${group.averagePdPercent}%', + ), + isThreeLine: true, + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => + StudentAnalysisDetailPage(group: group), + ), + ); + }, + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class StudentAnalysisGroup { + const StudentAnalysisGroup({ + required this.studentName, + required this.analyses, + }); + + final String studentName; + final List analyses; + + SavedAnalysis get latest => analyses.first; + int get count => analyses.length; + String get averagePdPercent { + if (analyses.isEmpty) { + return '0.0'; + } + final average = + analyses.fold( + 0, + (total, analysis) => total + analysis.probabilityPd, + ) / + analyses.length; + return (average * 100).toStringAsFixed(1); + } + + static List fromAnalyses(List analyses) { + final grouped = >{}; + final displayNames = {}; + + for (final analysis in analyses) { + final name = + analysis.studentName.trim().isEmpty + ? '-' + : analysis.studentName.trim(); + final key = name.toLowerCase(); + displayNames.putIfAbsent(key, () => name); + grouped.putIfAbsent(key, () => []).add(analysis); + } + + final groups = + grouped.entries + .map( + (entry) => StudentAnalysisGroup( + studentName: displayNames[entry.key] ?? entry.key, + analyses: entry.value, + ), + ) + .toList(); + groups.sort( + (a, b) => + a.studentName.toLowerCase().compareTo(b.studentName.toLowerCase()), + ); + return groups; + } +} + +class StudentAnalysisDetailPage extends StatelessWidget { + const StudentAnalysisDetailPage({super.key, required this.group}); + + final StudentAnalysisGroup group; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(group.studentName)), + body: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: group.analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = group.analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text('Prediksi ${index + 1}'), + subtitle: Text( + '${analysis.genderLabel} | ${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.studentGender, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + studentGender: json['student_gender']?.toString() ?? '', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String studentGender; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.studentGender, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + studentGender: json['student_gender']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId, String studentGender) { + return { + ...rawJson, + 'student_name': studentName, + 'student_gender': studentGender, + 'user_id': userId, + }; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final String studentGender; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; + + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({required this.value, required this.onChanged}); + + final String value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent%' + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260620010035.dart b/.history/cv_app/mobile_app/lib/main_20260620010035.dart new file mode 100644 index 0000000..8bd52ad --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260620010035.dart @@ -0,0 +1,2777 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar(context, message: 'Berhasil logout'); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + await _saveApiEndpoint(); + if (_isRegister) { + if (!mounted) { + return; + } + final registeredUsername = username; + setState(() { + _isRegister = false; + _fullNameController.clear(); + _usernameController.text = registeredUsername; + _passwordController.clear(); + _confirmPasswordController.clear(); + _errorMessage = null; + }); + showCompactSnackBar( + context, + message: 'Registrasi berhasil. Silakan login.', + icon: Icons.check_circle_outline, + ); + return; + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + String _studentGender = ''; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _initializeShell(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => 'confivoice_student_names_shared'; + + Future _initializeShell() async { + await _loadApiEndpoint(); + await _loadStudentNames(); + } + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final localNames = + preferences.getStringList(_studentNamesPreferenceKey) ?? []; + final savedResultNames = await _loadStudentNamesFromSavedResults(); + final names = _normalizeStudentNames([ + ...localNames, + ...savedResultNames, + ]); + await preferences.setStringList(_studentNamesPreferenceKey, names); + if (!mounted) { + return; + } + setState(() { + _studentNames = names; + }); + } catch (_) {} + } + + Future> _loadStudentNamesFromSavedResults() async { + try { + final response = await http + .get(_apiEndpoint('/predictions')) + .timeout(const Duration(seconds: 12)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + return const []; + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map((item) => (item as Map)['student_name']?.toString() ?? '') + .where((name) => _cleanStudentName(name).isNotEmpty) + .toList(); + } catch (_) { + return const []; + } + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + if (_studentGender.isEmpty) { + setState(() { + _errorMessage = 'Pilih jenis kelamin siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.fields['student_gender'] = _studentGender; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _studentGender = ''; + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'limit': '1000'}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + final displayUserName = + widget.session.fullName.trim().isEmpty + ? widget.session.username + : widget.session.fullName.trim(); + + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + Padding( + padding: const EdgeInsets.only(right: 12), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 92), + child: Text( + displayUserName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.text, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + studentGender: _studentGender, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onGenderChanged: (value) { + setState(() { + _studentGender = value ?? ''; + _prediction = null; + _errorMessage = null; + }); + }, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Pengaturan'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () { + Navigator.pop(context); + _confirmLogout(); + }, + child: const Text('Logout'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + final groups = StudentAnalysisGroup.fromAnalyses(analyses); + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: groups.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final group = groups[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: const Icon(Icons.folder_outlined), + ), + title: Text(group.studentName), + subtitle: Text( + '${group.latest.genderLabel} | ${group.count} hasil analisis\nRata-rata PD: ${group.averagePdPercent}%', + ), + isThreeLine: true, + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => + StudentAnalysisDetailPage(group: group), + ), + ); + }, + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class StudentAnalysisGroup { + const StudentAnalysisGroup({ + required this.studentName, + required this.analyses, + }); + + final String studentName; + final List analyses; + + SavedAnalysis get latest => analyses.first; + int get count => analyses.length; + String get averagePdPercent { + if (analyses.isEmpty) { + return '0.0'; + } + final average = + analyses.fold( + 0, + (total, analysis) => total + analysis.probabilityPd, + ) / + analyses.length; + return (average * 100).toStringAsFixed(1); + } + + static List fromAnalyses(List analyses) { + final grouped = >{}; + final displayNames = {}; + + for (final analysis in analyses) { + final name = + analysis.studentName.trim().isEmpty + ? '-' + : analysis.studentName.trim(); + final key = name.toLowerCase(); + displayNames.putIfAbsent(key, () => name); + grouped.putIfAbsent(key, () => []).add(analysis); + } + + final groups = + grouped.entries + .map( + (entry) => StudentAnalysisGroup( + studentName: displayNames[entry.key] ?? entry.key, + analyses: entry.value, + ), + ) + .toList(); + groups.sort( + (a, b) => + a.studentName.toLowerCase().compareTo(b.studentName.toLowerCase()), + ); + return groups; + } +} + +class StudentAnalysisDetailPage extends StatelessWidget { + const StudentAnalysisDetailPage({super.key, required this.group}); + + final StudentAnalysisGroup group; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(group.studentName)), + body: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: group.analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = group.analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text('Prediksi ${index + 1}'), + subtitle: Text( + '${analysis.genderLabel} | ${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.studentGender, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + studentGender: json['student_gender']?.toString() ?? '', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String studentGender; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.studentGender, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + studentGender: json['student_gender']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId, String studentGender) { + return { + ...rawJson, + 'student_name': studentName, + 'student_gender': studentGender, + 'user_id': userId, + }; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final String studentGender; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; + + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({required this.value, required this.onChanged}); + + final String value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent% + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260620010042.dart b/.history/cv_app/mobile_app/lib/main_20260620010042.dart new file mode 100644 index 0000000..d60ce2f --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260620010042.dart @@ -0,0 +1,2777 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar(context, message: 'Berhasil logout'); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + await _saveApiEndpoint(); + if (_isRegister) { + if (!mounted) { + return; + } + final registeredUsername = username; + setState(() { + _isRegister = false; + _fullNameController.clear(); + _usernameController.text = registeredUsername; + _passwordController.clear(); + _confirmPasswordController.clear(); + _errorMessage = null; + }); + showCompactSnackBar( + context, + message: 'Registrasi berhasil. Silakan login.', + icon: Icons.check_circle_outline, + ); + return; + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + String _studentGender = ''; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _initializeShell(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => 'confivoice_student_names_shared'; + + Future _initializeShell() async { + await _loadApiEndpoint(); + await _loadStudentNames(); + } + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final localNames = + preferences.getStringList(_studentNamesPreferenceKey) ?? []; + final savedResultNames = await _loadStudentNamesFromSavedResults(); + final names = _normalizeStudentNames([ + ...localNames, + ...savedResultNames, + ]); + await preferences.setStringList(_studentNamesPreferenceKey, names); + if (!mounted) { + return; + } + setState(() { + _studentNames = names; + }); + } catch (_) {} + } + + Future> _loadStudentNamesFromSavedResults() async { + try { + final response = await http + .get(_apiEndpoint('/predictions')) + .timeout(const Duration(seconds: 12)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + return const []; + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map((item) => (item as Map)['student_name']?.toString() ?? '') + .where((name) => _cleanStudentName(name).isNotEmpty) + .toList(); + } catch (_) { + return const []; + } + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + if (_studentGender.isEmpty) { + setState(() { + _errorMessage = 'Pilih jenis kelamin siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.fields['student_gender'] = _studentGender; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _studentGender = ''; + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'limit': '1000'}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + final displayUserName = + widget.session.fullName.trim().isEmpty + ? widget.session.username + : widget.session.fullName.trim(); + + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + Padding( + padding: const EdgeInsets.only(right: 12), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 92), + child: Text( + displayUserName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.text, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + studentGender: _studentGender, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onGenderChanged: (value) { + setState(() { + _studentGender = value ?? ''; + _prediction = null; + _errorMessage = null; + }); + }, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Pengaturan'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () { + Navigator.pop(context); + _confirmLogout(); + }, + child: const Text('Logout'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + final groups = StudentAnalysisGroup.fromAnalyses(analyses); + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: groups.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final group = groups[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: const Icon(Icons.folder_outlined), + ), + title: Text(group.studentName), + subtitle: Text( + '${group.latest.genderLabel} | ${group.count} hasil analisis\nRata-rata PD: ${group.averagePdPercent}%', + ), + isThreeLine: true, + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => + StudentAnalysisDetailPage(group: group), + ), + ); + }, + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class StudentAnalysisGroup { + const StudentAnalysisGroup({ + required this.studentName, + required this.analyses, + }); + + final String studentName; + final List analyses; + + SavedAnalysis get latest => analyses.first; + int get count => analyses.length; + String get averagePdPercent { + if (analyses.isEmpty) { + return '0.0'; + } + final average = + analyses.fold( + 0, + (total, analysis) => total + analysis.probabilityPd, + ) / + analyses.length; + return (average * 100).toStringAsFixed(1); + } + + static List fromAnalyses(List analyses) { + final grouped = >{}; + final displayNames = {}; + + for (final analysis in analyses) { + final name = + analysis.studentName.trim().isEmpty + ? '-' + : analysis.studentName.trim(); + final key = name.toLowerCase(); + displayNames.putIfAbsent(key, () => name); + grouped.putIfAbsent(key, () => []).add(analysis); + } + + final groups = + grouped.entries + .map( + (entry) => StudentAnalysisGroup( + studentName: displayNames[entry.key] ?? entry.key, + analyses: entry.value, + ), + ) + .toList(); + groups.sort( + (a, b) => + a.studentName.toLowerCase().compareTo(b.studentName.toLowerCase()), + ); + return groups; + } +} + +class StudentAnalysisDetailPage extends StatelessWidget { + const StudentAnalysisDetailPage({super.key, required this.group}); + + final StudentAnalysisGroup group; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(group.studentName)), + body: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: group.analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = group.analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text('Prediksi ${index + 1}'), + subtitle: Text( + '${analysis.genderLabel} | ${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.studentGender, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + studentGender: json['student_gender']?.toString() ?? '', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String studentGender; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.studentGender, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + studentGender: json['student_gender']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId, String studentGender) { + return { + ...rawJson, + 'student_name': studentName, + 'student_gender': studentGender, + 'user_id': userId, + }; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final String studentGender; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; + + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({required this.value, required this.onChanged}); + + final String value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent%" + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260620010044.dart b/.history/cv_app/mobile_app/lib/main_20260620010044.dart new file mode 100644 index 0000000..8bd52ad --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260620010044.dart @@ -0,0 +1,2777 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar(context, message: 'Berhasil logout'); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + await _saveApiEndpoint(); + if (_isRegister) { + if (!mounted) { + return; + } + final registeredUsername = username; + setState(() { + _isRegister = false; + _fullNameController.clear(); + _usernameController.text = registeredUsername; + _passwordController.clear(); + _confirmPasswordController.clear(); + _errorMessage = null; + }); + showCompactSnackBar( + context, + message: 'Registrasi berhasil. Silakan login.', + icon: Icons.check_circle_outline, + ); + return; + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + String _studentGender = ''; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _initializeShell(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => 'confivoice_student_names_shared'; + + Future _initializeShell() async { + await _loadApiEndpoint(); + await _loadStudentNames(); + } + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final localNames = + preferences.getStringList(_studentNamesPreferenceKey) ?? []; + final savedResultNames = await _loadStudentNamesFromSavedResults(); + final names = _normalizeStudentNames([ + ...localNames, + ...savedResultNames, + ]); + await preferences.setStringList(_studentNamesPreferenceKey, names); + if (!mounted) { + return; + } + setState(() { + _studentNames = names; + }); + } catch (_) {} + } + + Future> _loadStudentNamesFromSavedResults() async { + try { + final response = await http + .get(_apiEndpoint('/predictions')) + .timeout(const Duration(seconds: 12)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + return const []; + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map((item) => (item as Map)['student_name']?.toString() ?? '') + .where((name) => _cleanStudentName(name).isNotEmpty) + .toList(); + } catch (_) { + return const []; + } + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + if (_studentGender.isEmpty) { + setState(() { + _errorMessage = 'Pilih jenis kelamin siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.fields['student_gender'] = _studentGender; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _studentGender = ''; + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'limit': '1000'}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + final displayUserName = + widget.session.fullName.trim().isEmpty + ? widget.session.username + : widget.session.fullName.trim(); + + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + Padding( + padding: const EdgeInsets.only(right: 12), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 92), + child: Text( + displayUserName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.text, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + studentGender: _studentGender, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onGenderChanged: (value) { + setState(() { + _studentGender = value ?? ''; + _prediction = null; + _errorMessage = null; + }); + }, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Pengaturan'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () { + Navigator.pop(context); + _confirmLogout(); + }, + child: const Text('Logout'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + final groups = StudentAnalysisGroup.fromAnalyses(analyses); + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: groups.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final group = groups[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: const Icon(Icons.folder_outlined), + ), + title: Text(group.studentName), + subtitle: Text( + '${group.latest.genderLabel} | ${group.count} hasil analisis\nRata-rata PD: ${group.averagePdPercent}%', + ), + isThreeLine: true, + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => + StudentAnalysisDetailPage(group: group), + ), + ); + }, + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class StudentAnalysisGroup { + const StudentAnalysisGroup({ + required this.studentName, + required this.analyses, + }); + + final String studentName; + final List analyses; + + SavedAnalysis get latest => analyses.first; + int get count => analyses.length; + String get averagePdPercent { + if (analyses.isEmpty) { + return '0.0'; + } + final average = + analyses.fold( + 0, + (total, analysis) => total + analysis.probabilityPd, + ) / + analyses.length; + return (average * 100).toStringAsFixed(1); + } + + static List fromAnalyses(List analyses) { + final grouped = >{}; + final displayNames = {}; + + for (final analysis in analyses) { + final name = + analysis.studentName.trim().isEmpty + ? '-' + : analysis.studentName.trim(); + final key = name.toLowerCase(); + displayNames.putIfAbsent(key, () => name); + grouped.putIfAbsent(key, () => []).add(analysis); + } + + final groups = + grouped.entries + .map( + (entry) => StudentAnalysisGroup( + studentName: displayNames[entry.key] ?? entry.key, + analyses: entry.value, + ), + ) + .toList(); + groups.sort( + (a, b) => + a.studentName.toLowerCase().compareTo(b.studentName.toLowerCase()), + ); + return groups; + } +} + +class StudentAnalysisDetailPage extends StatelessWidget { + const StudentAnalysisDetailPage({super.key, required this.group}); + + final StudentAnalysisGroup group; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(group.studentName)), + body: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: group.analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = group.analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text('Prediksi ${index + 1}'), + subtitle: Text( + '${analysis.genderLabel} | ${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.studentGender, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + studentGender: json['student_gender']?.toString() ?? '', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String studentGender; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.studentGender, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + studentGender: json['student_gender']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId, String studentGender) { + return { + ...rawJson, + 'student_name': studentName, + 'student_gender': studentGender, + 'user_id': userId, + }; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final String studentGender; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; + + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({required this.value, required this.onChanged}); + + final String value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent% + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260620010045.dart b/.history/cv_app/mobile_app/lib/main_20260620010045.dart new file mode 100644 index 0000000..f34c494 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260620010045.dart @@ -0,0 +1,2777 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar(context, message: 'Berhasil logout'); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + await _saveApiEndpoint(); + if (_isRegister) { + if (!mounted) { + return; + } + final registeredUsername = username; + setState(() { + _isRegister = false; + _fullNameController.clear(); + _usernameController.text = registeredUsername; + _passwordController.clear(); + _confirmPasswordController.clear(); + _errorMessage = null; + }); + showCompactSnackBar( + context, + message: 'Registrasi berhasil. Silakan login.', + icon: Icons.check_circle_outline, + ); + return; + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + String _studentGender = ''; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _initializeShell(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => 'confivoice_student_names_shared'; + + Future _initializeShell() async { + await _loadApiEndpoint(); + await _loadStudentNames(); + } + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final localNames = + preferences.getStringList(_studentNamesPreferenceKey) ?? []; + final savedResultNames = await _loadStudentNamesFromSavedResults(); + final names = _normalizeStudentNames([ + ...localNames, + ...savedResultNames, + ]); + await preferences.setStringList(_studentNamesPreferenceKey, names); + if (!mounted) { + return; + } + setState(() { + _studentNames = names; + }); + } catch (_) {} + } + + Future> _loadStudentNamesFromSavedResults() async { + try { + final response = await http + .get(_apiEndpoint('/predictions')) + .timeout(const Duration(seconds: 12)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + return const []; + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map((item) => (item as Map)['student_name']?.toString() ?? '') + .where((name) => _cleanStudentName(name).isNotEmpty) + .toList(); + } catch (_) { + return const []; + } + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + if (_studentGender.isEmpty) { + setState(() { + _errorMessage = 'Pilih jenis kelamin siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.fields['student_gender'] = _studentGender; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _studentGender = ''; + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'limit': '1000'}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + final displayUserName = + widget.session.fullName.trim().isEmpty + ? widget.session.username + : widget.session.fullName.trim(); + + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + Padding( + padding: const EdgeInsets.only(right: 12), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 92), + child: Text( + displayUserName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.text, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + studentGender: _studentGender, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onGenderChanged: (value) { + setState(() { + _studentGender = value ?? ''; + _prediction = null; + _errorMessage = null; + }); + }, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Pengaturan'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () { + Navigator.pop(context); + _confirmLogout(); + }, + child: const Text('Logout'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + final groups = StudentAnalysisGroup.fromAnalyses(analyses); + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: groups.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final group = groups[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: const Icon(Icons.folder_outlined), + ), + title: Text(group.studentName), + subtitle: Text( + '${group.latest.genderLabel} | ${group.count} hasil analisis\nRata-rata PD: ${group.averagePdPercent}%', + ), + isThreeLine: true, + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => + StudentAnalysisDetailPage(group: group), + ), + ); + }, + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class StudentAnalysisGroup { + const StudentAnalysisGroup({ + required this.studentName, + required this.analyses, + }); + + final String studentName; + final List analyses; + + SavedAnalysis get latest => analyses.first; + int get count => analyses.length; + String get averagePdPercent { + if (analyses.isEmpty) { + return '0.0'; + } + final average = + analyses.fold( + 0, + (total, analysis) => total + analysis.probabilityPd, + ) / + analyses.length; + return (average * 100).toStringAsFixed(1); + } + + static List fromAnalyses(List analyses) { + final grouped = >{}; + final displayNames = {}; + + for (final analysis in analyses) { + final name = + analysis.studentName.trim().isEmpty + ? '-' + : analysis.studentName.trim(); + final key = name.toLowerCase(); + displayNames.putIfAbsent(key, () => name); + grouped.putIfAbsent(key, () => []).add(analysis); + } + + final groups = + grouped.entries + .map( + (entry) => StudentAnalysisGroup( + studentName: displayNames[entry.key] ?? entry.key, + analyses: entry.value, + ), + ) + .toList(); + groups.sort( + (a, b) => + a.studentName.toLowerCase().compareTo(b.studentName.toLowerCase()), + ); + return groups; + } +} + +class StudentAnalysisDetailPage extends StatelessWidget { + const StudentAnalysisDetailPage({super.key, required this.group}); + + final StudentAnalysisGroup group; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(group.studentName)), + body: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: group.analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = group.analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text('Prediksi ${index + 1}'), + subtitle: Text( + '${analysis.genderLabel} | ${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.studentGender, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + studentGender: json['student_gender']?.toString() ?? '', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String studentGender; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.studentGender, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + studentGender: json['student_gender']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId, String studentGender) { + return { + ...rawJson, + 'student_name': studentName, + 'student_gender': studentGender, + 'user_id': userId, + }; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final String studentGender; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; + + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({required this.value, required this.onChanged}); + + final String value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent%' + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260620010046.dart b/.history/cv_app/mobile_app/lib/main_20260620010046.dart new file mode 100644 index 0000000..2a06f2b --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260620010046.dart @@ -0,0 +1,2777 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar(context, message: 'Berhasil logout'); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + await _saveApiEndpoint(); + if (_isRegister) { + if (!mounted) { + return; + } + final registeredUsername = username; + setState(() { + _isRegister = false; + _fullNameController.clear(); + _usernameController.text = registeredUsername; + _passwordController.clear(); + _confirmPasswordController.clear(); + _errorMessage = null; + }); + showCompactSnackBar( + context, + message: 'Registrasi berhasil. Silakan login.', + icon: Icons.check_circle_outline, + ); + return; + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + String _studentGender = ''; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _initializeShell(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => 'confivoice_student_names_shared'; + + Future _initializeShell() async { + await _loadApiEndpoint(); + await _loadStudentNames(); + } + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final localNames = + preferences.getStringList(_studentNamesPreferenceKey) ?? []; + final savedResultNames = await _loadStudentNamesFromSavedResults(); + final names = _normalizeStudentNames([ + ...localNames, + ...savedResultNames, + ]); + await preferences.setStringList(_studentNamesPreferenceKey, names); + if (!mounted) { + return; + } + setState(() { + _studentNames = names; + }); + } catch (_) {} + } + + Future> _loadStudentNamesFromSavedResults() async { + try { + final response = await http + .get(_apiEndpoint('/predictions')) + .timeout(const Duration(seconds: 12)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + return const []; + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map((item) => (item as Map)['student_name']?.toString() ?? '') + .where((name) => _cleanStudentName(name).isNotEmpty) + .toList(); + } catch (_) { + return const []; + } + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + if (_studentGender.isEmpty) { + setState(() { + _errorMessage = 'Pilih jenis kelamin siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.fields['student_gender'] = _studentGender; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _studentGender = ''; + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'limit': '1000'}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + final displayUserName = + widget.session.fullName.trim().isEmpty + ? widget.session.username + : widget.session.fullName.trim(); + + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + Padding( + padding: const EdgeInsets.only(right: 12), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 92), + child: Text( + displayUserName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.text, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + studentGender: _studentGender, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onGenderChanged: (value) { + setState(() { + _studentGender = value ?? ''; + _prediction = null; + _errorMessage = null; + }); + }, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Pengaturan'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () { + Navigator.pop(context); + _confirmLogout(); + }, + child: const Text('Logout'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + final groups = StudentAnalysisGroup.fromAnalyses(analyses); + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: groups.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final group = groups[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: const Icon(Icons.folder_outlined), + ), + title: Text(group.studentName), + subtitle: Text( + '${group.latest.genderLabel} | ${group.count} hasil analisis\nRata-rata PD: ${group.averagePdPercent}%', + ), + isThreeLine: true, + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => + StudentAnalysisDetailPage(group: group), + ), + ); + }, + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class StudentAnalysisGroup { + const StudentAnalysisGroup({ + required this.studentName, + required this.analyses, + }); + + final String studentName; + final List analyses; + + SavedAnalysis get latest => analyses.first; + int get count => analyses.length; + String get averagePdPercent { + if (analyses.isEmpty) { + return '0.0'; + } + final average = + analyses.fold( + 0, + (total, analysis) => total + analysis.probabilityPd, + ) / + analyses.length; + return (average * 100).toStringAsFixed(1); + } + + static List fromAnalyses(List analyses) { + final grouped = >{}; + final displayNames = {}; + + for (final analysis in analyses) { + final name = + analysis.studentName.trim().isEmpty + ? '-' + : analysis.studentName.trim(); + final key = name.toLowerCase(); + displayNames.putIfAbsent(key, () => name); + grouped.putIfAbsent(key, () => []).add(analysis); + } + + final groups = + grouped.entries + .map( + (entry) => StudentAnalysisGroup( + studentName: displayNames[entry.key] ?? entry.key, + analyses: entry.value, + ), + ) + .toList(); + groups.sort( + (a, b) => + a.studentName.toLowerCase().compareTo(b.studentName.toLowerCase()), + ); + return groups; + } +} + +class StudentAnalysisDetailPage extends StatelessWidget { + const StudentAnalysisDetailPage({super.key, required this.group}); + + final StudentAnalysisGroup group; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(group.studentName)), + body: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: group.analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = group.analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text('Prediksi ${index + 1}'), + subtitle: Text( + '${analysis.genderLabel} | ${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.studentGender, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + studentGender: json['student_gender']?.toString() ?? '', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String studentGender; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.studentGender, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + studentGender: json['student_gender']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId, String studentGender) { + return { + ...rawJson, + 'student_name': studentName, + 'student_gender': studentGender, + 'user_id': userId, + }; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final String studentGender; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; + + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({required this.value, required this.onChanged}); + + final String value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260621041203.dart b/.history/cv_app/mobile_app/lib/main_20260621041203.dart new file mode 100644 index 0000000..3d375bc --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260621041203.dart @@ -0,0 +1,21 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +part 'app/app.dart'; +part 'fitur/auth/auth.dart'; +part 'fitur/home/confivoice_shell.dart'; +part 'fitur/saved_analyses/saved_analyses_page.dart'; +part 'models/analysis_models.dart'; +part 'fitur/prediction/prediction_page.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} diff --git a/.history/cv_web/app_20260615001457.py b/.history/cv_web/app_20260615001457.py new file mode 100644 index 0000000..2f4f262 --- /dev/null +++ b/.history/cv_web/app_20260615001457.py @@ -0,0 +1,600 @@ +from html import escape +from pathlib import Path +import sys + +from fastapi import FastAPI, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + delete_prediction_result, + get_database_label, + get_prediction_result, + init_db, + list_prediction_results, + save_prediction_result, + update_prediction_result, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page(): + rows = list_prediction_results(limit=500) + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = sum(float(row["probability_pd"] or 0) for row in rows) / total if total else 0 + + return _html_page( + title="Admin ConfiVoice", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total data{total}
+
Percaya diri{pd_count}
+
Tidak percaya diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Tambah Data

+

Gunakan form ini jika perlu menambahkan hasil analisis secara manual.

+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+
+
+
+

Data Analisis

+

Kelola data yang sudah tersimpan.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _html_page(title, content): + return f""" + + + + + + {escape(title)} + + + + {content} + + + """ diff --git a/.history/cv_web/app_20260615001816.py b/.history/cv_web/app_20260615001816.py new file mode 100644 index 0000000..bf7cb01 --- /dev/null +++ b/.history/cv_web/app_20260615001816.py @@ -0,0 +1,600 @@ +from html import escape +from pathlib import Path +import sys + +from fastapi import FastAPI, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + delete_prediction_result, + get_database_label, + get_prediction_result, + init_db, + list_prediction_results, + save_prediction_result, + update_prediction_result, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page(): + rows = list_prediction_results(limit=500) + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = sum(float(row["probability_pd"] or 0) for row in rows) / total if total else 0 + + return _html_page( + title="Admin ConfiVoice", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total data{total}
+
Percaya diri{pd_count}
+
Tidak percaya diri{tpd_count}
+
Rata-rata PercayaD{avg_pd * 100:.1f}%
+
+
+
+
+

Tambah Data

+

Gunakan form ini jika perlu menambahkan hasil analisis secara manual.

+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+
+
+
+

Data Analisis

+

Kelola data yang sudah tersimpan.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _html_page(title, content): + return f""" + + + + + + {escape(title)} + + + + {content} + + + """ diff --git a/.history/cv_web/app_20260615001819.py b/.history/cv_web/app_20260615001819.py new file mode 100644 index 0000000..41f76a0 --- /dev/null +++ b/.history/cv_web/app_20260615001819.py @@ -0,0 +1,600 @@ +from html import escape +from pathlib import Path +import sys + +from fastapi import FastAPI, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + delete_prediction_result, + get_database_label, + get_prediction_result, + init_db, + list_prediction_results, + save_prediction_result, + update_prediction_result, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page(): + rows = list_prediction_results(limit=500) + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = sum(float(row["probability_pd"] or 0) for row in rows) / total if total else 0 + + return _html_page( + title="Admin ConfiVoice", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total data{total}
+
Percaya diri{pd_count}
+
Tidak percaya diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Tambah Data

+

Gunakan form ini jika perlu menambahkan hasil analisis secara manual.

+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+
+
+
+

Data Analisis

+

Kelola data yang sudah tersimpan.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _html_page(title, content): + return f""" + + + + + + {escape(title)} + + + + {content} + + + """ diff --git a/.history/cv_web/app_20260615001824.py b/.history/cv_web/app_20260615001824.py new file mode 100644 index 0000000..0030430 --- /dev/null +++ b/.history/cv_web/app_20260615001824.py @@ -0,0 +1,600 @@ +from html import escape +from pathlib import Path +import sys + +from fastapi import FastAPI, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + delete_prediction_result, + get_database_label, + get_prediction_result, + init_db, + list_prediction_results, + save_prediction_result, + update_prediction_result, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page(): + rows = list_prediction_results(limit=500) + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = sum(float(row["probability_pd"] or 0) for row in rows) / total if total else 0 + + return _html_page( + title="Admin ConfiVoice", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total data{total}
+
Percaya diri{pd_count}
+
Tidak ercaya diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Tambah Data

+

Gunakan form ini jika perlu menambahkan hasil analisis secara manual.

+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+
+
+
+

Data Analisis

+

Kelola data yang sudah tersimpan.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _html_page(title, content): + return f""" + + + + + + {escape(title)} + + + + {content} + + + """ diff --git a/.history/cv_web/app_20260615001825.py b/.history/cv_web/app_20260615001825.py new file mode 100644 index 0000000..42a8173 --- /dev/null +++ b/.history/cv_web/app_20260615001825.py @@ -0,0 +1,600 @@ +from html import escape +from pathlib import Path +import sys + +from fastapi import FastAPI, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + delete_prediction_result, + get_database_label, + get_prediction_result, + init_db, + list_prediction_results, + save_prediction_result, + update_prediction_result, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page(): + rows = list_prediction_results(limit=500) + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = sum(float(row["probability_pd"] or 0) for row in rows) / total if total else 0 + + return _html_page( + title="Admin ConfiVoice", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total data{total}
+
Percaya diri{pd_count}
+
Tidak Percaya diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Tambah Data

+

Gunakan form ini jika perlu menambahkan hasil analisis secara manual.

+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+
+
+
+

Data Analisis

+

Kelola data yang sudah tersimpan.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _html_page(title, content): + return f""" + + + + + + {escape(title)} + + + + {content} + + + """ diff --git a/.history/cv_web/app_20260615001828.py b/.history/cv_web/app_20260615001828.py new file mode 100644 index 0000000..cc4b275 --- /dev/null +++ b/.history/cv_web/app_20260615001828.py @@ -0,0 +1,600 @@ +from html import escape +from pathlib import Path +import sys + +from fastapi import FastAPI, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + delete_prediction_result, + get_database_label, + get_prediction_result, + init_db, + list_prediction_results, + save_prediction_result, + update_prediction_result, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page(): + rows = list_prediction_results(limit=500) + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = sum(float(row["probability_pd"] or 0) for row in rows) / total if total else 0 + + return _html_page( + title="Admin ConfiVoice", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total data{total}
+
Percaya diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Tambah Data

+

Gunakan form ini jika perlu menambahkan hasil analisis secara manual.

+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+
+
+
+

Data Analisis

+

Kelola data yang sudah tersimpan.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _html_page(title, content): + return f""" + + + + + + {escape(title)} + + + + {content} + + + """ diff --git a/.history/cv_web/app_20260615001833.py b/.history/cv_web/app_20260615001833.py new file mode 100644 index 0000000..876ffd4 --- /dev/null +++ b/.history/cv_web/app_20260615001833.py @@ -0,0 +1,600 @@ +from html import escape +from pathlib import Path +import sys + +from fastapi import FastAPI, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + delete_prediction_result, + get_database_label, + get_prediction_result, + init_db, + list_prediction_results, + save_prediction_result, + update_prediction_result, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page(): + rows = list_prediction_results(limit=500) + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = sum(float(row["probability_pd"] or 0) for row in rows) / total if total else 0 + + return _html_page( + title="Admin ConfiVoice", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total data{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Tambah Data

+

Gunakan form ini jika perlu menambahkan hasil analisis secara manual.

+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+
+
+
+

Data Analisis

+

Kelola data yang sudah tersimpan.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _html_page(title, content): + return f""" + + + + + + {escape(title)} + + + + {content} + + + """ diff --git a/.history/cv_web/app_20260615001835.py b/.history/cv_web/app_20260615001835.py new file mode 100644 index 0000000..c7d5321 --- /dev/null +++ b/.history/cv_web/app_20260615001835.py @@ -0,0 +1,600 @@ +from html import escape +from pathlib import Path +import sys + +from fastapi import FastAPI, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + delete_prediction_result, + get_database_label, + get_prediction_result, + init_db, + list_prediction_results, + save_prediction_result, + update_prediction_result, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page(): + rows = list_prediction_results(limit=500) + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = sum(float(row["probability_pd"] or 0) for row in rows) / total if total else 0 + + return _html_page( + title="Admin ConfiVoice", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Tambah Data

+

Gunakan form ini jika perlu menambahkan hasil analisis secara manual.

+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+
+
+
+

Data Analisis

+

Kelola data yang sudah tersimpan.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _html_page(title, content): + return f""" + + + + + + {escape(title)} + + + + {content} + + + """ diff --git a/.history/cv_web/app_20260615003441.py b/.history/cv_web/app_20260615003441.py new file mode 100644 index 0000000..566bf93 --- /dev/null +++ b/.history/cv_web/app_20260615003441.py @@ -0,0 +1,600 @@ +from html import escape +from pathlib import Path +import sys + +from fastapi import FastAPI, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + delete_prediction_result, + get_database_label, + get_prediction_result, + init_db, + list_prediction_results, + save_prediction_result, + update_prediction_result, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page(): + rows = list_prediction_results(limit=500) + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = sum(float(row["probability_pd"] or 0) for row in rows) / total if total else 0 + + return _html_page( + title="Admin ConfiVoice", + content=""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Tambah Data

+

Gunakan form ini jika perlu menambahkan hasil analisis secara manual.

+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+
+
+
+

Data Analisis

+

Kelola data yang sudah tersimpan.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _html_page(title, content): + return f""" + + + + + + {escape(title)} + + + + {content} + + + """ diff --git a/.history/cv_web/app_20260615003446.py b/.history/cv_web/app_20260615003446.py new file mode 100644 index 0000000..c7d5321 --- /dev/null +++ b/.history/cv_web/app_20260615003446.py @@ -0,0 +1,600 @@ +from html import escape +from pathlib import Path +import sys + +from fastapi import FastAPI, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + delete_prediction_result, + get_database_label, + get_prediction_result, + init_db, + list_prediction_results, + save_prediction_result, + update_prediction_result, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page(): + rows = list_prediction_results(limit=500) + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = sum(float(row["probability_pd"] or 0) for row in rows) / total if total else 0 + + return _html_page( + title="Admin ConfiVoice", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Tambah Data

+

Gunakan form ini jika perlu menambahkan hasil analisis secara manual.

+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+
+
+
+

Data Analisis

+

Kelola data yang sudah tersimpan.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _html_page(title, content): + return f""" + + + + + + {escape(title)} + + + + {content} + + + """ diff --git a/.history/cv_web/app_20260615003447.py b/.history/cv_web/app_20260615003447.py new file mode 100644 index 0000000..c7d5321 --- /dev/null +++ b/.history/cv_web/app_20260615003447.py @@ -0,0 +1,600 @@ +from html import escape +from pathlib import Path +import sys + +from fastapi import FastAPI, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + delete_prediction_result, + get_database_label, + get_prediction_result, + init_db, + list_prediction_results, + save_prediction_result, + update_prediction_result, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page(): + rows = list_prediction_results(limit=500) + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = sum(float(row["probability_pd"] or 0) for row in rows) / total if total else 0 + + return _html_page( + title="Admin ConfiVoice", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Tambah Data

+

Gunakan form ini jika perlu menambahkan hasil analisis secara manual.

+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+
+
+
+

Data Analisis

+

Kelola data yang sudah tersimpan.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _html_page(title, content): + return f""" + + + + + + {escape(title)} + + + + {content} + + + """ diff --git a/.history/cv_web/app_20260618000217.py b/.history/cv_web/app_20260618000217.py new file mode 100644 index 0000000..2bfee9d --- /dev/null +++ b/.history/cv_web/app_20260618000217.py @@ -0,0 +1,600 @@ +from html import escape +from pathlib import Path +import sys + +from fastapi import FastAPI, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + delete_prediction_result, + get_database_label, + get_prediction_result, + init_db, + list_prediction_results, + save_prediction_result, + update_prediction_result, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page(): + rows = list_prediction_results(limit=500) + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = sum(float(row["probability_pd"] or 0) for row in rows) / total if total else 0 + + return _html_page( + title="Admin ConfiVoice", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Tambah Data

+

Gunakan form ini jika perlu menambahkan hasil analisis secara manual.

+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+
+
+
+

Data Analisis

+

Kelola data yang sudah tersimpan.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _html_page(title, content): + return f""" + + + + + + {escape(title)} + + + + {content} + + + """ diff --git a/.history/cv_web/app_20260618000256.py b/.history/cv_web/app_20260618000256.py new file mode 100644 index 0000000..c7d5321 --- /dev/null +++ b/.history/cv_web/app_20260618000256.py @@ -0,0 +1,600 @@ +from html import escape +from pathlib import Path +import sys + +from fastapi import FastAPI, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + delete_prediction_result, + get_database_label, + get_prediction_result, + init_db, + list_prediction_results, + save_prediction_result, + update_prediction_result, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page(): + rows = list_prediction_results(limit=500) + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = sum(float(row["probability_pd"] or 0) for row in rows) / total if total else 0 + + return _html_page( + title="Admin ConfiVoice", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Tambah Data

+

Gunakan form ini jika perlu menambahkan hasil analisis secara manual.

+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+
+
+
+

Data Analisis

+

Kelola data yang sudah tersimpan.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _html_page(title, content): + return f""" + + + + + + {escape(title)} + + + + {content} + + + """ diff --git a/.history/cv_web/app_20260618014739.py b/.history/cv_web/app_20260618014739.py new file mode 100644 index 0000000..7ce096f --- /dev/null +++ b/.history/cv_web/app_20260618014739.py @@ -0,0 +1,1416 @@ +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + return _html_page( + title="Logout", + active="logout", + content=""" +
+

Logout

+

Sesi admin ditutup. Saat ini web admin belum memakai session login khusus, jadi halaman ini menjadi pintu keluar visual.

+ Kembali ke Dashboard +
+ """, + ) + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + """ diff --git a/.history/cv_web/app_20260618014757.py b/.history/cv_web/app_20260618014757.py new file mode 100644 index 0000000..fb070bd --- /dev/null +++ b/.history/cv_web/app_20260618014757.py @@ -0,0 +1,1416 @@ +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + return _html_page( + title="Logout", + active="logout", + content=""" +
+

Logout

+

Sesi admin ditutup. Saat ini web admin belum memakai session login khusus, jadi halaman ini menjadi pintu keluar visual.

+ Kembali ke Dashboard +
+ """, + ) + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + """ diff --git a/.history/cv_web/app_20260618014941.py b/.history/cv_web/app_20260618014941.py new file mode 100644 index 0000000..3aff5c1 --- /dev/null +++ b/.history/cv_web/app_20260618014941.py @@ -0,0 +1,1426 @@ +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + return _html_page( + title="Logout", + active="logout", + content=""" +
+

Logout

+

Sesi admin ditutup. Saat ini web admin belum memakai session login khusus, jadi halaman ini menjadi pintu keluar visual.

+ Kembali ke Dashboard +
+ """, + ) + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + """ diff --git a/.history/cv_web/app_20260618014956.py b/.history/cv_web/app_20260618014956.py new file mode 100644 index 0000000..fb070bd --- /dev/null +++ b/.history/cv_web/app_20260618014956.py @@ -0,0 +1,1416 @@ +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + return _html_page( + title="Logout", + active="logout", + content=""" +
+

Logout

+

Sesi admin ditutup. Saat ini web admin belum memakai session login khusus, jadi halaman ini menjadi pintu keluar visual.

+ Kembali ke Dashboard +
+ """, + ) + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + """ diff --git a/.history/cv_web/app_20260618015004.py b/.history/cv_web/app_20260618015004.py new file mode 100644 index 0000000..21ed8c7 --- /dev/null +++ b/.history/cv_web/app_20260618015004.py @@ -0,0 +1,1426 @@ +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + return _html_page( + title="Logout", + active="logout", + content=""" +
+

Logout

+

Sesi admin ditutup. Saat ini web admin belum memakai session login khusus, jadi halaman ini menjadi pintu keluar visual.

+ Kembali ke Dashboard +
+ """, + ) + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ + """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + """ diff --git a/.history/cv_web/app_20260618015018.py b/.history/cv_web/app_20260618015018.py new file mode 100644 index 0000000..fb070bd --- /dev/null +++ b/.history/cv_web/app_20260618015018.py @@ -0,0 +1,1416 @@ +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + return _html_page( + title="Logout", + active="logout", + content=""" +
+

Logout

+

Sesi admin ditutup. Saat ini web admin belum memakai session login khusus, jadi halaman ini menjadi pintu keluar visual.

+ Kembali ke Dashboard +
+ """, + ) + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + """ diff --git a/.history/cv_web/app_20260618021000.py b/.history/cv_web/app_20260618021000.py new file mode 100644 index 0000000..53a22b5 --- /dev/null +++ b/.history/cv_web/app_20260618021000.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Confivoice +
+
+ +

{title}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk ke pane ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021004.py b/.history/cv_web/app_20260618021004.py new file mode 100644 index 0000000..888d202 --- /dev/null +++ b/.history/cv_web/app_20260618021004.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Confivoice +
+
+ +

{title}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021006.py b/.history/cv_web/app_20260618021006.py new file mode 100644 index 0000000..e7cc444 --- /dev/null +++ b/.history/cv_web/app_20260618021006.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Confivoice +
+
+ +

{title}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021008.py b/.history/cv_web/app_20260618021008.py new file mode 100644 index 0000000..1ae2f9b --- /dev/null +++ b/.history/cv_web/app_20260618021008.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Confivoice +
+
+ +

{title}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021109.py b/.history/cv_web/app_20260618021109.py new file mode 100644 index 0000000..18e5763 --- /dev/null +++ b/.history/cv_web/app_20260618021109.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{title}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021131.py b/.history/cv_web/app_20260618021131.py new file mode 100644 index 0000000..68296b6 --- /dev/null +++ b/.history/cv_web/app_20260618021131.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021132.py b/.history/cv_web/app_20260618021132.py new file mode 100644 index 0000000..c409755 --- /dev/null +++ b/.history/cv_web/app_20260618021132.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{}}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021134.py b/.history/cv_web/app_20260618021134.py new file mode 100644 index 0000000..68296b6 --- /dev/null +++ b/.history/cv_web/app_20260618021134.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021137.py b/.history/cv_web/app_20260618021137.py new file mode 100644 index 0000000..18e5763 --- /dev/null +++ b/.history/cv_web/app_20260618021137.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{title}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021139.py b/.history/cv_web/app_20260618021139.py new file mode 100644 index 0000000..18e5763 --- /dev/null +++ b/.history/cv_web/app_20260618021139.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{title}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021141.py b/.history/cv_web/app_20260618021141.py new file mode 100644 index 0000000..812529a --- /dev/null +++ b/.history/cv_web/app_20260618021141.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021143.py b/.history/cv_web/app_20260618021143.py new file mode 100644 index 0000000..4b389f9 --- /dev/null +++ b/.history/cv_web/app_20260618021143.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{Masuk}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021209.py b/.history/cv_web/app_20260618021209.py new file mode 100644 index 0000000..812529a --- /dev/null +++ b/.history/cv_web/app_20260618021209.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021216.py b/.history/cv_web/app_20260618021216.py new file mode 100644 index 0000000..18e5763 --- /dev/null +++ b/.history/cv_web/app_20260618021216.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{title}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021420.py b/.history/cv_web/app_20260618021420.py new file mode 100644 index 0000000..eb974f4 --- /dev/null +++ b/.history/cv_web/app_20260618021420.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{title}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021440.py b/.history/cv_web/app_20260618021440.py new file mode 100644 index 0000000..18e5763 --- /dev/null +++ b/.history/cv_web/app_20260618021440.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{title}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021614.py b/.history/cv_web/app_20260618021614.py new file mode 100644 index 0000000..18e5763 --- /dev/null +++ b/.history/cv_web/app_20260618021614.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{title}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021620.py b/.history/cv_web/app_20260618021620.py new file mode 100644 index 0000000..f3e7992 --- /dev/null +++ b/.history/cv_web/app_20260618021620.py @@ -0,0 +1,1975 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021623.py b/.history/cv_web/app_20260618021623.py new file mode 100644 index 0000000..18e5763 --- /dev/null +++ b/.history/cv_web/app_20260618021623.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{title}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021625.py b/.history/cv_web/app_20260618021625.py new file mode 100644 index 0000000..f3e7992 --- /dev/null +++ b/.history/cv_web/app_20260618021625.py @@ -0,0 +1,1975 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021629.py b/.history/cv_web/app_20260618021629.py new file mode 100644 index 0000000..18e5763 --- /dev/null +++ b/.history/cv_web/app_20260618021629.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{title}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021632.py b/.history/cv_web/app_20260618021632.py new file mode 100644 index 0000000..f3e7992 --- /dev/null +++ b/.history/cv_web/app_20260618021632.py @@ -0,0 +1,1975 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021634.py b/.history/cv_web/app_20260618021634.py new file mode 100644 index 0000000..18e5763 --- /dev/null +++ b/.history/cv_web/app_20260618021634.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{title}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021639.py b/.history/cv_web/app_20260618021639.py new file mode 100644 index 0000000..f3e7992 --- /dev/null +++ b/.history/cv_web/app_20260618021639.py @@ -0,0 +1,1975 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618031442.py b/.history/cv_web/app_20260618031442.py new file mode 100644 index 0000000..8826feb --- /dev/null +++ b/.history/cv_web/app_20260618031442.py @@ -0,0 +1,1975 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{'Buat akun Admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260622073911.py b/.history/cv_web/app_20260622073911.py new file mode 100644 index 0000000..f51c1cd --- /dev/null +++ b/.history/cv_web/app_20260622073911.py @@ -0,0 +1,1993 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return HTMLResponse(_auth_page(mode="login")) + + +@app.post("/admin/login", response_class=HTMLResponse) +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return HTMLResponse( + _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return HTMLResponse(_auth_page(mode="register")) + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return HTMLResponse( + _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + ) + if len(username) < 3: + return HTMLResponse( + _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + ) + if len(password) < 6: + return HTMLResponse( + _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + ) + if password != confirm_password: + return HTMLResponse( + _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return HTMLResponse( + _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + ) + + return HTMLResponse( + _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + eye_icon_json = json.dumps(eye_icon) + eye_off_icon_json = json.dumps(eye_off_icon) + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + f""" + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{'Buat akun Admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/project_svm_suara/app_20260524001201.py b/.history/project_svm_suara/app_20260524001201.py new file mode 100644 index 0000000..a614ef5 --- /dev/null +++ b/.history/project_svm_suara/app_20260524001201.py @@ -0,0 +1,120 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.60 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime dipakai sebagai cache key. + Jika model dilatih ulang dan file berubah, Streamlit otomatis memuat model baru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + # Semua input disiapkan ulang menjadi WAV mono 22050 Hz sebelum ekstraksi fitur. + try: + convert_to_wav(temp_input_path, temp_wav_path) + features = extract_features(temp_wav_path).reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = model.predict(features)[0] + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + st.subheader("Hasil Prediksi") + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning( + "Model belum yakin, suara perlu direkam ulang atau data training perlu ditambah." + ) + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") diff --git a/.history/project_svm_suara/app_20260524002356.py b/.history/project_svm_suara/app_20260524002356.py new file mode 100644 index 0000000..ac64ccf --- /dev/null +++ b/.history/project_svm_suara/app_20260524002356.py @@ -0,0 +1,121 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime dipakai sebagai cache key. + Jika model dilatih ulang dan file berubah, Streamlit otomatis memuat model baru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + # Semua input disiapkan ulang menjadi WAV mono 22050 Hz sebelum ekstraksi fitur. + try: + convert_to_wav(temp_input_path, temp_wav_path) + features = extract_features(temp_wav_path).reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = model.predict(features)[0] + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + st.subheader("Hasil Prediksi") + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning( + "Model belum yakin, suara perlu direkam ulang atau data training perlu ditambah." + ) + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") diff --git a/.history/project_svm_suara/app_20260524002543.py b/.history/project_svm_suara/app_20260524002543.py new file mode 100644 index 0000000..478ccd7 --- /dev/null +++ b/.history/project_svm_suara/app_20260524002543.py @@ -0,0 +1,116 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime dipakai sebagai cache key. + Jika model dilatih ulang dan file berubah, Streamlit otomatis memuat model baru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + # Semua input disiapkan ulang menjadi WAV mono 22050 Hz sebelum ekstraksi fitur. + try: + convert_to_wav(temp_input_path, temp_wav_path) + features = extract_features(temp_wav_path).reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = model.predict(features)[0] + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + st.subheader("Hasil Prediksi") + probability_pd = probabilities.get(LABEL_PD, 0.0) +probability_tpd = probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + +if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") +else: + st.warning( + "Model belum cukup yakin. Suara perlu direkam ulang atau data training perlu ditambah." + ) + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") diff --git a/.history/project_svm_suara/app_20260524002610.py b/.history/project_svm_suara/app_20260524002610.py new file mode 100644 index 0000000..7945b3f --- /dev/null +++ b/.history/project_svm_suara/app_20260524002610.py @@ -0,0 +1,113 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime dipakai sebagai cache key. + Jika model dilatih ulang dan file berubah, Streamlit otomatis memuat model baru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + # Semua input disiapkan ulang menjadi WAV mono 22050 Hz sebelum ekstraksi fitur. + try: + convert_to_wav(temp_input_path, temp_wav_path) + features = extract_features(temp_wav_path).reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = model.predict(features)[0] + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + st.subheader("Hasil Prediksi") + probability_pd = probabilities.get(LABEL_PD, 0.0) +probability_tpd = probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + +if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") +else: + st.warning( + "Model belum cukup yakin. Suara perlu direkam ulang atau data training perlu ditambah." + ) diff --git a/.history/project_svm_suara/app_20260524005657.py b/.history/project_svm_suara/app_20260524005657.py new file mode 100644 index 0000000..4bab8a2 --- /dev/null +++ b/.history/project_svm_suara/app_20260524005657.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + probabilities = model.predict_proba(features)[0] +class_probabilities = dict(zip(model.classes_, probabilities)) + +predicted_label = max(class_probabilities, key=class_probabilities.get) +confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524005709.py b/.history/project_svm_suara/app_20260524005709.py new file mode 100644 index 0000000..7092270 --- /dev/null +++ b/.history/project_svm_suara/app_20260524005709.py @@ -0,0 +1,144 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = model.predict(features)[0] + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524005725.py b/.history/project_svm_suara/app_20260524005725.py new file mode 100644 index 0000000..4bab8a2 --- /dev/null +++ b/.history/project_svm_suara/app_20260524005725.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + probabilities = model.predict_proba(features)[0] +class_probabilities = dict(zip(model.classes_, probabilities)) + +predicted_label = max(class_probabilities, key=class_probabilities.get) +confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524005728.py b/.history/project_svm_suara/app_20260524005728.py new file mode 100644 index 0000000..7092270 --- /dev/null +++ b/.history/project_svm_suara/app_20260524005728.py @@ -0,0 +1,144 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = model.predict(features)[0] + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524005741.py b/.history/project_svm_suara/app_20260524005741.py new file mode 100644 index 0000000..a31a66f --- /dev/null +++ b/.history/project_svm_suara/app_20260524005741.py @@ -0,0 +1,144 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + probabilities = model.predict_proba(features)[0] + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524005752.py b/.history/project_svm_suara/app_20260524005752.py new file mode 100644 index 0000000..7092270 --- /dev/null +++ b/.history/project_svm_suara/app_20260524005752.py @@ -0,0 +1,144 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = model.predict(features)[0] + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524005804.py b/.history/project_svm_suara/app_20260524005804.py new file mode 100644 index 0000000..b68dd31 --- /dev/null +++ b/.history/project_svm_suara/app_20260524005804.py @@ -0,0 +1,144 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524005816.py b/.history/project_svm_suara/app_20260524005816.py new file mode 100644 index 0000000..b68dd31 --- /dev/null +++ b/.history/project_svm_suara/app_20260524005816.py @@ -0,0 +1,144 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524005826.py b/.history/project_svm_suara/app_20260524005826.py new file mode 100644 index 0000000..b68dd31 --- /dev/null +++ b/.history/project_svm_suara/app_20260524005826.py @@ -0,0 +1,144 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524005833.py b/.history/project_svm_suara/app_20260524005833.py new file mode 100644 index 0000000..b68dd31 --- /dev/null +++ b/.history/project_svm_suara/app_20260524005833.py @@ -0,0 +1,144 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524005852.py b/.history/project_svm_suara/app_20260524005852.py new file mode 100644 index 0000000..7092270 --- /dev/null +++ b/.history/project_svm_suara/app_20260524005852.py @@ -0,0 +1,144 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = model.predict(features)[0] + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524005856.py b/.history/project_svm_suara/app_20260524005856.py new file mode 100644 index 0000000..b68dd31 --- /dev/null +++ b/.history/project_svm_suara/app_20260524005856.py @@ -0,0 +1,144 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524005946.py b/.history/project_svm_suara/app_20260524005946.py new file mode 100644 index 0000000..d7a97cd --- /dev/null +++ b/.history/project_svm_suara/app_20260524005946.py @@ -0,0 +1,139 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524005949.py b/.history/project_svm_suara/app_20260524005949.py new file mode 100644 index 0000000..b68dd31 --- /dev/null +++ b/.history/project_svm_suara/app_20260524005949.py @@ -0,0 +1,144 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524005955.py b/.history/project_svm_suara/app_20260524005955.py new file mode 100644 index 0000000..19e5dac --- /dev/null +++ b/.history/project_svm_suara/app_20260524005955.py @@ -0,0 +1,137 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010002.py b/.history/project_svm_suara/app_20260524010002.py new file mode 100644 index 0000000..af5046d --- /dev/null +++ b/.history/project_svm_suara/app_20260524010002.py @@ -0,0 +1,138 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010003.py b/.history/project_svm_suara/app_20260524010003.py new file mode 100644 index 0000000..b68dd31 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010003.py @@ -0,0 +1,144 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010330.py b/.history/project_svm_suara/app_20260524010330.py new file mode 100644 index 0000000..154b4fa --- /dev/null +++ b/.history/project_svm_suara/app_20260524010330.py @@ -0,0 +1,146 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") +else: + st.warning( + "Model belum cukup yakin, silakan rekam ulang atau tambah data training." + ) + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010333.py b/.history/project_svm_suara/app_20260524010333.py new file mode 100644 index 0000000..0cae0d9 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010333.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") +else: + st.warning( + "Model belum cukup yakin, silakan rekam ulang atau tambah data training." + ) + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010359.py b/.history/project_svm_suara/app_20260524010359.py new file mode 100644 index 0000000..b68dd31 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010359.py @@ -0,0 +1,144 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010406.py b/.history/project_svm_suara/app_20260524010406.py new file mode 100644 index 0000000..0e5dfaf --- /dev/null +++ b/.history/project_svm_suara/app_20260524010406.py @@ -0,0 +1,140 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010408.py b/.history/project_svm_suara/app_20260524010408.py new file mode 100644 index 0000000..704a389 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010408.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") +if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") +else: + st.warning( + "Model belum cukup yakin, silakan rekam ulang atau tambah data training." + ) + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010409.py b/.history/project_svm_suara/app_20260524010409.py new file mode 100644 index 0000000..0e5dfaf --- /dev/null +++ b/.history/project_svm_suara/app_20260524010409.py @@ -0,0 +1,140 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010411.py b/.history/project_svm_suara/app_20260524010411.py new file mode 100644 index 0000000..83afb61 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010411.py @@ -0,0 +1,140 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010412.py b/.history/project_svm_suara/app_20260524010412.py new file mode 100644 index 0000000..154b4fa --- /dev/null +++ b/.history/project_svm_suara/app_20260524010412.py @@ -0,0 +1,146 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") +else: + st.warning( + "Model belum cukup yakin, silakan rekam ulang atau tambah data training." + ) + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010415.py b/.history/project_svm_suara/app_20260524010415.py new file mode 100644 index 0000000..b68dd31 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010415.py @@ -0,0 +1,144 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010658.py b/.history/project_svm_suara/app_20260524010658.py new file mode 100644 index 0000000..7dce970 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010658.py @@ -0,0 +1,146 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + probabilities = model.predict_proba(features)[0] +class_probabilities = dict(zip(model.classes_, probabilities)) + +predicted_label = max(class_probabilities, key=class_probabilities.get) +confidence = class_probabilities[predicted_label] + +probability_pd = class_probabilities.get(LABEL_PD, 0.0) +probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010707.py b/.history/project_svm_suara/app_20260524010707.py new file mode 100644 index 0000000..1402aa6 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010707.py @@ -0,0 +1,147 @@ +from pyexpat import model +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + probabilities = model.predict_proba(features)[0] +class_probabilities = dict(zip(model.classes_, probabilities)) + +predicted_label = max(class_probabilities, key=class_probabilities.get) +confidence = class_probabilities[predicted_label] + +probability_pd = class_probabilities.get(LABEL_PD, 0.0) +probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010708.py b/.history/project_svm_suara/app_20260524010708.py new file mode 100644 index 0000000..82acf32 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010708.py @@ -0,0 +1,147 @@ +from pyexpat import model +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + probabilities = model.predict_proba(features)[0] +class_probabilities = dict(zip(model.classes_, probabilities)) + +predicted_label = max(class_probabilities, key=class_probabilities.get) +confidence = class_probabilities[predicted_label] + +probability_pd = class_probabilities.get(LABEL_PD, 0.0) +probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list( model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010710.py b/.history/project_svm_suara/app_20260524010710.py new file mode 100644 index 0000000..7dce970 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010710.py @@ -0,0 +1,146 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + probabilities = model.predict_proba(features)[0] +class_probabilities = dict(zip(model.classes_, probabilities)) + +predicted_label = max(class_probabilities, key=class_probabilities.get) +confidence = class_probabilities[predicted_label] + +probability_pd = class_probabilities.get(LABEL_PD, 0.0) +probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010712.py b/.history/project_svm_suara/app_20260524010712.py new file mode 100644 index 0000000..b68dd31 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010712.py @@ -0,0 +1,144 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010723.py b/.history/project_svm_suara/app_20260524010723.py new file mode 100644 index 0000000..1af1167 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010723.py @@ -0,0 +1,137 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010725.py b/.history/project_svm_suara/app_20260524010725.py new file mode 100644 index 0000000..158fdb3 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010725.py @@ -0,0 +1,137 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010727.py b/.history/project_svm_suara/app_20260524010727.py new file mode 100644 index 0000000..ced9810 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010727.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + probabilities = model.predict_proba(features)[0] +class_probabilities = dict(zip(model.classes_, probabilities)) + +predicted_label = max(class_probabilities, key=class_probabilities.get) +confidence = class_probabilities[predicted_label] + +probability_pd = class_probabilities.get(LABEL_PD, 0.0) +probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010731.py b/.history/project_svm_suara/app_20260524010731.py new file mode 100644 index 0000000..179156b --- /dev/null +++ b/.history/project_svm_suara/app_20260524010731.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + probabilities = model.predict_proba(features)[0] + lass_probabilities = dict(zip(model.classes_, probabilities)) + +predicted_label = max(class_probabilities, key=class_probabilities.get) +confidence = class_probabilities[predicted_label] + +probability_pd = class_probabilities.get(LABEL_PD, 0.0) +probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010733.py b/.history/project_svm_suara/app_20260524010733.py new file mode 100644 index 0000000..f6210b0 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010733.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + probabilities = model.predict_proba(features)[0] +lass_probabilities = dict(zip(model.classes_, probabilities)) + +predicted_label = max(class_probabilities, key=class_probabilities.get) +confidence = class_probabilities[predicted_label] + +probability_pd = class_probabilities.get(LABEL_PD, 0.0) +probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010736.py b/.history/project_svm_suara/app_20260524010736.py new file mode 100644 index 0000000..ced9810 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010736.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + probabilities = model.predict_proba(features)[0] +class_probabilities = dict(zip(model.classes_, probabilities)) + +predicted_label = max(class_probabilities, key=class_probabilities.get) +confidence = class_probabilities[predicted_label] + +probability_pd = class_probabilities.get(LABEL_PD, 0.0) +probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010737.py b/.history/project_svm_suara/app_20260524010737.py new file mode 100644 index 0000000..a0c863f --- /dev/null +++ b/.history/project_svm_suara/app_20260524010737.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + probabilities = model.predict_proba(features)[0] +``class_probabilities = dict(zip(model.classes_, probabilities)) + +predicted_label = max(class_probabilities, key=class_probabilities.get) +confidence = class_probabilities[predicted_label] + +probability_pd = class_probabilities.get(LABEL_PD, 0.0) +probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010739.py b/.history/project_svm_suara/app_20260524010739.py new file mode 100644 index 0000000..46122dc --- /dev/null +++ b/.history/project_svm_suara/app_20260524010739.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + +predicted_label = max(class_probabilities, key=class_probabilities.get) +confidence = class_probabilities[predicted_label] + +probability_pd = class_probabilities.get(LABEL_PD, 0.0) +probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010743.py b/.history/project_svm_suara/app_20260524010743.py new file mode 100644 index 0000000..7e88925 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010743.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + + predicted_label = max(class_probabilities, key=class_probabilities.get) +confidence = class_probabilities[predicted_label] + +probability_pd = class_probabilities.get(LABEL_PD, 0.0) +probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010746.py b/.history/project_svm_suara/app_20260524010746.py new file mode 100644 index 0000000..af0b128 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010746.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + confidence = class_probabilities[predicted_label] + +probability_pd = class_probabilities.get(LABEL_PD, 0.0) +probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010749.py b/.history/project_svm_suara/app_20260524010749.py new file mode 100644 index 0000000..7b79e34 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010749.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + confidence = class_probabilities[predicted_label] + + probability_pd = class_probabilities.get(LABEL_PD, 0.0) +probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010751.py b/.history/project_svm_suara/app_20260524010751.py new file mode 100644 index 0000000..9334968 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010751.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + confidence = class_probabilities[predicted_label] + + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010753.py b/.history/project_svm_suara/app_20260524010753.py new file mode 100644 index 0000000..e522f1d --- /dev/null +++ b/.history/project_svm_suara/app_20260524010753.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + confidence = class_probabilities[predicted_label] + + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010756.py b/.history/project_svm_suara/app_20260524010756.py new file mode 100644 index 0000000..8a273de --- /dev/null +++ b/.history/project_svm_suara/app_20260524010756.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + confidence = class_probabilities[predicted_label] + + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/features_20260524003158.py b/.history/project_svm_suara/features_20260524003158.py new file mode 100644 index 0000000..a9c990d --- /dev/null +++ b/.history/project_svm_suara/features_20260524003158.py @@ -0,0 +1,267 @@ +from collections import Counter +from pathlib import Path +import warnings + +import librosa +import numpy as np + + +SAMPLE_RATE = 22050 +LABEL_PD = "PD" +LABEL_TPD = "TPD" +VALID_LABELS = {LABEL_PD, LABEL_TPD} +N_MFCC = 13 +MIN_DURATION_SECONDS = 0.30 +MIN_RMS_FOR_USABLE_AUDIO = 0.001 + + +def get_label_from_filename(file_path): + """ + Mengambil label dari nama file atau nama folder. + + Urutan pengecekan penting: + - cek "_tpd" lebih dulu + - baru cek "_pd" + """ + path = Path(file_path) + filename = path.stem.lower() + parent = path.parent.name.lower() + + if "_tpd" in filename or parent in {"tpd", "not_confident", "tidak_percaya_diri"}: + return LABEL_TPD + if "_pd" in filename or parent in {"pd", "confident", "percaya_diri"}: + return LABEL_PD + + return None + + +def validate_label(label, file_path): + if label not in VALID_LABELS: + raise ValueError(f"Label tidak valid pada {Path(file_path).name}: {label}") + + +def load_and_preprocess_audio(file_path, sample_rate=SAMPLE_RATE): + """ + Membaca audio dengan preprocessing konsisten untuk training dan prediksi: + mono, sample rate 22050 Hz, trim silence, dan normalisasi volume. + """ + y, sr = librosa.load(file_path, sr=sample_rate, mono=True) + + if y.size == 0: + raise ValueError(f"Audio kosong: {file_path}") + + y, _ = librosa.effects.trim(y, top_db=30) + + if y.size == 0: + raise ValueError(f"Audio hanya berisi silence: {file_path}") + + duration = librosa.get_duration(y=y, sr=sr) + if duration < MIN_DURATION_SECONDS: + raise ValueError( + f"Audio terlalu pendek: {duration:.2f} detik. Minimal {MIN_DURATION_SECONDS:.2f} detik." + ) + + rms_value = float(np.sqrt(np.mean(y**2))) + if rms_value < MIN_RMS_FOR_USABLE_AUDIO: + raise ValueError( + f"Audio terlalu pelan/silent. RMS={rms_value:.5f}, " + f"minimal {MIN_RMS_FOR_USABLE_AUDIO:.5f}." + ) + + max_amplitude = np.max(np.abs(y)) + if max_amplitude > 0: + y = y / max_amplitude + + return y.astype(np.float32), sr + + +def mean_std(feature_matrix): + """ + Mengubah fitur frame-based menjadi statistik tetap. + Output selalu 1 dimensi dan stabil untuk SVM. + """ + feature_matrix = np.atleast_2d(feature_matrix) + return np.concatenate( + [ + np.mean(feature_matrix, axis=1), + np.std(feature_matrix, axis=1), + ] + ) + + +def extract_pitch_features(y, sr): + """ + Mengambil ringkasan fundamental frequency (pitch) dengan pyin. + Jika pitch tidak terdeteksi, nilai pitch dibuat 0 agar fitur tetap konsisten. + """ + f0, _, _ = librosa.pyin( + y, + fmin=librosa.note_to_hz("C2"), + fmax=librosa.note_to_hz("C7"), + sr=sr, + ) + voiced_f0 = f0[~np.isnan(f0)] + + if voiced_f0.size == 0: + return np.array([0.0, 0.0, 0.0], dtype=np.float32) + + voiced_ratio = voiced_f0.size / f0.size + return np.array( + [ + np.mean(voiced_f0), + np.std(voiced_f0), + voiced_ratio, + ], + dtype=np.float32, + ) + + +def extract_features(file_path, sample_rate=SAMPLE_RATE): + """ + Ekstraksi fitur suara yang sama untuk training dan prediksi: + - MFCC mean dan std + - RMS Energy mean dan std + - Zero Crossing Rate mean dan std + - Spectral Centroid mean dan std + - Spectral Bandwidth mean dan std + - Spectral Rolloff mean dan std + - Pitch/fundamental frequency + - Durasi suara aktif + """ + y, sr = load_and_preprocess_audio(file_path, sample_rate=sample_rate) + + mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=N_MFCC) + rms = librosa.feature.rms(y=y) + zcr = librosa.feature.zero_crossing_rate(y) + spectral_centroid = librosa.feature.spectral_centroid(y=y, sr=sr) + spectral_bandwidth = librosa.feature.spectral_bandwidth(y=y, sr=sr) + spectral_rolloff = librosa.feature.spectral_rolloff(y=y, sr=sr) + pitch_features = extract_pitch_features(y, sr) + active_duration = np.array([librosa.get_duration(y=y, sr=sr)], dtype=np.float32) + + feature_vector = np.concatenate( + [ + mean_std(mfcc), + mean_std(rms), + mean_std(zcr), + mean_std(spectral_centroid), + mean_std(spectral_bandwidth), + mean_std(spectral_rolloff), + pitch_features, + active_duration, + ] + ) + + if feature_vector.ndim != 1: + raise ValueError("Fitur audio harus 1 dimensi.") + if not np.all(np.isfinite(feature_vector)): + raise ValueError("Fitur audio mengandung NaN atau infinity.") + + return feature_vector.astype(np.float32) + + +def load_dataset(data_dir): + """ + Membaca semua file .wav pada folder data. + File tanpa label valid atau file rusak dilewati dengan peringatan. + """ + data_path = Path(data_dir) + audio_files = sorted(data_path.rglob("*.wav")) + + if not audio_files: + raise FileNotFoundError(f"Tidak ada file .wav di folder: {data_path}") + + features = [] + labels = [] + used_files = [] + + for audio_file in audio_files: + label = get_label_from_filename(audio_file) + if label is None: + warnings.warn( + f"File dilewati karena nama/folder tidak mengandung label PD atau TPD: " + f"{audio_file.name}" + ) + continue + + try: + validate_label(label, audio_file) + features.append(extract_features(audio_file)) + labels.append(label) + used_files.append(audio_file) + except Exception as error: + warnings.warn(f"File dilewati karena gagal diproses: {audio_file.name} ({error})") + + if not features: + raise ValueError("Tidak ada file audio valid yang berhasil diproses.") + + label_counts = Counter(labels) + print("\n=== Distribusi Label Dataset ===") + print(f"PD : {label_counts.get(LABEL_PD, 0)}") + print(f"TPD: {label_counts.get(LABEL_TPD, 0)}") + + invalid_labels = set(labels) - VALID_LABELS + if invalid_labels: + raise ValueError(f"Ditemukan label tidak valid: {sorted(invalid_labels)}") + + return np.array(features), np.array(labels), used_files + + +def check_dataset_quality(data_dir): + """ + Mengecek kualitas dataset: + - jumlah data PD dan TPD + - durasi setiap audio + - audio terlalu pendek + - audio terlalu pelan/silent + - file rusak + - rekomendasi file yang perlu direkam ulang + """ + data_path = Path(data_dir) + audio_files = sorted(data_path.rglob("*.wav")) + label_counts = Counter() + problems = [] + + print("\n=== Cek Kualitas Dataset ===") + + for audio_file in audio_files: + label = get_label_from_filename(audio_file) + if label is None: + problems.append((audio_file.name, "Label tidak ditemukan")) + continue + + label_counts[label] += 1 + + try: + y_raw, sr = librosa.load(audio_file, sr=SAMPLE_RATE, mono=True) + duration_raw = librosa.get_duration(y=y_raw, sr=sr) + rms_raw = float(np.sqrt(np.mean(y_raw**2))) if y_raw.size else 0.0 + + issue_notes = [] + if duration_raw < MIN_DURATION_SECONDS: + issue_notes.append(f"terlalu pendek ({duration_raw:.2f} detik)") + if rms_raw < MIN_RMS_FOR_USABLE_AUDIO: + issue_notes.append(f"terlalu pelan/silent (RMS={rms_raw:.5f})") + + print( + f"{audio_file.name} | label={label} | durasi={duration_raw:.2f}s | " + f"rms={rms_raw:.5f}" + ) + + if issue_notes: + problems.append((audio_file.name, ", ".join(issue_notes))) + except Exception as error: + problems.append((audio_file.name, f"file rusak/gagal dibaca ({error})")) + + print("\nJumlah data:") + print(f"PD : {label_counts.get(LABEL_PD, 0)}") + print(f"TPD: {label_counts.get(LABEL_TPD, 0)}") + + print("\nRekomendasi rekam ulang/perbaikan:") + if not problems: + print("Tidak ada masalah kualitas audio yang jelas.") + else: + for filename, reason in problems: + print(f"- {filename}: {reason}") + + return problems diff --git a/.history/project_svm_suara/features_20260524031911.py b/.history/project_svm_suara/features_20260524031911.py new file mode 100644 index 0000000..a9c990d --- /dev/null +++ b/.history/project_svm_suara/features_20260524031911.py @@ -0,0 +1,267 @@ +from collections import Counter +from pathlib import Path +import warnings + +import librosa +import numpy as np + + +SAMPLE_RATE = 22050 +LABEL_PD = "PD" +LABEL_TPD = "TPD" +VALID_LABELS = {LABEL_PD, LABEL_TPD} +N_MFCC = 13 +MIN_DURATION_SECONDS = 0.30 +MIN_RMS_FOR_USABLE_AUDIO = 0.001 + + +def get_label_from_filename(file_path): + """ + Mengambil label dari nama file atau nama folder. + + Urutan pengecekan penting: + - cek "_tpd" lebih dulu + - baru cek "_pd" + """ + path = Path(file_path) + filename = path.stem.lower() + parent = path.parent.name.lower() + + if "_tpd" in filename or parent in {"tpd", "not_confident", "tidak_percaya_diri"}: + return LABEL_TPD + if "_pd" in filename or parent in {"pd", "confident", "percaya_diri"}: + return LABEL_PD + + return None + + +def validate_label(label, file_path): + if label not in VALID_LABELS: + raise ValueError(f"Label tidak valid pada {Path(file_path).name}: {label}") + + +def load_and_preprocess_audio(file_path, sample_rate=SAMPLE_RATE): + """ + Membaca audio dengan preprocessing konsisten untuk training dan prediksi: + mono, sample rate 22050 Hz, trim silence, dan normalisasi volume. + """ + y, sr = librosa.load(file_path, sr=sample_rate, mono=True) + + if y.size == 0: + raise ValueError(f"Audio kosong: {file_path}") + + y, _ = librosa.effects.trim(y, top_db=30) + + if y.size == 0: + raise ValueError(f"Audio hanya berisi silence: {file_path}") + + duration = librosa.get_duration(y=y, sr=sr) + if duration < MIN_DURATION_SECONDS: + raise ValueError( + f"Audio terlalu pendek: {duration:.2f} detik. Minimal {MIN_DURATION_SECONDS:.2f} detik." + ) + + rms_value = float(np.sqrt(np.mean(y**2))) + if rms_value < MIN_RMS_FOR_USABLE_AUDIO: + raise ValueError( + f"Audio terlalu pelan/silent. RMS={rms_value:.5f}, " + f"minimal {MIN_RMS_FOR_USABLE_AUDIO:.5f}." + ) + + max_amplitude = np.max(np.abs(y)) + if max_amplitude > 0: + y = y / max_amplitude + + return y.astype(np.float32), sr + + +def mean_std(feature_matrix): + """ + Mengubah fitur frame-based menjadi statistik tetap. + Output selalu 1 dimensi dan stabil untuk SVM. + """ + feature_matrix = np.atleast_2d(feature_matrix) + return np.concatenate( + [ + np.mean(feature_matrix, axis=1), + np.std(feature_matrix, axis=1), + ] + ) + + +def extract_pitch_features(y, sr): + """ + Mengambil ringkasan fundamental frequency (pitch) dengan pyin. + Jika pitch tidak terdeteksi, nilai pitch dibuat 0 agar fitur tetap konsisten. + """ + f0, _, _ = librosa.pyin( + y, + fmin=librosa.note_to_hz("C2"), + fmax=librosa.note_to_hz("C7"), + sr=sr, + ) + voiced_f0 = f0[~np.isnan(f0)] + + if voiced_f0.size == 0: + return np.array([0.0, 0.0, 0.0], dtype=np.float32) + + voiced_ratio = voiced_f0.size / f0.size + return np.array( + [ + np.mean(voiced_f0), + np.std(voiced_f0), + voiced_ratio, + ], + dtype=np.float32, + ) + + +def extract_features(file_path, sample_rate=SAMPLE_RATE): + """ + Ekstraksi fitur suara yang sama untuk training dan prediksi: + - MFCC mean dan std + - RMS Energy mean dan std + - Zero Crossing Rate mean dan std + - Spectral Centroid mean dan std + - Spectral Bandwidth mean dan std + - Spectral Rolloff mean dan std + - Pitch/fundamental frequency + - Durasi suara aktif + """ + y, sr = load_and_preprocess_audio(file_path, sample_rate=sample_rate) + + mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=N_MFCC) + rms = librosa.feature.rms(y=y) + zcr = librosa.feature.zero_crossing_rate(y) + spectral_centroid = librosa.feature.spectral_centroid(y=y, sr=sr) + spectral_bandwidth = librosa.feature.spectral_bandwidth(y=y, sr=sr) + spectral_rolloff = librosa.feature.spectral_rolloff(y=y, sr=sr) + pitch_features = extract_pitch_features(y, sr) + active_duration = np.array([librosa.get_duration(y=y, sr=sr)], dtype=np.float32) + + feature_vector = np.concatenate( + [ + mean_std(mfcc), + mean_std(rms), + mean_std(zcr), + mean_std(spectral_centroid), + mean_std(spectral_bandwidth), + mean_std(spectral_rolloff), + pitch_features, + active_duration, + ] + ) + + if feature_vector.ndim != 1: + raise ValueError("Fitur audio harus 1 dimensi.") + if not np.all(np.isfinite(feature_vector)): + raise ValueError("Fitur audio mengandung NaN atau infinity.") + + return feature_vector.astype(np.float32) + + +def load_dataset(data_dir): + """ + Membaca semua file .wav pada folder data. + File tanpa label valid atau file rusak dilewati dengan peringatan. + """ + data_path = Path(data_dir) + audio_files = sorted(data_path.rglob("*.wav")) + + if not audio_files: + raise FileNotFoundError(f"Tidak ada file .wav di folder: {data_path}") + + features = [] + labels = [] + used_files = [] + + for audio_file in audio_files: + label = get_label_from_filename(audio_file) + if label is None: + warnings.warn( + f"File dilewati karena nama/folder tidak mengandung label PD atau TPD: " + f"{audio_file.name}" + ) + continue + + try: + validate_label(label, audio_file) + features.append(extract_features(audio_file)) + labels.append(label) + used_files.append(audio_file) + except Exception as error: + warnings.warn(f"File dilewati karena gagal diproses: {audio_file.name} ({error})") + + if not features: + raise ValueError("Tidak ada file audio valid yang berhasil diproses.") + + label_counts = Counter(labels) + print("\n=== Distribusi Label Dataset ===") + print(f"PD : {label_counts.get(LABEL_PD, 0)}") + print(f"TPD: {label_counts.get(LABEL_TPD, 0)}") + + invalid_labels = set(labels) - VALID_LABELS + if invalid_labels: + raise ValueError(f"Ditemukan label tidak valid: {sorted(invalid_labels)}") + + return np.array(features), np.array(labels), used_files + + +def check_dataset_quality(data_dir): + """ + Mengecek kualitas dataset: + - jumlah data PD dan TPD + - durasi setiap audio + - audio terlalu pendek + - audio terlalu pelan/silent + - file rusak + - rekomendasi file yang perlu direkam ulang + """ + data_path = Path(data_dir) + audio_files = sorted(data_path.rglob("*.wav")) + label_counts = Counter() + problems = [] + + print("\n=== Cek Kualitas Dataset ===") + + for audio_file in audio_files: + label = get_label_from_filename(audio_file) + if label is None: + problems.append((audio_file.name, "Label tidak ditemukan")) + continue + + label_counts[label] += 1 + + try: + y_raw, sr = librosa.load(audio_file, sr=SAMPLE_RATE, mono=True) + duration_raw = librosa.get_duration(y=y_raw, sr=sr) + rms_raw = float(np.sqrt(np.mean(y_raw**2))) if y_raw.size else 0.0 + + issue_notes = [] + if duration_raw < MIN_DURATION_SECONDS: + issue_notes.append(f"terlalu pendek ({duration_raw:.2f} detik)") + if rms_raw < MIN_RMS_FOR_USABLE_AUDIO: + issue_notes.append(f"terlalu pelan/silent (RMS={rms_raw:.5f})") + + print( + f"{audio_file.name} | label={label} | durasi={duration_raw:.2f}s | " + f"rms={rms_raw:.5f}" + ) + + if issue_notes: + problems.append((audio_file.name, ", ".join(issue_notes))) + except Exception as error: + problems.append((audio_file.name, f"file rusak/gagal dibaca ({error})")) + + print("\nJumlah data:") + print(f"PD : {label_counts.get(LABEL_PD, 0)}") + print(f"TPD: {label_counts.get(LABEL_TPD, 0)}") + + print("\nRekomendasi rekam ulang/perbaikan:") + if not problems: + print("Tidak ada masalah kualitas audio yang jelas.") + else: + for filename, reason in problems: + print(f"- {filename}: {reason}") + + return problems diff --git a/README.md b/README.md new file mode 100644 index 0000000..80b9409 --- /dev/null +++ b/README.md @@ -0,0 +1,144 @@ +# ConfiVoice + +Struktur utama project: + +```text +confivoice3/ +├── cv_app/ # Aplikasi Flutter mobile +├── ml/ # Machine learning, model, dataset, dan API prediksi +└── cv_web/ # Website admin untuk melihat data hasil prediksi siswa/i +``` + +## 1. Jalankan API Prediksi + +API berada di folder `ml/`. + +```bash +cd ml +../cv_app/.venv/bin/python -m uvicorn api_app:app --host 0.0.0.0 --port 8000 +``` + +Endpoint penting: + +```text +POST http://localhost:8000/predict +POST http://localhost:8000/predictions +GET http://localhost:8000/predictions +GET http://localhost:8000/model +``` + +`POST /predict` hanya menjalankan analisis dan menampilkan hasil di Flutter. +Data baru masuk database setelah pengguna menekan tombol `Simpan Hasil`, yang +mengirim data ke `POST /predictions`. + +## 2. Jalankan Flutter Mobile + +Flutter berada di folder `cv_app/mobile_app/`. + +```bash +cd cv_app/mobile_app +flutter pub get +flutter run +``` + +Endpoint default emulator Android: + +```text +http://10.0.2.2:8000/predict +``` + +Jika memakai HP fisik, ganti endpoint di menu `Model` menjadi IP komputer, +misalnya: + +```text +http://192.168.1.6:8000/predict +``` + +## 3. Jalankan Website Admin + +Admin berada di folder `cv_web/`. + +```bash +cd cv_web +../cv_app/.venv/bin/python -m uvicorn app:app --host 0.0.0.0 --port 8001 +``` + +Buka: + +```text +http://localhost:8001/admin +``` + +## Database phpMyAdmin / MySQL + +Struktur baru default memakai MySQL/MariaDB agar data bisa dicek lewat +phpMyAdmin. + +1. Nyalakan MySQL dari XAMPP/MAMP. +2. Buka phpMyAdmin. +3. Jalankan API. Database dan tabel akan dibuat otomatis jika user MySQL punya + izin `CREATE DATABASE`. + +Konfigurasi default: + +```text +host: 127.0.0.1 +port: 3306 +user: root +password: kosong +database: confivoice +table: prediction_results +``` + +Jika setting MySQL kamu berbeda, pakai environment: + +```bash +export CONFIVOICE_DB_DRIVER=mysql +export CONFIVOICE_MYSQL_HOST=127.0.0.1 +export CONFIVOICE_MYSQL_PORT=3306 +export CONFIVOICE_MYSQL_USER=root +export CONFIVOICE_MYSQL_PASSWORD= +export CONFIVOICE_MYSQL_DATABASE=confivoice +``` + +Untuk MAMP, biasanya port `8889` dan password `root`. + +Schema SQL manual tersedia di: + +```text +cv_web/schema_mysql.sql +``` + +Jika ada data SQLite lama yang mau dipindah ke MySQL: + +```bash +cd ml +../cv_app/.venv/bin/python migrate_sqlite_to_mysql.py +``` + +SQLite lama, jika masih ada, berada di: + +```text +cv_web/data/confivoice.db +``` + +## File Lama + +Beberapa file lama di `cv_app/` masih dipertahankan agar command lama tidak +langsung rusak: + +```text +cv_app/api_app.py +cv_app/app.py +cv_app/predict_app.py +cv_app/audio_utils_app.py +cv_app/database.py +``` + +Untuk struktur baru, gunakan: + +```text +ml/api_app.py +cv_web/app.py +cv_app/mobile_app/ +``` diff --git a/cv_app/README.md b/cv_app/README.md new file mode 100644 index 0000000..91791bc --- /dev/null +++ b/cv_app/README.md @@ -0,0 +1,122 @@ +# ConfiVoice Mobile App + +Folder `cv_app/` dipakai untuk aplikasi Flutter mobile. + +Kode Flutter berada di: + +```text +cv_app/mobile_app/ +``` + +File lama Streamlit/API masih ada di folder ini untuk kompatibilitas, tetapi +struktur baru yang disarankan adalah: + +```text +ml/ # API prediksi dan model machine learning +cv_web/ # Website admin +cv_app/ # Flutter mobile +``` + +## Menjalankan Flutter + +```bash +cd cv_app/mobile_app +flutter pub get +flutter run +``` + +## Endpoint API + +Jalankan API dari folder `ml/`: + +```bash +cd ml +uvicorn api_app:app --host 0.0.0.0 --port 8000 +``` + +Endpoint prediksi: + +```text +POST /predict +``` + +Field multipart upload: + +```text +file +student_name +``` + +Setiap prediksi dari Flutter akan disimpan ke database default: + +```text +cv_web/data/confivoice.db +``` + +SQLite tetap database SQL, tetapi tidak perlu server database terpisah. Cocok +untuk menjalankan aplikasi lokal/TA. + +Jika ingin melihat database lewat phpMyAdmin, gunakan mode MySQL/MariaDB: + +1. Nyalakan MySQL dari XAMPP/MAMP/Laragon. +2. Buka phpMyAdmin. +3. Buat database bernama `confivoice`, atau biarkan API membuat database otomatis jika user MySQL punya izin `CREATE DATABASE`. +4. Install dependency: + +```bash +cd cv_app +source .venv/bin/activate +pip install PyMySQL +``` + +5. Jalankan API dengan konfigurasi MySQL: + +```bash +export CONFIVOICE_DB_DRIVER=mysql +export CONFIVOICE_MYSQL_HOST=127.0.0.1 +export CONFIVOICE_MYSQL_PORT=3306 +export CONFIVOICE_MYSQL_USER=root +export CONFIVOICE_MYSQL_PASSWORD= +export CONFIVOICE_MYSQL_DATABASE=confivoice +python -m uvicorn api_app:app --host 0.0.0.0 --port 8000 +``` + +Untuk MAMP, password root biasanya `root` dan port MySQL bisa `8889`: + +```bash +export CONFIVOICE_MYSQL_PASSWORD=root +export CONFIVOICE_MYSQL_PORT=8889 +``` + +Setelah prediksi dari Flutter, data akan masuk ke tabel: + +```text +confivoice.prediction_results +``` + +Endpoint untuk melihat data: + +```text +GET /predictions +GET /admin +``` + +Website admin dapat dibuka di: + +```text +http://localhost:8001/admin +``` + +Jika memakai HP fisik, ganti `localhost` dengan IP komputer yang menjalankan API, +misalnya: + +```text +http://192.168.1.6:8000/admin +``` + +## Catatan + +- Folder `cv_app/` tidak berisi dataset utama. +- Folder `cv_app/` tidak berisi model utama. +- File upload diproses sebagai file sementara menggunakan `tempfile`. +- Audio non-WAV dikonversi otomatis ke WAV mono 22050 Hz menggunakan `pydub` dan `ffmpeg`. diff --git a/cv_app/api_app.py b/cv_app/api_app.py new file mode 100644 index 0000000..4a49eaa --- /dev/null +++ b/cv_app/api_app.py @@ -0,0 +1,25 @@ +""" +Compatibility wrapper. + +API utama berada di ../ml/api_app.py. File ini dipertahankan agar command lama +`cd cv_app && uvicorn api_app:app` tetap memakai API terbaru. +""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import sys + + +ML_DIR = Path(__file__).resolve().parent.parent / "ml" +ML_API_PATH = ML_DIR / "api_app.py" + +if str(ML_DIR) not in sys.path: + sys.path.insert(0, str(ML_DIR)) + +spec = spec_from_file_location("confivoice_ml_api_app", ML_API_PATH) +if spec is None or spec.loader is None: + raise RuntimeError(f"Gagal memuat API utama: {ML_API_PATH}") + +module = module_from_spec(spec) +spec.loader.exec_module(module) +app = module.app diff --git a/cv_app/app.py b/cv_app/app.py new file mode 100644 index 0000000..2ca76df --- /dev/null +++ b/cv_app/app.py @@ -0,0 +1,267 @@ +from pathlib import Path + +import librosa +import numpy as np +import pandas as pd +import streamlit as st + +from audio_utils_app import ( + SUPPORTED_AUDIO_EXTENSIONS, + cleanup_temp_files, + convert_audio_to_wav, + save_uploaded_file_to_temp, +) +from predict_app import ( + CONFIDENCE_THRESHOLD, + MODEL_NOT_FOUND_MESSAGE, + MODEL_PATH, + get_model_info, + predict_audio, +) + + +st.set_page_config( + page_title="ConfiVoice", + page_icon="CV", + layout="wide", +) + + +SUPPORTED_UPLOAD_TYPES = [ + extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS) +] + + +def load_audio_for_plot(wav_path): + y, sr = librosa.load(wav_path, sr=22050, mono=True) + if y.size == 0: + raise ValueError("Audio kosong atau tidak memiliki sinyal suara.") + return y, sr + + +def render_waveform(wav_path): + y, sr = load_audio_for_plot(wav_path) + max_points = 3000 + step = max(1, len(y) // max_points) + y_plot = y[::step] + time_axis = np.arange(len(y_plot)) * step / sr + + waveform = pd.DataFrame( + { + "Waktu (detik)": time_axis, + "Amplitudo": y_plot, + } + ).set_index("Waktu (detik)") + + st.subheader("Waveform") + st.line_chart(waveform, height=260) + + +def render_spectrogram(wav_path): + y, sr = load_audio_for_plot(wav_path) + spectrogram = librosa.amplitude_to_db(np.abs(librosa.stft(y)), ref=np.max) + min_value = float(np.min(spectrogram)) + max_value = float(np.max(spectrogram)) + + if max_value > min_value: + spectrogram_image = (spectrogram - min_value) / (max_value - min_value) + else: + spectrogram_image = np.zeros_like(spectrogram) + + st.subheader("Spectrogram") + st.image( + np.flipud(spectrogram_image), + caption="Frekuensi rendah di bawah, frekuensi tinggi di atas.", + use_container_width=True, + clamp=True, + ) + + +def render_audio_uploader(key): + return st.file_uploader( + "Upload audio", + type=SUPPORTED_UPLOAD_TYPES, + key=key, + ) + + +def prepare_uploaded_audio(uploaded_file): + temp_input_path = save_uploaded_file_to_temp(uploaded_file) + temp_wav_path = convert_audio_to_wav(temp_input_path) + return temp_input_path, temp_wav_path + + +def render_prediction_result(result): + if result.get("is_valid_audio") is False: + st.subheader("Audio Tidak Valid") + st.error(result.get("error_message") or result.get("explanation") or "Audio tidak valid.") + with st.expander("Kualitas audio"): + st.json(result.get("audio_quality", {})) + return + + probability_pd = result["probabilities"]["PD"] + probability_tpd = result["probabilities"]["TPD"] + confidence = result["confidence"] + + st.subheader("Hasil Prediksi") + metric_cols = st.columns(3) + metric_cols[0].metric("Label", result["label"]) + metric_cols[1].metric("Keterangan", result["description"]) + metric_cols[2].metric("Confidence", f"{confidence * 100:.2f}%") + + st.progress(probability_pd, text=f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.progress(probability_tpd, text=f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence < CONFIDENCE_THRESHOLD: + st.warning("Model belum yakin. Confidence di bawah 60%, pertimbangkan untuk merekam ulang audio.") + else: + st.success(f"Hasil utama: {result['label']} - {result['description']}") + + if result.get("explanation"): + st.info(result["explanation"]) + + with st.expander("Kualitas audio dan indikator suara"): + st.write("Kualitas audio") + st.json(result.get("audio_quality", {})) + st.write("Indikator suara") + st.json(result.get("voice_indicators", {})) + + with st.expander("Output dictionary"): + st.json(result) + + +def render_upload_preview(uploaded_file, show_visuals=True): + temp_input_path = None + temp_wav_path = None + + try: + temp_input_path, temp_wav_path = prepare_uploaded_audio(uploaded_file) + st.audio(uploaded_file.getvalue(), format=uploaded_file.type or "audio/wav") + + if show_visuals: + waveform_col, spectrogram_col = st.columns(2) + with waveform_col: + render_waveform(temp_wav_path) + with spectrogram_col: + render_spectrogram(temp_wav_path) + + return temp_input_path + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + return None + finally: + cleanup_temp_files(temp_wav_path) + + +def show_home(): + st.title("ConfiVoice") + st.write("Aplikasi Streamlit untuk memprediksi suara PD atau TPD dari model machine learning.") + + st.info( + "Folder aplikasi ini hanya membaca model dari `../ml/models/` dan fitur dari `../ml/features.py`." + ) + + st.write("Menu yang tersedia:") + st.write("- Beranda") + st.write("- Prediksi Suara") + st.write("- Visualisasi Audio") + st.write("- Informasi Model") + st.write("- Tentang Sistem") + + +def show_prediction(): + st.title("Prediksi Suara") + st.write("Upload audio dalam format WAV, MP3, M4A, OGG, FLAC, WEBM, atau AAC.") + + uploaded_file = render_audio_uploader("prediction_upload") + if uploaded_file is None: + return + + temp_input_path = None + temp_wav_path = None + + try: + temp_input_path, temp_wav_path = prepare_uploaded_audio(uploaded_file) + st.audio(uploaded_file.getvalue(), format=uploaded_file.type or "audio/wav") + + waveform_col, spectrogram_col = st.columns(2) + with waveform_col: + render_waveform(temp_wav_path) + with spectrogram_col: + render_spectrogram(temp_wav_path) + + if st.button("Prediksi", type="primary"): + with st.spinner("Mengekstraksi fitur dan menjalankan model..."): + result = predict_audio(temp_input_path) + render_prediction_result(result) + except FileNotFoundError as error: + message = MODEL_NOT_FOUND_MESSAGE if str(error) == MODEL_NOT_FOUND_MESSAGE else str(error) + st.error(message) + except Exception as error: + st.error(f"Gagal memproses prediksi: {error}") + finally: + cleanup_temp_files(temp_input_path, temp_wav_path) + + +def show_visualization(): + st.title("Visualisasi Audio") + uploaded_file = render_audio_uploader("visualization_upload") + if uploaded_file is None: + return + + temp_input_path = None + temp_wav_path = None + + try: + temp_input_path, temp_wav_path = prepare_uploaded_audio(uploaded_file) + st.audio(uploaded_file.getvalue(), format=uploaded_file.type or "audio/wav") + render_waveform(temp_wav_path) + render_spectrogram(temp_wav_path) + except Exception as error: + st.error(f"Gagal membuat visualisasi: {error}") + finally: + cleanup_temp_files(temp_input_path, temp_wav_path) + + +def show_model_info(): + st.title("Informasi Model") + + try: + info = get_model_info() + except FileNotFoundError: + st.error(MODEL_NOT_FOUND_MESSAGE) + return + except Exception as error: + st.error(f"Gagal membaca informasi model: {error}") + return + + st.write(f"Path model: `{info['model_path']}`") + st.write(f"Tipe model: `{info['model_type']}`") + st.write(f"Class model: `{', '.join(info['classes'])}`") + st.write(f"Jumlah fitur yang diharapkan: `{info['expected_features']}`") + st.write(f"Model utama: `{MODEL_PATH.relative_to(MODEL_PATH.parents[2])}`") + + +def show_about(): + st.title("Tentang Sistem") + st.write( + "ConfiVoice memisahkan aplikasi Streamlit dari proses machine learning. " + "Folder `cv_app/` berisi antarmuka aplikasi, sementara folder `ml/` tetap menjadi tempat dataset, training, fitur audio, dan model." + ) + st.write( + "Audio upload dikonversi sementara ke WAV mono 22050 Hz, lalu fitur diekstraksi memakai `../ml/features.py`. " + "Tidak ada audio upload yang disimpan permanen ke `ml/data/`." + ) + + +MENU_HANDLERS = { + "Beranda": show_home, + "Prediksi Suara": show_prediction, + "Visualisasi Audio": show_visualization, + "Informasi Model": show_model_info, + "Tentang Sistem": show_about, +} + + +selected_menu = st.sidebar.radio("Menu", list(MENU_HANDLERS.keys())) +MENU_HANDLERS[selected_menu]() diff --git a/cv_app/audio_utils_app.py b/cv_app/audio_utils_app.py new file mode 100644 index 0000000..987d66c --- /dev/null +++ b/cv_app/audio_utils_app.py @@ -0,0 +1,86 @@ +from pathlib import Path +import tempfile +import warnings + +with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message="Couldn't find ffmpeg or avconv.*", + category=RuntimeWarning, + ) + from pydub import AudioSegment + +try: + import imageio_ffmpeg +except ImportError: + imageio_ffmpeg = None + + +TARGET_SAMPLE_RATE = 22050 +SUPPORTED_AUDIO_EXTENSIONS = {".wav", ".mp3", ".m4a", ".ogg", ".flac", ".webm", ".aac"} + +if imageio_ffmpeg is not None: + AudioSegment.converter = imageio_ffmpeg.get_ffmpeg_exe() + + +def validate_audio_extension(file_path): + extension = Path(file_path).suffix.lower() + if extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(sorted(SUPPORTED_AUDIO_EXTENSIONS)).upper().replace(".", "") + raise ValueError(f"Format audio tidak didukung: {extension}. Format yang didukung: {allowed}") + + +def save_uploaded_file_to_temp(uploaded_file): + """ + Menyimpan file upload Streamlit ke file sementara. + File ini bukan dataset dan tidak disimpan ke ml/data. + """ + suffix = Path(uploaded_file.name).suffix.lower() or ".wav" + validate_audio_extension(f"audio{suffix}") + + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) + try: + temp_file.write(uploaded_file.getvalue()) + return Path(temp_file.name) + finally: + temp_file.close() + + +def create_temp_wav_path(): + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".wav") + temp_path = Path(temp_file.name) + temp_file.close() + return temp_path + + +def convert_audio_to_wav(input_path, output_path=None): + """ + Konversi audio upload ke WAV mono 22050 Hz menggunakan pydub/ffmpeg. + """ + input_path = Path(input_path) + validate_audio_extension(input_path) + + if not input_path.exists(): + raise FileNotFoundError(f"File audio tidak ditemukan: {input_path}") + + output_path = Path(output_path) if output_path else create_temp_wav_path() + output_path.parent.mkdir(parents=True, exist_ok=True) + + try: + audio = AudioSegment.from_file(input_path) + except FileNotFoundError as error: + raise RuntimeError( + "ffmpeg tidak ditemukan. Pastikan ffmpeg sudah terpasang dan dapat diakses oleh pydub." + ) from error + except Exception as error: + raise RuntimeError(f"Gagal membaca atau mengonversi audio: {error}") from error + + audio = audio.set_channels(1).set_frame_rate(TARGET_SAMPLE_RATE) + audio.export(output_path, format="wav") + return output_path + + +def cleanup_temp_files(*paths): + for path in paths: + if path: + Path(path).unlink(missing_ok=True) diff --git a/cv_app/database.py b/cv_app/database.py new file mode 100644 index 0000000..1b0ab87 --- /dev/null +++ b/cv_app/database.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +import os +import sqlite3 +from pathlib import Path + + +BASE_DIR = Path(__file__).resolve().parent +DATA_DIR = BASE_DIR / "data" +DB_PATH = DATA_DIR / "confivoice.db" + +DB_DRIVER = os.getenv("CONFIVOICE_DB_DRIVER", "sqlite").lower() +MYSQL_HOST = os.getenv("CONFIVOICE_MYSQL_HOST", "127.0.0.1") +MYSQL_PORT = int(os.getenv("CONFIVOICE_MYSQL_PORT", "3306")) +MYSQL_USER = os.getenv("CONFIVOICE_MYSQL_USER", "root") +MYSQL_PASSWORD = os.getenv("CONFIVOICE_MYSQL_PASSWORD", "") +MYSQL_DATABASE = os.getenv("CONFIVOICE_MYSQL_DATABASE", "confivoice") + + +def get_database_label(): + if DB_DRIVER == "mysql": + return f"mysql://{MYSQL_USER}@{MYSQL_HOST}:{MYSQL_PORT}/{MYSQL_DATABASE}" + return str(DB_PATH) + + +def get_connection(): + if DB_DRIVER == "mysql": + return get_mysql_connection() + + DATA_DIR.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(DB_PATH) + connection.row_factory = sqlite3.Row + return connection + + +def get_mysql_connection(database=MYSQL_DATABASE): + try: + import pymysql + except ImportError as error: + raise RuntimeError( + "PyMySQL belum terinstall. Jalankan: pip install PyMySQL" + ) from error + + return pymysql.connect( + host=MYSQL_HOST, + port=MYSQL_PORT, + user=MYSQL_USER, + password=MYSQL_PASSWORD, + database=database, + charset="utf8mb4", + cursorclass=pymysql.cursors.DictCursor, + autocommit=False, + ) + + +def init_mysql_db(): + with get_mysql_connection(database=None) as connection: + with connection.cursor() as cursor: + cursor.execute( + f"CREATE DATABASE IF NOT EXISTS `{MYSQL_DATABASE}` " + "CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ) + connection.commit() + + with get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS prediction_results ( + id INT AUTO_INCREMENT PRIMARY KEY, + student_name VARCHAR(255) NOT NULL, + predicted_label VARCHAR(20), + description VARCHAR(255), + confidence DOUBLE NOT NULL DEFAULT 0, + probability_pd DOUBLE NOT NULL DEFAULT 0, + probability_tpd DOUBLE NOT NULL DEFAULT 0, + is_valid_audio TINYINT(1) NOT NULL DEFAULT 1, + error_message TEXT, + audio_duration DOUBLE NOT NULL DEFAULT 0, + volume_score DOUBLE NOT NULL DEFAULT 0, + intonation_score DOUBLE NOT NULL DEFAULT 0, + pause_score DOUBLE NOT NULL DEFAULT 0, + speech_activity_ratio DOUBLE NOT NULL DEFAULT 0, + silence_ratio DOUBLE NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + connection.commit() + + +def init_db(): + if DB_DRIVER == "mysql": + init_mysql_db() + return + + with get_connection() as connection: + connection.execute( + """ + CREATE TABLE IF NOT EXISTS prediction_results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + student_name TEXT NOT NULL, + predicted_label TEXT, + description TEXT, + confidence REAL NOT NULL DEFAULT 0, + probability_pd REAL NOT NULL DEFAULT 0, + probability_tpd REAL NOT NULL DEFAULT 0, + is_valid_audio INTEGER NOT NULL DEFAULT 1, + error_message TEXT, + audio_duration REAL NOT NULL DEFAULT 0, + volume_score REAL NOT NULL DEFAULT 0, + intonation_score REAL NOT NULL DEFAULT 0, + pause_score REAL NOT NULL DEFAULT 0, + speech_activity_ratio REAL NOT NULL DEFAULT 0, + silence_ratio REAL NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + connection.commit() + + +def save_prediction_result(student_name, result): + init_db() + audio_quality = result.get("audio_quality") or {} + indicators = result.get("voice_indicators") or {} + + if DB_DRIVER == "mysql": + return save_prediction_result_mysql(student_name, result, audio_quality, indicators) + + with get_connection() as connection: + cursor = connection.execute( + """ + INSERT INTO prediction_results ( + student_name, + predicted_label, + description, + confidence, + probability_pd, + probability_tpd, + is_valid_audio, + error_message, + audio_duration, + volume_score, + intonation_score, + pause_score, + speech_activity_ratio, + silence_ratio + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + student_name, + result.get("predicted_label") or result.get("label"), + result.get("description"), + float(result.get("confidence") or 0), + float(result.get("probability_pd") or 0), + float(result.get("probability_tpd") or 0), + 1 if result.get("is_valid_audio") is not False else 0, + result.get("error_message"), + float(audio_quality.get("duration") or 0), + float(indicators.get("volume_score") or 0), + float(indicators.get("intonation_score") or 0), + float(indicators.get("pause_score") or 0), + float(indicators.get("speech_activity_ratio") or 0), + float(indicators.get("silence_ratio") or 0), + ), + ) + connection.commit() + return cursor.lastrowid + + +def save_prediction_result_mysql(student_name, result, audio_quality, indicators): + with get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + INSERT INTO prediction_results ( + student_name, + predicted_label, + description, + confidence, + probability_pd, + probability_tpd, + is_valid_audio, + error_message, + audio_duration, + volume_score, + intonation_score, + pause_score, + speech_activity_ratio, + silence_ratio + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + student_name, + result.get("predicted_label") or result.get("label"), + result.get("description"), + float(result.get("confidence") or 0), + float(result.get("probability_pd") or 0), + float(result.get("probability_tpd") or 0), + 1 if result.get("is_valid_audio") is not False else 0, + result.get("error_message"), + float(audio_quality.get("duration") or 0), + float(indicators.get("volume_score") or 0), + float(indicators.get("intonation_score") or 0), + float(indicators.get("pause_score") or 0), + float(indicators.get("speech_activity_ratio") or 0), + float(indicators.get("silence_ratio") or 0), + ), + ) + prediction_id = cursor.lastrowid + connection.commit() + return prediction_id + + +def list_prediction_results(limit=200): + init_db() + if DB_DRIVER == "mysql": + return list_prediction_results_mysql(limit=limit) + + with get_connection() as connection: + rows = connection.execute( + """ + SELECT + id, + student_name, + predicted_label, + description, + confidence, + probability_pd, + probability_tpd, + is_valid_audio, + error_message, + audio_duration, + volume_score, + intonation_score, + pause_score, + speech_activity_ratio, + silence_ratio, + created_at + FROM prediction_results + ORDER BY created_at DESC, id DESC + LIMIT ? + """, + (limit,), + ).fetchall() + + return [dict(row) for row in rows] + + +def list_prediction_results_mysql(limit=200): + with get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT + id, + student_name, + predicted_label, + description, + confidence, + probability_pd, + probability_tpd, + is_valid_audio, + error_message, + audio_duration, + volume_score, + intonation_score, + pause_score, + speech_activity_ratio, + silence_ratio, + created_at + FROM prediction_results + ORDER BY created_at DESC, id DESC + LIMIT %s + """, + (limit,), + ) + rows = cursor.fetchall() + + for row in rows: + row["created_at"] = str(row["created_at"]) + return rows diff --git a/cv_app/mobile_app/.gitignore b/cv_app/mobile_app/.gitignore new file mode 100644 index 0000000..79c113f --- /dev/null +++ b/cv_app/mobile_app/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/cv_app/mobile_app/.metadata b/cv_app/mobile_app/.metadata new file mode 100644 index 0000000..9fc6a8a --- /dev/null +++ b/cv_app/mobile_app/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "ea121f8859e4b13e47a8f845e4586164519588bc" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: ea121f8859e4b13e47a8f845e4586164519588bc + base_revision: ea121f8859e4b13e47a8f845e4586164519588bc + - platform: web + create_revision: ea121f8859e4b13e47a8f845e4586164519588bc + base_revision: ea121f8859e4b13e47a8f845e4586164519588bc + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/cv_app/mobile_app/README.md b/cv_app/mobile_app/README.md new file mode 100644 index 0000000..d1b9bd6 --- /dev/null +++ b/cv_app/mobile_app/README.md @@ -0,0 +1,88 @@ +# ConfiVoice Mobile + +Flutter Android client untuk aplikasi ConfiVoice. + +Mobile app ini tidak menyimpan dataset atau model. Audio dipilih dari perangkat Android, lalu dikirim ke API prediksi. API tetap bertugas membaca model dari: + +```text +../ml/models/svm_voice_confidence_model.joblib +``` + +Audio bisa berasal dari file yang dipilih dari perangkat atau dari fitur rekam suara langsung di halaman `Prediksi`. Rekaman disimpan sementara sebagai WAV mono 22050 Hz di storage aplikasi. + +## Menjalankan di Emulator + +Jalankan API dari folder `cv_app`: + +```bash +cd ml +uvicorn api_app:app --host 0.0.0.0 --port 8000 +``` + +Lalu jalankan mobile app: + +```bash +cd cv_app/mobile_app +flutter pub get +flutter run +``` + +Default endpoint di aplikasi: + +```text +http://10.0.2.2:8000/predict +``` + +`10.0.2.2` adalah alamat khusus emulator Android untuk mengakses `localhost` komputer. Alamat ini tidak bisa dipakai dari HP fisik. + +Saat prediksi, isi `Nama siswa/i` di halaman `Prediksi`. Nama dan hasil analisis +akan disimpan oleh API ke database SQLite: + +```text +cv_web/data/confivoice.db +``` + +Admin dapat melihat hasil dari browser: + +```text +http://localhost:8001/admin +``` + +## Menjalankan di HP Fisik + +Ganti endpoint pada menu `Model` menjadi alamat IP komputer yang menjalankan API, misalnya: + +```text +http://192.168.1.6:8000/predict +``` + +Pastikan HP dan komputer berada di jaringan yang sama. + +## Build APK + +```bash +cd cv_app/mobile_app +flutter build apk --release +``` + +Output APK biasanya berada di: + +```text +build/app/outputs/flutter-apk/app-release.apk +``` + +## Format Response API + +Aplikasi mengharapkan JSON: + +```json +{ + "label": "PD", + "description": "Percaya Diri", + "confidence": 0.76, + "probabilities": { + "PD": 0.76, + "TPD": 0.24 + } +} +``` diff --git a/cv_app/mobile_app/analysis_options.yaml b/cv_app/mobile_app/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/cv_app/mobile_app/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/cv_app/mobile_app/android/.gitignore b/cv_app/mobile_app/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/cv_app/mobile_app/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/cv_app/mobile_app/android/app/build.gradle.kts b/cv_app/mobile_app/android/app/build.gradle.kts new file mode 100644 index 0000000..257fd3d --- /dev/null +++ b/cv_app/mobile_app/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.confivoice.mobile" + compileSdk = flutter.compileSdkVersion + ndkVersion = "30.0.14904198" + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.confivoice.mobile" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = 23 + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/cv_app/mobile_app/android/app/src/debug/AndroidManifest.xml b/cv_app/mobile_app/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/cv_app/mobile_app/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/cv_app/mobile_app/android/app/src/main/AndroidManifest.xml b/cv_app/mobile_app/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..df64f9e --- /dev/null +++ b/cv_app/mobile_app/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cv_app/mobile_app/android/app/src/main/kotlin/com/confivoice/mobile/MainActivity.kt b/cv_app/mobile_app/android/app/src/main/kotlin/com/confivoice/mobile/MainActivity.kt new file mode 100644 index 0000000..d605807 --- /dev/null +++ b/cv_app/mobile_app/android/app/src/main/kotlin/com/confivoice/mobile/MainActivity.kt @@ -0,0 +1,5 @@ +package com.confivoice.mobile + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/cv_app/mobile_app/android/app/src/main/res/drawable-v21/launch_background.xml b/cv_app/mobile_app/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/cv_app/mobile_app/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/cv_app/mobile_app/android/app/src/main/res/drawable/launch_background.xml b/cv_app/mobile_app/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/cv_app/mobile_app/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/cv_app/mobile_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/cv_app/mobile_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..eec190fc35ab776de23a0c56b93391bc1e972e4c GIT binary patch literal 7409 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 literal 0 HcmV?d00001 diff --git a/cv_app/mobile_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/cv_app/mobile_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..76f0272c4aadc908eca160f46ec7018c16c67778 GIT binary patch literal 3677 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 Ef;;T`YXATM literal 0 HcmV?d00001 diff --git a/cv_app/mobile_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/cv_app/mobile_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..c1e728381801a73bc0020b8bed72d45c9d6fac0d GIT binary patch literal 25833 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 Ef}pf*-T(jq literal 0 HcmV?d00001 diff --git a/cv_app/mobile_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/cv_app/mobile_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..5550a7150093f36a49deebdb71f9e6c2422a1c1d GIT binary patch literal 43495 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 rz;Ss+mtz4qF0bfvEC9#l6 + + + + + + diff --git a/cv_app/mobile_app/android/app/src/main/res/values/styles.xml b/cv_app/mobile_app/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/cv_app/mobile_app/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/cv_app/mobile_app/android/app/src/profile/AndroidManifest.xml b/cv_app/mobile_app/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/cv_app/mobile_app/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/cv_app/mobile_app/android/build.gradle.kts b/cv_app/mobile_app/android/build.gradle.kts new file mode 100644 index 0000000..89176ef --- /dev/null +++ b/cv_app/mobile_app/android/build.gradle.kts @@ -0,0 +1,21 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/cv_app/mobile_app/android/gradle.properties b/cv_app/mobile_app/android/gradle.properties new file mode 100644 index 0000000..f018a61 --- /dev/null +++ b/cv_app/mobile_app/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/cv_app/mobile_app/android/gradle/wrapper/gradle-wrapper.properties b/cv_app/mobile_app/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..afa1e8e --- /dev/null +++ b/cv_app/mobile_app/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip diff --git a/cv_app/mobile_app/android/settings.gradle.kts b/cv_app/mobile_app/android/settings.gradle.kts new file mode 100644 index 0000000..a439442 --- /dev/null +++ b/cv_app/mobile_app/android/settings.gradle.kts @@ -0,0 +1,25 @@ +pluginManagement { + val flutterSdkPath = run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.7.0" apply false + id("org.jetbrains.kotlin.android") version "1.8.22" apply false +} + +include(":app") diff --git a/cv_app/mobile_app/devtools_options.yaml b/cv_app/mobile_app/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/cv_app/mobile_app/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/cv_app/mobile_app/lib/app/app.dart b/cv_app/mobile_app/lib/app/app.dart new file mode 100644 index 0000000..6140936 --- /dev/null +++ b/cv_app/mobile_app/lib/app/app.dart @@ -0,0 +1,143 @@ +part of '../main.dart'; + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} diff --git a/cv_app/mobile_app/lib/audio_source.dart b/cv_app/mobile_app/lib/audio_source.dart new file mode 100644 index 0000000..21edacb --- /dev/null +++ b/cv_app/mobile_app/lib/audio_source.dart @@ -0,0 +1,3 @@ +export 'audio_source_stub.dart' + if (dart.library.io) 'audio_source_io.dart' + if (dart.library.html) 'audio_source_web.dart'; diff --git a/cv_app/mobile_app/lib/audio_source_io.dart b/cv_app/mobile_app/lib/audio_source_io.dart new file mode 100644 index 0000000..caf9903 --- /dev/null +++ b/cv_app/mobile_app/lib/audio_source_io.dart @@ -0,0 +1,74 @@ +import 'dart:io'; +import 'dart:math'; + +import 'package:file_picker/file_picker.dart'; +import 'package:http/http.dart' as http; + +class SelectedAudio { + const SelectedAudio({required this.name, required this.path}); + + final String name; + final String path; +} + +Future pickAudio(List supportedExtensions) async { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: supportedExtensions, + allowMultiple: false, + ); + + final pickedFile = result?.files.single; + final path = pickedFile?.path; + if (pickedFile == null || path == null) { + return null; + } + + return SelectedAudio(name: pickedFile.name, path: path); +} + +Future audioFromRecorderPath( + String path, + String fallbackName, +) async { + return SelectedAudio( + name: path.split(Platform.pathSeparator).last, + path: path, + ); +} + +Future> buildWavePreview(SelectedAudio audio) async { + final bytes = await File(audio.path).readAsBytes(); + return _wavePoints(bytes); +} + +Future multipartFileFromAudio( + String fieldName, + SelectedAudio audio, +) { + return http.MultipartFile.fromPath(fieldName, audio.path); +} + +String recordingFilePath(String fileName) { + return '${Directory.systemTemp.path}${Platform.pathSeparator}$fileName'; +} + +List _wavePoints(List bytes) { + if (bytes.isEmpty) { + return const []; + } + + const pointCount = 96; + final step = max(1, bytes.length ~/ pointCount); + final points = []; + + for (var index = 0; index < bytes.length; index += step) { + final normalized = (bytes[index] - 128) / 128.0; + points.add(normalized.clamp(-1.0, 1.0)); + if (points.length == pointCount) { + break; + } + } + + return points; +} diff --git a/cv_app/mobile_app/lib/audio_source_stub.dart b/cv_app/mobile_app/lib/audio_source_stub.dart new file mode 100644 index 0000000..861a6d8 --- /dev/null +++ b/cv_app/mobile_app/lib/audio_source_stub.dart @@ -0,0 +1,28 @@ +import 'package:http/http.dart' as http; + +class SelectedAudio { + const SelectedAudio({required this.name}); + + final String name; +} + +Future pickAudio(List supportedExtensions) { + throw UnsupportedError('Audio picking is not supported on this platform.'); +} + +Future audioFromRecorderPath(String path, String fallbackName) { + throw UnsupportedError('Recording is not supported on this platform.'); +} + +Future> buildWavePreview(SelectedAudio audio) { + throw UnsupportedError('Waveform preview is not supported on this platform.'); +} + +Future multipartFileFromAudio( + String fieldName, + SelectedAudio audio, +) { + throw UnsupportedError('Audio upload is not supported on this platform.'); +} + +String recordingFilePath(String fileName) => fileName; diff --git a/cv_app/mobile_app/lib/audio_source_web.dart b/cv_app/mobile_app/lib/audio_source_web.dart new file mode 100644 index 0000000..0aac776 --- /dev/null +++ b/cv_app/mobile_app/lib/audio_source_web.dart @@ -0,0 +1,76 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:file_picker/file_picker.dart'; +import 'package:http/http.dart' as http; + +class SelectedAudio { + const SelectedAudio({required this.name, required this.bytes}); + + final String name; + final Uint8List bytes; +} + +Future pickAudio(List supportedExtensions) async { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: supportedExtensions, + allowMultiple: false, + withData: true, + ); + + final pickedFile = result?.files.single; + final bytes = pickedFile?.bytes; + if (pickedFile == null || bytes == null) { + return null; + } + + return SelectedAudio(name: pickedFile.name, bytes: bytes); +} + +Future audioFromRecorderPath( + String path, + String fallbackName, +) async { + final response = await http.get(Uri.parse(path)); + if (response.statusCode < 200 || response.statusCode >= 300) { + return null; + } + + return SelectedAudio(name: fallbackName, bytes: response.bodyBytes); +} + +Future> buildWavePreview(SelectedAudio audio) async { + return _wavePoints(audio.bytes); +} + +Future multipartFileFromAudio( + String fieldName, + SelectedAudio audio, +) { + return Future.value( + http.MultipartFile.fromBytes(fieldName, audio.bytes, filename: audio.name), + ); +} + +String recordingFilePath(String fileName) => fileName; + +List _wavePoints(List bytes) { + if (bytes.isEmpty) { + return const []; + } + + const pointCount = 96; + final step = max(1, bytes.length ~/ pointCount); + final points = []; + + for (var index = 0; index < bytes.length; index += step) { + final normalized = (bytes[index] - 128) / 128.0; + points.add(normalized.clamp(-1.0, 1.0)); + if (points.length == pointCount) { + break; + } + } + + return points; +} diff --git a/cv_app/mobile_app/lib/fitur/auth/auth.dart b/cv_app/mobile_app/lib/fitur/auth/auth.dart new file mode 100644 index 0000000..75b7fd3 --- /dev/null +++ b/cv_app/mobile_app/lib/fitur/auth/auth.dart @@ -0,0 +1,707 @@ +part of '../../main.dart'; + +class UserSession { + const UserSession({ + required this.id, + required this.fullName, + required this.username, + }); + + factory UserSession.fromJson(Map json) { + return UserSession( + id: + json['id'] is int + ? json['id'] as int + : int.parse(json['id'].toString()), + fullName: json['full_name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + ); + } + + final int id; + final String fullName; + final String username; + + Map toJson() { + return {'id': id, 'full_name': fullName, 'username': username}; + } +} + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + static const _sessionPreferenceKey = 'confivoice_user_session'; + + UserSession? _session; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final preferences = await SharedPreferences.getInstance(); + final sessionJson = preferences.getString(_sessionPreferenceKey); + if (sessionJson != null && sessionJson.isNotEmpty) { + _session = UserSession.fromJson( + (jsonDecode(sessionJson) as Map).cast(), + ); + } + } catch (_) { + _session = null; + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _setSession(UserSession session) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _sessionPreferenceKey, + jsonEncode(session.toJson()), + ); + if (mounted) { + setState(() { + _session = session; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar( + context, + message: 'Berhasil masuk, ${session.fullName}', + icon: Icons.check_circle_outline, + ); + } + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showCompactSnackBar(context, message: 'Berhasil logout'); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final session = _session; + if (session == null) { + return AuthPage(onAuthenticated: _setSession); + } + + return ConfiVoiceShell(session: session, onLogout: _logout); + } +} + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, required this.onAuthenticated}); + + final ValueChanged onAuthenticated; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + + final TextEditingController _apiController = TextEditingController( + text: defaultApiEndpoint(), + ); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isRegister = false; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _fullNameController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = normalizeApiEndpoint( + preferences.getString(_apiEndpointPreferenceKey) ?? '', + ); + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) {} + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Uri? _authEndpoint(String path) { + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + return null; + } + return predictEndpoint.replace(path: path, queryParameters: {}); + } + + Future _submit() async { + final endpoint = _authEndpoint(_isRegister ? '/register' : '/login'); + if (endpoint == null) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + final fullName = _fullNameController.text.trim(); + if (username.isEmpty || + password.isEmpty || + (_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) { + setState(() { + _errorMessage = 'Lengkapi data akun terlebih dahulu.'; + }); + return; + } + + if (_isRegister && password != confirmPassword) { + setState(() { + _errorMessage = 'Konfirmasi password belum sama.'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final body = { + 'username': username, + 'password': password, + if (_isRegister) 'full_name': fullName, + }; + final response = await http + .post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final decoded = jsonDecode(response.body); + final detail = + decoded is Map ? decoded['detail']?.toString() : response.body; + throw Exception(detail ?? response.body); + } + + await _saveApiEndpoint(); + if (_isRegister) { + if (!mounted) { + return; + } + final registeredUsername = username; + setState(() { + _isRegister = false; + _fullNameController.clear(); + _usernameController.text = registeredUsername; + _passwordController.clear(); + _confirmPasswordController.clear(); + _errorMessage = null; + }); + showCompactSnackBar( + context, + message: 'Registrasi berhasil. Silakan login.', + icon: Icons.check_circle_outline, + ); + return; + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = + _isRegister + ? _cleanAuthErrorMessage(error) + : 'Username atau password salah.'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + String _cleanAuthErrorMessage(Object error) { + final message = error.toString(); + const exceptionPrefix = 'Exception: '; + return message.startsWith(exceptionPrefix) + ? message.substring(exceptionPrefix.length) + : message; + } + + InputDecoration _authFieldDecoration({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) { + return InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: label, + labelStyle: const TextStyle(color: Color(0xFF536179)), + prefixIcon: Icon(icon, color: AppColors.navy, size: 20), + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFD8DFEA)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.blue, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.recording), + ), + ); + } + + Widget _buildModeSwitch() { + return Container( + height: 46, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEAF2FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD6E3F7)), + ), + child: Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: + _isRegister ? Alignment.centerRight : Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0D2A52), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF102A56).withValues(alpha: 0.22), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + ), + ), + ), + Row( + children: [ + _ModeButton( + label: 'Masuk', + isSelected: !_isRegister, + onTap: () { + setState(() { + _isRegister = false; + _errorMessage = null; + _confirmPasswordController.clear(); + }); + }, + ), + _ModeButton( + label: 'Daftar', + isSelected: _isRegister, + onTap: () { + setState(() { + _isRegister = true; + _errorMessage = null; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildAuthForm() { + return SizedBox( + height: 270, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + transitionBuilder: (child, animation) { + final offset = Tween( + begin: Offset(_isRegister ? 0.10 : -0.10, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offset, child: child), + ); + }, + child: + _isRegister + ? Column( + key: const ValueKey('register-form'), + children: [ + TextField( + controller: _fullNameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Nama lengkap', + icon: Icons.badge_outlined, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + label: 'Konfirmasi password', + icon: Icons.lock_reset_outlined, + onToggle: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ) + : Column( + key: const ValueKey('login-form'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _usernameController, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: _authFieldDecoration( + label: 'Username', + icon: Icons.person_outline, + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + _PasswordField( + controller: _passwordController, + obscureText: _obscurePassword, + label: 'Password', + icon: Icons.lock_outline, + onToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + onSubmitted: (_) => _submit(), + decorationBuilder: _authFieldDecoration, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Colors.white, + size: 42, + ), + const SizedBox(height: 10), + const Text( + 'Confivoice', + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 26), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.20), + blurRadius: 28, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSwitch(), + const SizedBox(height: 22), + _buildAuthForm(), + const SizedBox(height: 18), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.navy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: + _isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Text( + _isRegister ? 'Daftar' : 'Masuk', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthErrorBox(message: _errorMessage!), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF0F172A), + fontSize: 16, + fontWeight: FontWeight.w800, + ), + child: Text(label), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.obscureText, + required this.label, + required this.icon, + required this.onToggle, + required this.onSubmitted, + required this.decorationBuilder, + }); + + final TextEditingController controller; + final bool obscureText; + final String label; + final IconData icon; + final VoidCallback onToggle; + final ValueChanged onSubmitted; + final InputDecoration Function({ + required String label, + required IconData icon, + Widget? suffixIcon, + }) + decorationBuilder; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + style: const TextStyle(color: Color(0xFF0F172A)), + decoration: decorationBuilder( + label: label, + icon: icon, + suffixIcon: IconButton( + tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password', + onPressed: onToggle, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.navy, + ), + ), + ), + onSubmitted: onSubmitted, + ); + } +} + +class _AuthErrorBox extends StatelessWidget { + const _AuthErrorBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFBBF24)), + ), + child: Text( + message, + style: const TextStyle(color: Color(0xFF92400E), fontSize: 12), + ), + ); + } +} diff --git a/cv_app/mobile_app/lib/fitur/home/confivoice_shell.dart b/cv_app/mobile_app/lib/fitur/home/confivoice_shell.dart new file mode 100644 index 0000000..392a3ee --- /dev/null +++ b/cv_app/mobile_app/lib/fitur/home/confivoice_shell.dart @@ -0,0 +1,790 @@ +part of '../../main.dart'; + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: defaultApiEndpoint(), + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + String _studentGender = ''; + List _studentNames = const []; + Map _studentGenders = const {}; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _initializeShell(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = normalizeApiEndpoint( + preferences.getString(_apiEndpointPreferenceKey) ?? '', + ); + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => 'confivoice_student_names_shared'; + String get _studentGendersPreferenceKey => + 'confivoice_student_genders_shared'; + + Future _initializeShell() async { + await _loadApiEndpoint(); + await _loadStudentNames(); + } + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final localNames = + preferences.getStringList(_studentNamesPreferenceKey) ?? []; + final localGenderJson = + preferences.getString(_studentGendersPreferenceKey) ?? '{}'; + final localGenders = _decodeStudentGenders(localGenderJson); + final savedResultData = await _loadStudentDataFromSavedResults(); + final names = _normalizeStudentNames([ + ...localNames, + ...savedResultData.names, + ]); + final genders = _mergeStudentGenders( + localGenders, + savedResultData.genders, + ); + await preferences.setStringList(_studentNamesPreferenceKey, names); + await preferences.setString( + _studentGendersPreferenceKey, + jsonEncode(genders), + ); + if (!mounted) { + return; + } + setState(() { + _studentNames = names; + _studentGenders = genders; + }); + } catch (_) {} + } + + Future<_StudentData> _loadStudentDataFromSavedResults() async { + try { + final response = await http + .get(_apiEndpoint('/predictions')) + .timeout(const Duration(seconds: 12)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + return const _StudentData(names: [], genders: {}); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + final names = []; + final genders = {}; + for (final item in data) { + final row = (item as Map).cast(); + final name = _cleanStudentName(row['student_name']?.toString() ?? ''); + final gender = _cleanStudentGender( + row['student_gender']?.toString() ?? '', + ); + if (name.isEmpty) { + continue; + } + names.add(name); + if (gender.isNotEmpty) { + genders[name.toLowerCase()] = gender; + } + } + return _StudentData(names: names, genders: genders); + } catch (_) { + return const _StudentData(names: [], genders: {}); + } + } + + Map _decodeStudentGenders(String rawJson) { + try { + final decoded = jsonDecode(rawJson) as Map; + return decoded.map( + (key, value) => + MapEntry(key.toLowerCase(), _cleanStudentGender(value.toString())), + )..removeWhere((_, value) => value.isEmpty); + } catch (_) { + return {}; + } + } + + Map _mergeStudentGenders( + Map localGenders, + Map savedGenders, + ) { + return {...savedGenders, ...localGenders} + ..removeWhere((_, value) => value.isEmpty); + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + String _cleanStudentGender(String gender) { + final cleanGender = gender.trim(); + return cleanGender == 'Laki-laki' || cleanGender == 'Perempuan' + ? cleanGender + : ''; + } + + Future _rememberStudentName(String name, {String? gender}) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final cleanGender = _cleanStudentGender(gender ?? _studentGender); + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final updatedGenders = Map.from(_studentGenders); + if (cleanGender.isNotEmpty) { + updatedGenders[cleanName.toLowerCase()] = cleanGender; + } + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + await preferences.setString( + _studentGendersPreferenceKey, + jsonEncode(updatedGenders), + ); + if (mounted) { + setState(() { + _studentNames = updated; + _studentGenders = updatedGenders; + _studentNameController.text = cleanName; + if (cleanGender.isNotEmpty) { + _studentGender = cleanGender; + } + }); + } + } + + void _applyStudentGenderFromName(String name) { + final gender = _studentGenders[_cleanStudentName(name).toLowerCase()]; + if (gender == null || gender.isEmpty) { + return; + } + setState(() { + _studentGender = gender; + _prediction = null; + _errorMessage = null; + }); + } + + bool get _hasStoredGenderForCurrentStudent { + final cleanName = _cleanStudentName(_studentNameController.text); + if (cleanName.isEmpty) { + return false; + } + return _studentGenders[cleanName.toLowerCase()]?.isNotEmpty == true; + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + if (_studentGender.isEmpty) { + setState(() { + _errorMessage = 'Pilih jenis kelamin siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.fields['student_gender'] = _studentGender; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw _PredictionApiException( + response.statusCode, + _readApiErrorMessage(responseBody), + ); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } on _PredictionApiException catch (error) { + setState(() { + _errorMessage = 'Gagal prediksi: ${error.message}'; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName( + prediction.studentName, + gender: _studentGender, + ); + _studentNameController.clear(); + setState(() { + _studentGender = ''; + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'limit': '1000'}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + final displayUserName = + widget.session.fullName.trim().isEmpty + ? widget.session.username + : widget.session.fullName.trim(); + + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + Padding( + padding: const EdgeInsets.only(right: 12), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 92), + child: Text( + displayUserName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.text, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + studentGender: _studentGender, + hasStoredStudentGender: _hasStoredGenderForCurrentStudent, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onSelectStudentName: _applyStudentGenderFromName, + onGenderChanged: (value) { + setState(() { + _studentGender = value ?? ''; + _prediction = null; + _errorMessage = null; + }); + }, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Pengaturan'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () { + Navigator.pop(context); + _confirmLogout(); + }, + child: const Text('Logout'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class _StudentData { + const _StudentData({required this.names, required this.genders}); + + final List names; + final Map genders; +} + +class _PredictionApiException implements Exception { + const _PredictionApiException(this.statusCode, this.message); + + final int statusCode; + final String message; + + @override + String toString() => 'API error $statusCode: $message'; +} + +String _readApiErrorMessage(String responseBody) { + try { + final decoded = jsonDecode(responseBody) as Map; + final detail = decoded['detail']; + if (detail is String && detail.trim().isNotEmpty) { + return detail.trim(); + } + if (detail != null) { + return detail.toString(); + } + } catch (_) {} + return responseBody.trim().isEmpty + ? 'Server mengembalikan respons kosong.' + : responseBody.trim(); +} diff --git a/cv_app/mobile_app/lib/fitur/prediction/prediction_page.dart b/cv_app/mobile_app/lib/fitur/prediction/prediction_page.dart new file mode 100644 index 0000000..ae4881c --- /dev/null +++ b/cv_app/mobile_app/lib/fitur/prediction/prediction_page.dart @@ -0,0 +1,950 @@ +part of '../../main.dart'; + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.hasStoredStudentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onSelectStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final bool hasStoredStudentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onSelectStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + onSelectName: onSelectStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + isStored: hasStoredStudentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + required this.onSelectName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + final ValueChanged onSelectName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({ + required this.value, + required this.isStored, + required this.onChanged, + }); + + final String value; + final bool isStored; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + if (isStored && value.isNotEmpty) { + return InputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + child: Row( + children: [ + Expanded(child: Text(value)), + const Text( + 'Tersimpan', + style: TextStyle(color: AppColors.blueSoft), + ), + ], + ), + ); + } + + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + widget.onSelectName(name); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/cv_app/mobile_app/lib/fitur/saved_analyses/saved_analyses_page.dart b/cv_app/mobile_app/lib/fitur/saved_analyses/saved_analyses_page.dart new file mode 100644 index 0000000..16a9536 --- /dev/null +++ b/cv_app/mobile_app/lib/fitur/saved_analyses/saved_analyses_page.dart @@ -0,0 +1,219 @@ +part of '../../main.dart'; + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + final groups = StudentAnalysisGroup.fromAnalyses(analyses); + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: groups.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final group = groups[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: const Icon(Icons.folder_outlined), + ), + title: Text(group.studentName), + subtitle: Text( + '${group.latest.genderLabel} | ${group.count} hasil analisis\nRata-rata PD: ${group.averagePdPercent}%', + ), + isThreeLine: true, + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => + StudentAnalysisDetailPage(group: group), + ), + ); + }, + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class StudentAnalysisGroup { + const StudentAnalysisGroup({ + required this.studentName, + required this.analyses, + }); + + final String studentName; + final List analyses; + + SavedAnalysis get latest => analyses.first; + int get count => analyses.length; + String get averagePdPercent { + if (analyses.isEmpty) { + return '0.0'; + } + final average = + analyses.fold( + 0, + (total, analysis) => total + analysis.probabilityPd, + ) / + analyses.length; + return (average * 100).toStringAsFixed(1); + } + + static List fromAnalyses(List analyses) { + final grouped = >{}; + final displayNames = {}; + + for (final analysis in analyses) { + final name = + analysis.studentName.trim().isEmpty + ? '-' + : analysis.studentName.trim(); + final key = name.toLowerCase(); + displayNames.putIfAbsent(key, () => name); + grouped.putIfAbsent(key, () => []).add(analysis); + } + + final groups = + grouped.entries + .map( + (entry) => StudentAnalysisGroup( + studentName: displayNames[entry.key] ?? entry.key, + analyses: entry.value, + ), + ) + .toList(); + groups.sort( + (a, b) => + a.studentName.toLowerCase().compareTo(b.studentName.toLowerCase()), + ); + return groups; + } +} + +class StudentAnalysisDetailPage extends StatelessWidget { + const StudentAnalysisDetailPage({super.key, required this.group}); + + final StudentAnalysisGroup group; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(group.studentName)), + body: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: group.analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = group.analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text('Prediksi ${index + 1}'), + subtitle: Text( + '${analysis.genderLabel} | ${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + ), + ); + }, + ), + ); + } +} diff --git a/cv_app/mobile_app/lib/main.dart b/cv_app/mobile_app/lib/main.dart new file mode 100644 index 0000000..dfd6095 --- /dev/null +++ b/cv_app/mobile_app/lib/main.dart @@ -0,0 +1,36 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +part 'app/app.dart'; +part 'fitur/auth/auth.dart'; +part 'fitur/home/confivoice_shell.dart'; +part 'fitur/saved_analyses/saved_analyses_page.dart'; +part 'models/analysis_models.dart'; +part 'fitur/prediction/prediction_page.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +String defaultApiEndpoint() { + return kIsWeb + ? 'http://127.0.0.1:8000/predict' + : 'http://MacBook-Pro-2.local:8000/predict'; +} + +String normalizeApiEndpoint(String endpoint) { + final cleanEndpoint = endpoint.trim(); + if (kIsWeb && cleanEndpoint.toLowerCase().contains('macbook-pro-2.local')) { + return 'http://127.0.0.1:8000/predict'; + } + return cleanEndpoint; +} diff --git a/cv_app/mobile_app/lib/models/analysis_models.dart b/cv_app/mobile_app/lib/models/analysis_models.dart new file mode 100644 index 0000000..090e066 --- /dev/null +++ b/cv_app/mobile_app/lib/models/analysis_models.dart @@ -0,0 +1,138 @@ +part of '../main.dart'; + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.studentGender, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + studentGender: json['student_gender']?.toString() ?? '', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String studentGender; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.studentGender, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + studentGender: json['student_gender']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId, String studentGender) { + return { + ...rawJson, + 'student_name': studentName, + 'student_gender': studentGender, + 'user_id': userId, + }; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final String studentGender; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; + + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} diff --git a/cv_app/mobile_app/pubspec.lock b/cv_app/mobile_app/pubspec.lock new file mode 100644 index 0000000..b03df25 --- /dev/null +++ b/cv_app/mobile_app/pubspec.lock @@ -0,0 +1,466 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 + url: "https://pub.dev" + source: hosted + version: "2.12.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "942a4791cd385a68ccb3b32c71c427aba508a1bb949b86dff2adbe4049f16239" + url: "https://pub.dev" + source: hosted + version: "0.3.5" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" + url: "https://pub.dev" + source: hosted + version: "1.3.2" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 + url: "https://pub.dev" + source: hosted + version: "8.3.7" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: c2fe1001710127dfa7da89977a08d591398370d099aacdaa6d44da7eb14b8476 + url: "https://pub.dev" + source: hosted + version: "2.0.31" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec + url: "https://pub.dev" + source: hosted + version: "10.0.8" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + url: "https://pub.dev" + source: hosted + version: "3.0.9" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + record: + dependency: "direct main" + description: + name: record + sha256: "10911465138fafacef459a780564e883e01bd48eabf87ab20543684884492870" + url: "https://pub.dev" + source: hosted + version: "6.2.1" + record_android: + dependency: transitive + description: + name: record_android + sha256: eb1732e42d0d2a1895b8db86e4fc917287e6d8491b6ed59918aea8bed6c69de4 + url: "https://pub.dev" + source: hosted + version: "1.5.2" + record_ios: + dependency: transitive + description: + name: record_ios + sha256: c051fb48edd7a0e265daafb9108730dc827c27b551728a3fdfb3ef69efd89c73 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: "31181787bf7eccb0e298835836b69b3cd0a903863b75d70e937de3dec71cd8f3" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + record_macos: + dependency: transitive + description: + name: record_macos + sha256: cfe1b61435e27db418bf513dc36820d10c9f7eb1843786c2c9a52e07e2f4f627 + url: "https://pub.dev" + source: hosted + version: "1.2.2" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: "8e56cbe06c6984137fb86132ff03459f29938d927496d9b2d0962e2d6345d488" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78" + url: "https://pub.dev" + source: hosted + version: "1.0.7" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: bd14436108211b0d4ee5038689a56d4ae3620fd72fd6036e113bf1345bc74d9e + url: "https://pub.dev" + source: hosted + version: "2.4.13" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + url: "https://pub.dev" + source: hosted + version: "0.7.4" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" + url: "https://pub.dev" + source: hosted + version: "14.3.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba" + url: "https://pub.dev" + source: hosted + version: "5.13.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" +sdks: + dart: ">=3.7.2 <4.0.0" + flutter: ">=3.29.0" diff --git a/cv_app/mobile_app/pubspec.yaml b/cv_app/mobile_app/pubspec.yaml new file mode 100644 index 0000000..58b7428 --- /dev/null +++ b/cv_app/mobile_app/pubspec.yaml @@ -0,0 +1,91 @@ +name: confivoice_mobile +description: "ConfiVoice mobile app for Android prediction client." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.7.2 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + cupertino_icons: ^1.0.8 + file_picker: ^8.1.7 + http: ^1.2.2 + record: ^6.2.1 + shared_preferences: ^2.5.3 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^5.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/cv_app/mobile_app/test/widget_test.dart b/cv_app/mobile_app/test/widget_test.dart new file mode 100644 index 0000000..a7e8845 --- /dev/null +++ b/cv_app/mobile_app/test/widget_test.dart @@ -0,0 +1,11 @@ +import 'package:confivoice_mobile/main.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('ConfiVoice mobile renders home page', (WidgetTester tester) async { + await tester.pumpWidget(const ConfiVoiceMobileApp()); + + expect(find.text('ConfiVoice'), findsOneWidget); + expect(find.text('Mulai Prediksi'), findsOneWidget); + }); +} diff --git a/cv_app/mobile_app/web/favicon.png b/cv_app/mobile_app/web/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..8aaa46ac1ae21512746f852a42ba87e4165dfdd1 GIT binary patch literal 917 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|I14-?iy0X7 zltGxWVyS%@P(fs7NJL45ua8x7ey(0(N`6wRUPW#JP&EUCO@$SZnVVXYs8ErclUHn2 zVXFjIVFhG^g!Ppaz)DK8ZIvQ?0~DO|i&7O#^-S~(l1AfjnEK zjFOT9D}DX)@^Za$W4-*MbbUihOG|wNBYh(yU7!lx;>x^|#0uTKVr7USFmqf|i<65o z3raHc^AtelCMM;Vme?vOfh>Xph&xL%(-1c06+^uR^q@XSM&D4+Kp$>4P^%3{)XKjo zGZknv$b36P8?Z_gF{nK@`XI}Z90TzwSQO}0J1!f2c(B=V`5aP@1P1a|PZ!4!3&Gl8 zTYqUsf!gYFyJnXpu0!n&N*SYAX-%d(5gVjrHJWqXQshj@!Zm{!01WsQrH~9=kTxW#6SvuapgMqt>$=j#%eyGrQzr zP{L-3gsMA^$I1&gsBAEL+vxi1*Igl=8#8`5?A-T5=z-sk46WA1IUT)AIZHx1rdUrf zVJrJn<74DDw`j)Ki#gt}mIT-Q`XRa2-jQXQoI%w`nb|XblvzK${ZzlV)m-XcwC(od z71_OEC5Bt9GEXosOXaPTYOia#R4ID2TiU~`zVMl08TV_C%DnU4^+HE>9(CE4D6?Fz oujB08i7adh9xk7*FX66dWH6F5TM;?E2b5PlUHx3vIVCg!0Dx9vYXATM literal 0 HcmV?d00001 diff --git a/cv_app/mobile_app/web/icons/Icon-192.png b/cv_app/mobile_app/web/icons/Icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..b749bfef07473333cf1dd31e9eed89862a5d52aa GIT binary patch literal 5292 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 literal 0 HcmV?d00001 diff --git a/cv_app/mobile_app/web/icons/Icon-512.png b/cv_app/mobile_app/web/icons/Icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..88cfd48dff1169879ba46840804b412fe02fefd6 GIT binary patch literal 8252 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 literal 0 HcmV?d00001 diff --git a/cv_app/mobile_app/web/icons/Icon-maskable-192.png b/cv_app/mobile_app/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000000000000000000000000000000000000..eb9b4d76e525556d5d89141648c724331630325d GIT binary patch literal 5594 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 X>c!)3PgKfL&lX^$Z>Cpu&6)6jvi^Z! literal 0 HcmV?d00001 diff --git a/cv_app/mobile_app/web/icons/Icon-maskable-512.png b/cv_app/mobile_app/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..d69c56691fbdb0b7efa65097c7cc1edac12a6d3e GIT binary patch literal 20998 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 MPgg&ebxsLQ025IeI{*Lx literal 0 HcmV?d00001 diff --git a/cv_app/mobile_app/web/index.html b/cv_app/mobile_app/web/index.html new file mode 100644 index 0000000..715d43e --- /dev/null +++ b/cv_app/mobile_app/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + confivoice_mobile + + + + + + diff --git a/cv_app/mobile_app/web/manifest.json b/cv_app/mobile_app/web/manifest.json new file mode 100644 index 0000000..e214ba8 --- /dev/null +++ b/cv_app/mobile_app/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "confivoice_mobile", + "short_name": "confivoice_mobile", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/cv_app/predict_app.py b/cv_app/predict_app.py new file mode 100644 index 0000000..99c6b5f --- /dev/null +++ b/cv_app/predict_app.py @@ -0,0 +1,173 @@ +from pathlib import Path +import sys + +import joblib +import numpy as np + +from audio_utils_app import convert_audio_to_wav + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" +MODEL_DIR = ML_DIR / "models" +MODEL_PATH = MODEL_DIR / "svm_voice_confidence_model.joblib" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from features import ( # noqa: E402 + LABEL_PD, + LABEL_TPD, + analyze_audio, + build_prediction_explanation, +) + + +CONFIDENCE_THRESHOLD = 0.60 +MODEL_NOT_FOUND_MESSAGE = "Model tidak ditemukan. Pastikan file model berada di folder ml/models/." + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +def find_model_path(): + if MODEL_PATH.exists(): + return MODEL_PATH + + available_models = sorted(MODEL_DIR.glob("*.joblib")) + if available_models: + return available_models[0] + + raise FileNotFoundError(MODEL_NOT_FOUND_MESSAGE) + + +def load_prediction_model(model_path=None): + model_path = Path(model_path) if model_path else find_model_path() + if not model_path.exists(): + raise FileNotFoundError(MODEL_NOT_FOUND_MESSAGE) + return joblib.load(model_path) + + +def get_expected_feature_count(model): + if hasattr(model, "named_steps") and "scaler" in model.named_steps: + return getattr(model.named_steps["scaler"], "n_features_in_", None) + return getattr(model, "n_features_in_", None) + + +def calculate_indicator_probability(model_probability_pd, indicators): + indicator_pd = ( + 0.15 * indicators["volume_score"] + + 0.35 * indicators["intonation_score"] + + 0.50 * indicators["pause_score"] + ) + + adjusted_pd = 0.80 * model_probability_pd + 0.20 * indicator_pd + + if indicators["pause_score"] < 0.35: + adjusted_pd -= (0.35 - indicators["pause_score"]) * 0.25 + if indicators["volume_score"] < 0.18: + adjusted_pd -= (0.18 - indicators["volume_score"]) * 0.10 + if indicators["intonation_score"] < 0.45: + adjusted_pd -= (0.45 - indicators["intonation_score"]) * 0.15 + + return float(np.clip(adjusted_pd, 0.01, 0.99)), float(np.clip(indicator_pd, 0.0, 1.0)) + + +def predict_audio(audio_path, model_path=None): + """ + Prediksi audio dari aplikasi. + Model tetap dibaca dari ../ml/models dan fitur diambil dari ../ml/features.py. + """ + model = load_prediction_model(model_path) + wav_path = convert_audio_to_wav(audio_path) + + try: + try: + analysis = analyze_audio(wav_path, validate_quality=True) + except ValueError as error: + fallback_analysis = analyze_audio(wav_path, validate_quality=False) + return { + "is_valid_audio": False, + "error_message": str(error), + "predicted_label": None, + "description": "Audio tidak valid", + "confidence": 0.0, + "probability_pd": 0.0, + "probability_tpd": 0.0, + "margin": 0.0, + "audio_quality": fallback_analysis["audio_quality"], + "voice_indicators": fallback_analysis["voice_indicators"], + "explanation": str(error), + } + + features = analysis["features"] + feature_matrix = np.asarray(features, dtype=np.float32).reshape(1, -1) + + expected_feature_count = get_expected_feature_count(model) + if expected_feature_count and feature_matrix.shape[1] != expected_feature_count: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {feature_matrix.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_feature_count} fitur." + ) + + prediction = model.predict(feature_matrix) + probabilities = model.predict_proba(feature_matrix) + + predicted_label = str(prediction[0]) + class_probabilities = { + str(label): float(probability) + for label, probability in zip(model.classes_, probabilities[0]) + } + model_probability_pd = float(class_probabilities.get(LABEL_PD, 0.0)) + probability_pd, indicator_pd_score = calculate_indicator_probability( + model_probability_pd, + analysis["voice_indicators"], + ) + probability_tpd = float(1.0 - probability_pd) + predicted_label = LABEL_PD if probability_pd >= probability_tpd else LABEL_TPD + confidence = float(max(probability_pd, probability_tpd)) + margin = float(abs(probability_pd - probability_tpd)) + explanation = build_prediction_explanation( + predicted_label, + confidence, + analysis["voice_indicators"], + ) + + return { + "is_valid_audio": True, + "predicted_label": predicted_label, + "label": predicted_label, + "description": LABEL_DESCRIPTION.get(predicted_label, predicted_label), + "confidence": confidence, + "probability_pd": probability_pd, + "probability_tpd": probability_tpd, + "margin": margin, + "probabilities": { + LABEL_PD: probability_pd, + LABEL_TPD: probability_tpd, + }, + "audio_quality": analysis["audio_quality"], + "voice_indicators": analysis["voice_indicators"], + "indicator_pd_score": indicator_pd_score, + "model_probability_pd": model_probability_pd, + "model_probability_tpd": float(class_probabilities.get(LABEL_TPD, 0.0)), + "explanation": explanation, + } + finally: + Path(wav_path).unlink(missing_ok=True) + + +def get_model_info(): + model_path = find_model_path() + model = load_prediction_model(model_path) + + return { + "model_path": str(model_path.relative_to(PROJECT_DIR)), + "model_type": type(model).__name__, + "classes": [str(label) for label in getattr(model, "classes_", [])], + "expected_features": get_expected_feature_count(model), + } diff --git a/cv_app/requirements.txt b/cv_app/requirements.txt new file mode 100644 index 0000000..e927c2a --- /dev/null +++ b/cv_app/requirements.txt @@ -0,0 +1,16 @@ +numpy>=1.23,<2.0 +numba>=0.58,<0.60 +librosa==0.10.2.post1 +scikit-learn>=1.3,<1.6 +joblib>=1.3 +streamlit>=1.31 +soundfile>=0.12 +pydub>=0.25 +imageio-ffmpeg>=0.5 +ffmpeg-python>=0.2 +matplotlib +pandas +fastapi +uvicorn +python-multipart +PyMySQL diff --git a/cv_web/README.md b/cv_web/README.md new file mode 100644 index 0000000..6f990f6 --- /dev/null +++ b/cv_web/README.md @@ -0,0 +1,69 @@ +# ConfiVoice Admin Web + +Folder `cv_web/` berisi website admin untuk melihat hasil prediksi siswa/i. + +Website ini membaca database yang sama dengan API di folder `ml/`. + +## Menjalankan Admin Web + +```bash +cd cv_web +../cv_app/.venv/bin/python -m uvicorn app:app --host 0.0.0.0 --port 8001 +``` + +Buka: + +```text +http://localhost:8001/admin +``` + +## Database phpMyAdmin / MySQL + +Mode default memakai MySQL/MariaDB agar data bisa dicek lewat phpMyAdmin. + +```text +database: confivoice +table: prediction_results +``` + +Nyalakan MySQL dari XAMPP/MAMP, lalu jalankan API dan admin web. API akan +membuat database dan tabel otomatis jika user MySQL punya izin. + +Konfigurasi default: + +```text +host: 127.0.0.1 +port: 3306 +user: root +password: kosong +``` + +Jika setting MySQL berbeda, jalankan API dan admin web dengan environment yang +sama: + +```bash +export CONFIVOICE_DB_DRIVER=mysql +export CONFIVOICE_MYSQL_HOST=127.0.0.1 +export CONFIVOICE_MYSQL_PORT=3306 +export CONFIVOICE_MYSQL_USER=root +export CONFIVOICE_MYSQL_PASSWORD= +export CONFIVOICE_MYSQL_DATABASE=confivoice +``` + +Data berada di tabel: + +```text +prediction_results +``` + +Schema manual untuk phpMyAdmin: + +```text +cv_web/schema_mysql.sql +``` + +SQLite lama, jika masih ada, berada di: + +```text +cv_web/data/confivoice.db +``` diff --git a/cv_web/app.py b/cv_web/app.py new file mode 100644 index 0000000..f51c1cd --- /dev/null +++ b/cv_web/app.py @@ -0,0 +1,1993 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return HTMLResponse(_auth_page(mode="login")) + + +@app.post("/admin/login", response_class=HTMLResponse) +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return HTMLResponse( + _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return HTMLResponse(_auth_page(mode="register")) + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return HTMLResponse( + _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + ) + if len(username) < 3: + return HTMLResponse( + _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + ) + if len(password) < 6: + return HTMLResponse( + _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + ) + if password != confirm_password: + return HTMLResponse( + _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return HTMLResponse( + _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + ) + + return HTMLResponse( + _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + eye_icon_json = json.dumps(eye_icon) + eye_off_icon_json = json.dumps(eye_off_icon) + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + f""" + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{'Buat akun Admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/cv_web/mysql.env.example b/cv_web/mysql.env.example new file mode 100644 index 0000000..da5f793 --- /dev/null +++ b/cv_web/mysql.env.example @@ -0,0 +1,6 @@ +export CONFIVOICE_DB_DRIVER=mysql +export CONFIVOICE_MYSQL_HOST=127.0.0.1 +export CONFIVOICE_MYSQL_PORT=3306 +export CONFIVOICE_MYSQL_USER=root +export CONFIVOICE_MYSQL_PASSWORD= +export CONFIVOICE_MYSQL_DATABASE=confivoice diff --git a/cv_web/schema_mysql.sql b/cv_web/schema_mysql.sql new file mode 100644 index 0000000..d054781 --- /dev/null +++ b/cv_web/schema_mysql.sql @@ -0,0 +1,34 @@ +CREATE DATABASE IF NOT EXISTS confivoice + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +USE confivoice; + +CREATE TABLE IF NOT EXISTS prediction_results ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NULL, + student_name VARCHAR(255) NOT NULL, + student_gender VARCHAR(20), + predicted_label VARCHAR(20), + description VARCHAR(255), + confidence DOUBLE NOT NULL DEFAULT 0, + probability_pd DOUBLE NOT NULL DEFAULT 0, + probability_tpd DOUBLE NOT NULL DEFAULT 0, + is_valid_audio TINYINT(1) NOT NULL DEFAULT 1, + error_message TEXT, + audio_duration DOUBLE NOT NULL DEFAULT 0, + volume_score DOUBLE NOT NULL DEFAULT 0, + intonation_score DOUBLE NOT NULL DEFAULT 0, + pause_score DOUBLE NOT NULL DEFAULT 0, + speech_activity_ratio DOUBLE NOT NULL DEFAULT 0, + silence_ratio DOUBLE NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + full_name VARCHAR(255) NOT NULL, + username VARCHAR(120) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/docs/blackbox/Pengujian_Blackbox_ConfiVoice.html b/docs/blackbox/Pengujian_Blackbox_ConfiVoice.html new file mode 100644 index 0000000..4c8a982 --- /dev/null +++ b/docs/blackbox/Pengujian_Blackbox_ConfiVoice.html @@ -0,0 +1,387 @@ + + + + + Pengujian Blackbox ConfiVoice + + + +
+

Pengujian Blackbox User

+ + + + + + + + + + + + + + + + + +
Judul:Klasifikasi Tingkat Percaya Diri Berdasarkan Analisis Suara Menggunakan Pendekatan Machine Learning Berbasis Mobile
Nama:............................................................
Jabatan:User/Guru
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
No.HalamanSkenarioHasil yang DiharapkanKeterangan
BerhasilTidak Berhasil
1Halaman LoginPengguna membuka aplikasi ConfiVoice.Sistem menampilkan form login, tombol daftar akun, dan pengaturan endpoint API.✓ 
2Halaman DaftarPengguna memilih menu daftar dan mengisi nama lengkap, username, password, serta konfirmasi password.Sistem membuat akun baru dan mengarahkan pengguna kembali ke halaman login.✓ 
3Halaman LoginPengguna mengisi username dan password yang valid.Sistem berhasil masuk dan mengarahkan pengguna ke halaman utama analisis.✓ 
4Halaman UtamaPengguna mengisi nama siswa dan memilih jenis kelamin.Sistem menerima data siswa tanpa menampilkan error.✓ 
5Halaman UtamaPengguna memilih file audio dari perangkat.Sistem menampilkan audio yang dipilih dan menampilkan preview gelombang suara.✓ 
6Halaman UtamaPengguna menekan tombol rekam, berbicara, lalu menghentikan rekaman.Sistem menyimpan rekaman dalam format audio dan menampilkan preview hasil rekaman.✓ 
7Halaman PrediksiPengguna menekan tombol Prediksi setelah data siswa dan audio sudah lengkap.Sistem mengirim audio ke backend dan menampilkan hasil klasifikasi percaya diri atau tidak percaya diri beserta nilai persentase PD dan TPD.✓ 
8Halaman PrediksiPengguna menekan tombol Prediksi ketika nama, jenis kelamin, atau audio belum lengkap.Sistem menampilkan pesan peringatan agar pengguna melengkapi data terlebih dahulu.✓ 
9Halaman Hasil PrediksiPengguna menekan tombol Simpan Hasil setelah hasil prediksi muncul.Sistem menyimpan hasil analisis ke database dan mengosongkan form untuk pengujian siswa berikutnya.✓ 
10Halaman Lihat AnalisisPengguna membuka menu Lihat Analisis.Sistem menampilkan daftar riwayat hasil analisis yang sudah tersimpan berdasarkan data siswa.✓ 
11Halaman Detail AnalisisPengguna memilih salah satu data siswa pada riwayat analisis.Sistem menampilkan detail hasil prediksi, jenis kelamin, label prediksi, nilai PD, dan nilai TPD.✓ 
12LogoutPengguna menekan tombol logout.Sistem mengakhiri sesi pengguna dan kembali ke halaman login.✓ 
+ +
+
+

Pengujian Blackbox Web Admin

+ + + + + + + + + + + + + + + + + +
Judul:Klasifikasi Tingkat Percaya Diri Berdasarkan Analisis Suara Menggunakan Pendekatan Machine Learning Berbasis Mobile
Nama:............................................................
Jabatan:Admin
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
No.HalamanSkenarioHasil yang DiharapkanKeterangan
BerhasilTidak Berhasil
1Halaman Login AdminAdmin membuka halaman login web admin.Sistem menampilkan form masuk admin dan pilihan daftar akun.✓ 
2Halaman Register AdminAdmin mengisi nama lengkap, username, password, dan konfirmasi password.Sistem membuat akun admin baru dan mengarahkan admin ke halaman login.✓ 
3Halaman Login AdminAdmin mengisi username dan password yang valid.Sistem berhasil masuk dan mengarahkan admin ke dashboard.✓ 
4Dashboard AdminAdmin membuka halaman dashboard.Sistem menampilkan statistik total prediksi, jumlah PD, jumlah TPD, rata-rata PD, serta tabel hasil analisis.✓ 
5Filter DashboardAdmin menggunakan pencarian atau filter label pada data prediksi.Sistem menampilkan data hasil analisis sesuai kata kunci atau label yang dipilih.✓ 
6Tambah Data PrediksiAdmin membuka halaman tambah data dan mengisi form hasil analisis secara manual.Sistem menyimpan data prediksi baru ke database.✓ 
7Edit Data PrediksiAdmin membuka halaman edit pada salah satu data prediksi dan mengubah isian data.Sistem memperbarui data prediksi pada database.✓ 
8Hapus Data PrediksiAdmin menekan tombol hapus pada salah satu data prediksi.Sistem menampilkan konfirmasi dan menghapus data setelah admin menyetujui.✓ 
9Detail SiswaAdmin membuka detail riwayat salah satu siswa.Sistem menampilkan kumpulan hasil analisis berdasarkan nama siswa yang dipilih.✓ 
10Kelola UserAdmin membuka halaman kelola user.Sistem menampilkan daftar user dan form untuk menambahkan user.✓ 
11Edit/Hapus UserAdmin mengubah atau menghapus data user.Sistem memperbarui atau menghapus data user dari database.✓ 
12Logout AdminAdmin memilih menu logout.Sistem menghapus sesi admin dan mengarahkan kembali ke halaman login admin.✓ 
+ +
+ + diff --git a/docs/blackbox/check/Pengujian_Blackbox_ConfiVoice.txt b/docs/blackbox/check/Pengujian_Blackbox_ConfiVoice.txt new file mode 100644 index 0000000..dad1e71 --- /dev/null +++ b/docs/blackbox/check/Pengujian_Blackbox_ConfiVoice.txt @@ -0,0 +1,176 @@ +Pengujian Blackbox User +Judul : Klasifikasi Tingkat Percaya Diri Berdasarkan Analisis Suara Menggunakan Pendekatan Machine Learning Berbasis Mobile +Nama : ............................................................ +Jabatan : User/Guru +No. +Halaman +Skenario +Hasil yang Diharapkan +Keterangan + + + + +Berhasil +Tidak Berhasil +1 +Halaman Login +Pengguna membuka aplikasi ConfiVoice. +Sistem menampilkan form login, tombol daftar akun, dan pengaturan endpoint API. +✓ + +2 +Halaman Daftar +Pengguna memilih menu daftar dan mengisi nama lengkap, username, password, serta konfirmasi password. +Sistem membuat akun baru dan mengarahkan pengguna kembali ke halaman login. +✓ + +3 +Halaman Login +Pengguna mengisi username dan password yang valid. +Sistem berhasil masuk dan mengarahkan pengguna ke halaman utama analisis. +✓ + +4 +Halaman Utama +Pengguna mengisi nama siswa dan memilih jenis kelamin. +Sistem menerima data siswa tanpa menampilkan error. +✓ + +5 +Halaman Utama +Pengguna memilih file audio dari perangkat. +Sistem menampilkan audio yang dipilih dan menampilkan preview gelombang suara. +✓ + +6 +Halaman Utama +Pengguna menekan tombol rekam, berbicara, lalu menghentikan rekaman. +Sistem menyimpan rekaman dalam format audio dan menampilkan preview hasil rekaman. +✓ + +7 +Halaman Prediksi +Pengguna menekan tombol Prediksi setelah data siswa dan audio sudah lengkap. +Sistem mengirim audio ke backend dan menampilkan hasil klasifikasi percaya diri atau tidak percaya diri beserta nilai persentase PD dan TPD. +✓ + +8 +Halaman Prediksi +Pengguna menekan tombol Prediksi ketika nama, jenis kelamin, atau audio belum lengkap. +Sistem menampilkan pesan peringatan agar pengguna melengkapi data terlebih dahulu. +✓ + +9 +Halaman Hasil Prediksi +Pengguna menekan tombol Simpan Hasil setelah hasil prediksi muncul. +Sistem menyimpan hasil analisis ke database dan mengosongkan form untuk pengujian siswa berikutnya. +✓ + +10 +Halaman Lihat Analisis +Pengguna membuka menu Lihat Analisis. +Sistem menampilkan daftar riwayat hasil analisis yang sudah tersimpan berdasarkan data siswa. +✓ + +11 +Halaman Detail Analisis +Pengguna memilih salah satu data siswa pada riwayat analisis. +Sistem menampilkan detail hasil prediksi, jenis kelamin, label prediksi, nilai PD, dan nilai TPD. +✓ + +12 +Logout +Pengguna menekan tombol logout. +Sistem mengakhiri sesi pengguna dan kembali ke halaman login. +✓ + + +Pengujian Blackbox Web Admin +Judul : Klasifikasi Tingkat Percaya Diri Berdasarkan Analisis Suara Menggunakan Pendekatan Machine Learning Berbasis Mobile +Nama : ............................................................ +Jabatan : Admin +No. +Halaman +Skenario +Hasil yang Diharapkan +Keterangan + + + + +Berhasil +Tidak Berhasil +1 +Halaman Login Admin +Admin membuka halaman login web admin. +Sistem menampilkan form masuk admin dan pilihan daftar akun. +✓ + +2 +Halaman Register Admin +Admin mengisi nama lengkap, username, password, dan konfirmasi password. +Sistem membuat akun admin baru dan mengarahkan admin ke halaman login. +✓ + +3 +Halaman Login Admin +Admin mengisi username dan password yang valid. +Sistem berhasil masuk dan mengarahkan admin ke dashboard. +✓ + +4 +Dashboard Admin +Admin membuka halaman dashboard. +Sistem menampilkan statistik total prediksi, jumlah PD, jumlah TPD, rata-rata PD, serta tabel hasil analisis. +✓ + +5 +Filter Dashboard +Admin menggunakan pencarian atau filter label pada data prediksi. +Sistem menampilkan data hasil analisis sesuai kata kunci atau label yang dipilih. +✓ + +6 +Tambah Data Prediksi +Admin membuka halaman tambah data dan mengisi form hasil analisis secara manual. +Sistem menyimpan data prediksi baru ke database. +✓ + +7 +Edit Data Prediksi +Admin membuka halaman edit pada salah satu data prediksi dan mengubah isian data. +Sistem memperbarui data prediksi pada database. +✓ + +8 +Hapus Data Prediksi +Admin menekan tombol hapus pada salah satu data prediksi. +Sistem menampilkan konfirmasi dan menghapus data setelah admin menyetujui. +✓ + +9 +Detail Siswa +Admin membuka detail riwayat salah satu siswa. +Sistem menampilkan kumpulan hasil analisis berdasarkan nama siswa yang dipilih. +✓ + +10 +Kelola User +Admin membuka halaman kelola user. +Sistem menampilkan daftar user dan form untuk menambahkan user. +✓ + +11 +Edit/Hapus User +Admin mengubah atau menghapus data user. +Sistem memperbarui atau menghapus data user dari database. +✓ + +12 +Logout Admin +Admin memilih menu logout. +Sistem menghapus sesi admin dan mengarahkan kembali ke halaman login admin. +✓ + + diff --git a/docs/diagrams/ERD(1M).drawio.png b/docs/diagrams/ERD(1M).drawio.png new file mode 100644 index 0000000000000000000000000000000000000000..2520d86005be2e550afaad69395976af245dfdd7 GIT binary patch literal 125853 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⋘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 literal 0 HcmV?d00001 diff --git a/docs/diagrams/ERD.drawio.png b/docs/diagrams/ERD.drawio.png new file mode 100644 index 0000000000000000000000000000000000000000..828bf3570d26f1daa4e490021fee20c7ea413190 GIT binary patch literal 124391 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±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%0A-+Oc8{-KQHrLNL46suEj z|21-y`GdJy>Sf2qD$0+M8||`_63+0W_2N;psFjjZ3fK$#UXW6wjI-g;$;lkkz1bHI zR8VMeVe_Z3ru~e20@d7iy;XD=wyf)3+Vot5l=`{~^{|ou>Xvhnci{Xm>?KEhD<2=8 zFTO|I7Q`!+bu;4Y)W-{*1M)4Q*>7g8Je{T&8*F_yoo1z*lobIspli>#NBtu0tVGS? zvdW=Y7=YJpsoP}CgGR8?UD(neu-brWGtx;?zO5MaJh| zk(Vdks*YH*mH4YOnC+?}bp@zi0uuy?g2a9kc0|k{?zL0#`r$<{ARAzxwZH8zVqeqc zOVt}(0b5@2p@5cThDvEyWc{(yyqhTX^%IR0y)smQuISN*_uEJoT0o)c^G6FiMz7I3 zB?@=`n|vK84VR)_EUSwLGf_M;N}{_xlS!0~Lx&KEs044Dl6oR8tPV?ZE@eFI4Iow7&_UAah&Spd-FD^|i8QEV=NaUw}tU-4@JU$UiMiI^P68n3~+W|qqz(<{Ob7V zwewhhUq{9*tZQkfMPK$WfDXaY*DmB-)Hqdd z9QeMVE#*hMJk%!|VSGBXY*T_^OSeO2yf?c1a6ceKlNUx$>x*9Reji@A{SH?~$PHd+ zMV*b-W2cAe5|_BarYx#9LLZw z|5-1wR@%VeA zJKOhSq?Nx|AvUtSw?wjlHS^)R;SulfcxyWD;UaQr#X?W8XdM>4p zGnHkXFi)>>(cOasZ8?Cm~145^UJA=nac96DH_&L(bUlxE7bdg6zva)i%M zTYdPB#aTg(CEAUJNx>b_Ug2A~5}-Tk4)hU#E`#cYE(1`nP(R)Mj0;Q*MN$(lnp7+kdS)cxP#UTrUp{|oMx@|*VgDRF!^F*|+f zl406FP^SA~1_7$p+UeqCWK7~^ENH45!`2g?f)*d){AIle_=n)>$(VFgyKyHbs7U&N zm#vjL)QWx2;*9_L1ogHz`a%SuvHWU9!J6k$7j?poFO69T)}^b@XrUzK|Po+$U0;zI&Xfj=&!ZAFk8pt?w zjo<2_o<;NBX*@k%Xw)(PPkUpP;044r^fF7N)+vy_YyIzc-+dAlTaI*tHzaD{^1Kz* zob=fdUqCBX3s%ZBVvjZmx5HUzs2!4;wN}t~+!Ptc5qi@ZO}?No^1~1o@H7Dc-kIV5 zfOjW)$00uC%>(JF$3sNYB}w%QY5VIshk3ncTZ$K>d*_l$=;tci^>q8m)R&g*L5*BS zw2{rlRe|SGM!JR+#}gq0uYAfim`M{?Meu`;lRz!Vx=Ux+$x>5Q`x6FHAnthZeO_La;0mvj>{)&U|{ zG?QtEF1Dc(sRS0TlSO6urKIcM1LllLMg$a24Jeb_Ermnp^Q1TE<^y8rzUw4-1w|`l z4)!i`;aazskFAksV%c`?n%hwAHOu>4^Ps1Ls=(+5D%BZrR=&+SlJ>v)xlIMpq({t{Xb@^zy)UXL(zl;DQOFT_4iTR=c9U?mRHS9f_6 ze|Cj64x14VM|PCI+kZZ;`95oU7O=lk5Xs2k^zx4`HtOz>+Lp5UID+(rno~G=upN%V zD>k&jIQgZSn`rTzX@i?(Mp-+RaDg%+(SqbHq3>sA@;pp~cse`gFnhWRTB48`JNvz5 zcU7FlB<@>7DAD8^V*hlM?$M&jA4bx2K_KsDyg6)fV5I|i=T@y(ang7n(Kod=PsFjcRpR^JD)oDw4l}(x1 zFv!+UuveEoT08f)ePuFPa9&%J@ib<~Gzn6&N<%(%cYI|Me_unb)h=hyrn;BB^j_HY zNc*;h+IR0W>?vn%xzEqa<7};7gZT3;Nr*W8{1IZa*zurvGkvNf&hEOaoCqJh0^_xa z4_)t0Dn`5}XHQ|>aDSm-g;hckvJdqsKhzfRPf=|dA9#KtFh0BV;RJjfSB0fjWakm2 z&z)vbB%R;Ae~SefYjb)T{?dQ3HkI*p15{~={ex4PGxkBR7G-WO$Esbms|y9^?7p=N z1$m2mCY_6L$lwEcDo90D ze~BzAf2xs3amM8@^?og4vQUdXLHT%tx!w+{7$v*AP4Oz}0nTz@HMBKEl()9l%|46g zlVfSZ47mVXR!?oe{K(3~RPT@ARN|>830(XZ3P#aa@J89xlXeePp25zE)OFAt)B-y} z?I!Fn``Bdv2YC_SC;ld&{PyV*A@G?YAwz`%q2%-?^THB!3o-h>6AX!?RNwtlnbeG8{qKXitasrUfdMs{q;c z3I&-_MRI4_>@EY6cl^t)i-U7%o_6?rsthIi+rosR%mp2KzBubtMA1EI&T~yCESfyz zwYA}H`d?2Q!oI7f=($}KCJfxv+ZAmo5d4#SeZNc`>CM@%cCPI_U)Yc+;U`J%z;GRX zdygptE%K@PC=XiX*kE&$5kzH8t`GXIW?2LZQrfJXf$M`n+qCwfhUSH}UO<KobnYJF5nts_85aYEnsuzMo$XWmp;i(`NDbx1IvW(Qpvzd)_2?!;eQktObt(u|FvZ!$J3a6A_)ZfOe5 zTk^MY*5?XV5Fs=FG|f^koWGe)7iS?i`3sdP@R0!MKBU~wX#9!M_wLuOtFd{?q*gAi zyw#3>11Cn;ajeE6XNb{Hi*r%ILA3a{pY1# zC*OS-1-V3e3QAeS1&eXkH%C%P!)s^>zRLZ&_8^i%?<&{0PbJ1B3JpPBBZ>!c<8YC$G7NtwTB z2628#z}=4b^DJf0yh>#2M;#S$RLn@p0~{6?qGA8c%5U_8apwMcPJsi*wv!$&ENKcY}>*#o}urC^>{fI>CwirbM zmLV{(eIj9Be>G#%4bm#;EZpz1ULY@eSud!xTxlXmSX&1W>f57x$G z>L~m%V0zDT3|A@Xysx(w?0w-@U&cqg$PN_5F=0x6DtJLE*U=n8{AItng?ZDT%V6mT z+soR})SmZfJ?~A0PzE8T1tAn7+Ben0rM+wR?o-Qs^3}|D^p4^$m08y?oba0~#YtB9QVU-to!51^i;jZhxL!c&&&3bb&q|0xS8D=tF9pK6U z^ka4*p%D|6Dm$~V;KR^ihoojFNL(i;UBZpObdmlSDJEQJ*bC@j3D>-F_{$|m*APBG z8-+OdbFIovR@gL?!|$_36=I+Vu@+A$?o$^`^`c zqTgbK)ERA0!3tCaJ@k86KP(y*o95qNPV6d4Q2A>qEdu`zTOgxt;|44{o5Pab3s+Oh zL;VLAaG|d=6j-xixDY9^J3wtZ~#|TNw#<%zkr5$iH(lL`I@Y#HI>;VVB9T>(-Rc{{lODJ7E~Vb!t;vIn2Hs6m${F zN=nwCu&V}k5`|0mL9blJS?9!UE?kELKApY)z?U5;v0Txdg%HsDiXE#j&iQ>Zbk$JX zzf~x`m`1pBS;NmzD`$F7Gm_$t7c@5%an_R;CJVmeu-ptM;6u5pLn75Yo<)=Q6xH-% z6eJQ&`>Dnuoo8p6uXKLSsBr2EinEoEh)^^}o0`PSav=X(#USY>QU7sF9GfQ35vZyW zcNNh@)V#v`di&Cv{Hb;G?;%w#R32*?4##Q^sF8z;O z5^N87Y|X3qpJwa}XjB(p1r;Sye%w}EoAi#D+AG}k!5cM^;ye!)8%}HSN#3O4FRI;k z!29Y|aqS83UkS=pRCih<1Z`cpmRgr~>u3a9+PH>AL6-6-(Zo7l3!1qou9tg{m2_gH zS&7QkXK6tJ=ZtN8x7nX64X=`M%R~H(aeE1UKT-J3zZiMHKqGD6uvhK@g&~SQ4Zt6` zh%tbiEfC-~8)shFUyC(gB(SJ>)he>Q;%U=5Qdi&%y;J0elS8S=_#iRR6Eng}>yH zoo=fTa)!pAo|CR&I#p_3yXC7o;-@4Q0? zqM!Njmp1c29`;#cMAC}?#VlPX2#U%V(kAFX)*&kx%8%1{FS${X! zm6n6-|E+N*s-e(}iQJg0cpBpWTGKyo8&dd6t0Rl}6yfaX0TzxNg#2Y~q#vsMgv+Aj zT&z=rshl5UpP#qI)i0f7*Sp4`QCB?^XU+T#zk6)OmxGceqw~^|4aq&4daGZDMxRs4qW1rgYs= zY>X$gy|ouX%Rlr`)VU{e{7-?~;hAK>(`bP*1Jmr~-&zbbY%frpEtsF!t|T$=5x{o! z<3+z$v2mp72F-)BAm>7yQ{pBel7$BwH2c^YHp3h2XuSKW{&f(7-ln^m8R9{CXlU5( zdA6#HTk*RI5HQ>rzEX7q&0&tGr0%cKm)3Jac4`8j9gAHNPLA3*YP)4_Dr68`CwHG1%<%JArdq*naDNNQDVqo6*7HTUkODf^#YGN6LO=v@lY7X}`4 zJ>_YCq-&l@$Ju`}YP2`b;GyJ0UuZEMyxly!p{xZ9BPd4GKOFBbQSv@j5>H$fl|! z{M7QrrtuuIr5PTiETzMvB z@|9BXO+b@ZQC*W9?kx~J0 z!_Lf|vHA!ND(AJk(PdMcsFaCW+JgVw4RU;?dJX)R;@68TwuJEODOjM?eIV0Tv|TI3 z|IO)u1{y+sdep2V(MY7y@70D+P8J(~q%9d zK7pV7!T=;x_u)r^(^ns;+W*TB>Rv%Dd3t^4I;?8u`>5gW2lKf}_51b`{mex%0%d43 zl{y6ouH^JBd_OWGg)6E+PRnJBJ0PVV1lJ~C|41q!`B}WOYlFdRFBrP@#C9m&)$mex zNkBY+3clKVuVdD$oCB1am{jqiM;;2ZfgwN!^`gh9qb+xhr5tDdEm?ZTpV$lQU5)aW zh7Bpc(hWI89zBWBaMqs#S-eNWpga7s`}}XEt^UEPMp95n!FSL66M2$w zd9wQ3$7$GFH6h?6oHxiS;k~W79_(t^xGL>t?;=hq5x7!;T~eB1emCx(QIk5mbx;tn zG8Q4cFeY;;ikbj{gIbCLfo;+p^Jh&+_cQuBz0XIIG(t>w6;}E+OW(J^d3QQ!O8L=2 zK!IyORV??t0piZDFOiHNo5oH#c{cm7wM>gYhZ78tMG0W+z2nh z-S=rZ__ppG`idn$S^rYF>W;5;o6-!>h}zDu`pXBh@u4^bOA-g1Y&-!3PUt`tbHfUa z0@p}hE2U<;_swM{#Z}J`4RH}l6B3-OK)?%hQ%Qqf$@~Dv`Q(|#}5q(JDP`$|djFuN|@#8y|Vj@-D+Rm0w$XK05;SL$3 z4To6z`Z+oNes9y3lLWGF%47A?OQ;%W{a$RU^yMLGm-_rVvL*>bNQ8d^zuYWEj~gT_ z{jOHCT~x7nz?qoc&kRP5!bkJFLFi5&qgF!??sC!Dc1EcI{Ih%E`-%}Gx$mqWhyZ|z1j{n-!qG?HgHEC z-L@q?m*by6vGSu`Xm0X?n7aXxaL_8TWw_T_B&g==Bz8rLSK>&OE65zdLPUc#SqeB$ z9PJVJr~q#+P81dwH_mBv%r?c-B0lIL>zdfiRVm=NBc4`UveHl@v8EkfkDLP-?fufN z>b~GP?RGbMW29WA?`Nj965oOws$c*`E%(#gf9m>bS3P6XgYE{ZvGaEmiz&&_o80|g41j{C;`n>>ro_Wm=Dn1RV`q+{%r_Q{mrr5V+p?#mozuq! zr%TOiR78OlZ>AR{*NKPkuzaNG)oV^;AgOXWU9EUg|6@x0_=b~Z6#0Cyje1Lw!zXN= zP=j9G*aV#fiuu#bu8hXcjvPY0j9}NUPRbP&O|~sKr0aujxN**B(38-7kF{qw>7Ajm z_h;$!o_nJ7S!z#|a7Ak@cd)1Ukh$gc zdp%eNd6zjQiAXHDD8-bq$Gv=>k=z8s=&17y9GnCHf*H?{_^FD*YiLcb3c?Jd-d}Yd zY*iw~#GKmh%pM+VF?qcx`(=6;*BLn&p1Sb;a1QZ(s^b;>CI(alOJSg*q|P)_y+6RK zqR;u(8S5d*bKIN+<)B5n}&a%Y=8)IRQ+eE0hwAAh4;I!19y6*P4IG5C;`M~EU9km2sGN&1-vG9K_-D+vE zGG?P2WYd<#x&%mN{)jupD`}MNb zQea1tpAU-Q3Ww@Y{!1q%9@p120uWc*=W%D^wn#;zc?QF;bPArh%GJvk!>KRV>wj-D ztq0m9m7domNvfhyQz^zcw_K~`w$}DdF*ojbNtqv0%oeJswF^}RjjS4Xw)dju9k?<)(jgqFuFp@QSUTe69DKoW+mwh9%eoBF0A^qerPvF zm?bQ=H*w3IRQi+u6_E0U13+ltuwCvGxHGz}i`8CEwv<=HVC$5c@&@yo&*Eei^M~k) z7~}g>Ne|{8;WJ{{3M7W@jzW&F<7DZzl9yJCM2Bcgnl54#*KG|KPT+00(2g_H&sg&7 z78@~VGL-zr1uSUOnQYX`6Sndqq6tm-kuxl`&qy^Pl zNCYS3dZ)SzkJQv!H%&9SGQD=TU{>iS;F;V+aJTch<_DF{-Qm3K`7NuZm&qEkiBSd0WZ!M;6=Mf#=+yqeqWo9q*9w-mio}vsi(R{Z~(ff&76t+)V zmDbdkhqKOJyH^CZY7G624sv-|@7^9rslRBI&LXn8^UrgQta^TfV{R&ED51p*H9!Dv zUVBqD|4dh%WSK~@kxBu8!=*1{l4rLURDn9^3D#v~L>~0e7F^U>@7_8X+gN&thBKQ! zin!I!hKAIpj2tmb9Rj9v{EdiDeHPISC*dl(-}=VgN`z_rO?v(R4o{FR0d`b%X$&S` zK>LoXJZAY?NyI4%Dp9oi<5Yz~ln8J=s3Bkv5{=57b=M5-t_| zLAVw1=(7=JP>q+utBSdnvhE{{IAMmqTI_$WdnXB$B?bkmFnc`FUrYF^ckdS%+T@-o zvs9o!S=-de^FO5%70F3)2^E>MoD?PoAP;>H!1B8MSnm3n>l@>)f6G3~GDprj`Wti^ z$_3UzoyUB^tVM=>x<;ruu`%v6b>(9}$Nh$!it@%0;IvUB1gr{>BRpgp7J}lKIqIQJ z^n2ya*Ql^p=sqKX$uFyU7=Hjg^qcoeKm4)sRQ$5%()+Fci83cS$cI8}a_-pOSeulr zcB&H~*F3_pi{cpb^^qf1nAQ&Q-or;Kpak!k)pR@8`@6Y35QPE2U&0!s0`PRkpoSI&VsG1O z?az(V1kvrj)tTKNnrKr=cXY2LNTl^v`z-lABE84^!G&0rB+H8pLR|pXps+bDOVTE@cCI)km9# zZcd=g<=yZL?(2#Td0@?iv;G9C((z}rX$Z^2S_ftFmHpG|=kwoNEW-L}L88kSCqz5U zLVUR7YjT}f9`ToWv|T+AkEXDmaZ}t&yA1fHbs5nS_f$d!O0Tj{ZqjsH#F8UeIKpp; z8agU%Yw?406Wh;<*yL}>ymuTBuN5OLc(p3_+2`_Ccsy&A_5Vp~QIS0xOab`;;6|4! zb_vyplTnO#xm3>YS*qmbmaEBo*v=k>c*6;6FnIw2e`oWxl_Tg4k&V}Ykx@8QKW31d z%!YzuIf(?Ixy_;Pj@jSlFN>{m-YyCpEdEfS${PPbI!=&dU~`?`QoK=)PyMrj#YwVN0_Hq?(8ybUHa|fkeS(Ee0#1^))fb>Kr_Kv@T7+=l%~NnmhuK zIaWWBtG;VOA_B1Fhi^B?sxmOUp`bHRUH>lE-(33V3W@%n1%#U=X7?Bp9H7Zw5a>B} zp?t7eYeMEkJ~A&*<|El3@XN5Es3;xZ(NTh*+}Gx`J+8-|xmN=^5>qYehTtcGuEjJT zqxa;QzP+p6Y;lN-)Hp%8>TE%X91e2c4zgPVxC&twI#Cv&IQwUK5pbmB5jFR>?}V=I z5fdr#i|@a>gO043qb0X7^Z~TA!EK`wiwv(ep6w}DlJYHKzYhLW*=Z?>aUv(^Y!lV% zzySUqi4?1P#J{E&`LK_c*^GgcO76t?=bq+!`?f);vbXzV<+TFK*{OU|yFmIgTzs+D zgccHNgH%eBmfyYF8oyPF_*ICrP;B8f)Jc~<0kx89KZLMNu`mCz-wiT=qdV$}{Znhh z8SqVF1R3EX^NZ8pc3OeH*iohfP!yJMvse1dji|hfj3fy{B-*coXxCFk!yPH~fbG7l zI*-E6ZJ44@5MG)Ha%HiJMVIPcv27na?=U}ioTUP~O_$tEFzMl`j&m>a?FI#Q5oH3W zN|teo+?UGENT>J-wTfB20)G}!MZ4r}jX%VLHn=%^u5CcLwmzAon8f|!bgA#~#PeJQYr-Sk*$e}@B9-VQR;NEXH3+E}-aoosgnf`3(b!^*d6!_Qnst<=km{wHmA za^=?*NrkT)1ixi4-n(4c`COkHfF9Y>v`2N|E5yE??2DWb6svlAXue`{`(K6fuq?tk z5qb;&YJ?$2axI3#vD;`tX=?Z%4fkKM!lxQN^s=k2rW_(nJ!~Qzu z5=~*`I00-3OMW;mC;xjbj_tF^cpsFc&?Mq87-dC#2QyQ!AR%ur2W85gN zMO8VO#T2)}SV>29U`v=l8T|`sU0vjChO9>!@)+c@pZSqrPj?r!ijzp~#RuEt#uTzf znHolgas0h={sIettSKpWT%))SzV#R5GzOro1T{uM4v=GegQ7!toxhY<;RNvnUWT#g zH_iXlByqx`ceoNftUg zPg2j*1>TDotEl0!UJ(%S@V1VKCtcbHUa56_6B1F+yB{#LH+Dm0Gki%FAIC-9;$BAxkyOB*q{?6+-ntmH8jvYZ!#dTYg z$)>njj>;kML~nK}gjhoRu9NDlDAoDx!#A@#AzhO7{{?)tF%`og4@8z_woKRm+-zS6 z%ar90Ye5-_z(RLOJ`9XjqFr&9`^ac@C->&jz^&EuJqH3&!R>wYg+5oa^R9_-@G%Y@ zAJ58%tVrhI9Y!>(N@7h7xU@~+{mbUUlxY7=qA~Y5iVFd>cFidd?axV*)|E#Q4d3zL zoSyH)HVefjdpIebKQ%#Rg}TDe#qS*A)#bd zW#kDk5zgKgWok;mce0+v4snnY!8>#m->^ykMQFYliSxf&zg=Hb{0P2!jhX?ox%{L1 z619r=x(3pC22#j)d}>M##!Xv*+Er_eRM5=iGMFU8aN35RzEeuDgGu@ECvud`nbqZp zu$VDM++P#F{>5(131=L>=_66|o}DQ$L+ff2P(1;h!<)>ct^Mf>|L-$Z3gPFafs~&9epxH|Mh@c$^ncUlmbAJg?FP-G&inGZf z8j@#1+bzve|3yx4Ss90Ae2^v+6$aAX{s%K6A>JL3ALFB}Mc+Ue&Q~`6 zzG6rD(FFeO4zuFA?NS=IV_&CihbJc)&9rF$8thWgQZiVA7i9-69KHa1ULJK@e8*>< z_8wcAeq=mhSI`ckV@*avvPeI82?~l2SNIhN`pAwenD#=L^lV z2IMd5!Z9@O@1H9EV|yG_j~$DgbVNKF0sJ9ci1|@u~TABzT9&nuB^SoG~*(URl}| zb&11i&+`-Cr7MxLhz10TUeH_6tC{|=LEf;}XGaJcO6x?%$W{$a9;+uMljkGCA6|R) z#>SSTVL-c|2bsVv4x2OLTE(vuG+-sTYNb@!9yF+YW$_-z5`BQ0w-K3PMcUo_)3+XUDc%CLz!}(2 zQuXN`F!oGKU|Qw3iD1%A9vrtn2!BH7n0iTYI zn$gc!e%Nzv_R}5#j$K`Q(-#NM=|&~OA-W(ZGn4{)NW~E zR2GQ_+s!L+I-S-=^fJ+{DouL@I`PYC*R&SJOLf}WzcVnlXu(+xHv%DXqg^zXLr+lB z?f=QAy;~)h8 zc7X4Dneha`)7sDisy^ctUz_$3Pd$`p1tEhUpilM+&K~u6kRGo1PJOnLIT>&Efq0{C z%)b~}h!WZsXp)P*+%_?-AZiA5b9vkq9k+7P+#1saQ%7_7FxZk;JcMPr^I4ntu+y=i~YMkVxS)otiYxK>P z(D_@=k;#}e_nteZn2U9=_E z5jr0YCh+uJlD(Pl0dLkzHhkUyI@T^}(g#at2chJuLk}&Ak@~S@#^bM|Q<8YvlGQJT zF`uEd(Ex;EZln_Kw}vO~9HUl&j@kA61S@luZqw-!%Uqt{~<41pG@cnSC_QI`{MVjc_tr%U|uLE+O?j1P$^$8rCpZUEEE&5Ohsp6-T%F zYMLsmdK87~n4fJ_L>A7_X%Am>XI+Q}wylUl0L%y#Q#P*_J#*D^2oELFk3xsk$th*Z zA2nibGJ!@}h|R{oxe0;||2@_P-fjMcD|!M{igq~7F@6PHPbZHJA{wH`6MvTA3r7$^ zITZTIN2}YaEO1$|uQ0LI-hgDW!ExY*CM4{h`{V25j%oLq41Bl8o{#BBo-y>W30;DI zr%Y4>)M>js%2iJ$HNs}&V60nn zRHgooshu!_SpS%>Q|PE*wW>_)4MYaVLC;gMp4P`DE|w zcr^q{=Io6})(?n}LF17Ym=BEX^&Omb)|Bs3k^{GYUVp9qd~V*F_1j|ctxQ0GRA;bC zJtlF*o(4%L9>tK=n1x*= z!9xZlxp3CU0}H^eqAB&eP-%~b8#7Nif7@fnp9(wtnbDm5E_}Y)TJe2qoS9UW2$kB> zDK3o){e_g;ZI#gs95L|!>(O&7Add~X=fgsrm>T{sV;hp%6izOHf30$*ga8WEe($~A zb9;`vKk`t+n9w3@4D?f>ev5jktC7@}eDO6v1UGlG;BEI3clPTp-t>`kUo36}!-sN7 zLb73jX)RvPTIaT@s`NVv~j6 zRLG1mEs`yY6zzGjz|!lpNCdP8TB&9tb41Y>=MnlKvq zRwKf5MMKhE_bM8)u0U5AL(+hpC>AV-@Kvs!If!5XR=6MTP2#6Fa6X@#buL5~Ft4Tu zf5Vzz)tKD4${}S*=p`U6(Lmd(GCJkJ7YO%pca<_!6em^Ug=Au=k3& zPP-=vZ_f^7DJFl6^NsfE(!AMuRm~#E*IX)Z?wfzE8l=y2fUVG~DCU@(7-j2}RR8Yi zpvPxMmiWw1AkG#ZXPCpN!OPD`b(Xt_^ybf!is;GB?;|Si!Xt@ zea3}}{Bj-ZYPYZAG#@GrVyAE^g#juuQ^eXBS0lu{6w zp8YC{^Rx}P%Cv9>BQCK?dG08d`|Fh4?hH0SW04OX!EI{Fxe;>`pF)Q7RM&HOcl>M^ zrERo6nP_Q}v?{Z7m(ccYa&S_4oanij%WduoX^y6iRL(m(B4G)%ta0^>klxKR>MZY0 zKRKI0Xv6BF0h382WIIW}7 zzxTK~uU3cFvh9dB;eeJMMaA2MGIHSUBH_iUm+CHA!Eecgg$d?U++6w`AEoGNq83G$ z^um*wij$7bjW1OK66X=Sh4zKD@R4t$CQL3FA%oHJ6Pz*KyyevsT${@hI5D zAiO2XzDDzf*vC}A4cV=%$_HfLSLg)rt9TO0H5X{h&7kHbnlP9osx0S5YS->ht|})2 z;$St|R)qQ&p;v3INlJC{S`@n!$!O}%-DWIcZ_`#=-=eo`>mofQ@FB=QHTmdeha8{e zgKHI1{t1>KZG8H0REsggOvWO^qHs9R5QQ9N=hog0mhucm;;wI+pS&>Zh2UEJpW{jw z((mDXE>BaU8_-m;eF=NsHdzY{K!4&`S(UdO-fm}{Vu!-=~ zb<*AO1RALvQheFlhXj7j?nkqm4{9~_a4STjVDk$-2Bwh-Y8D^GUZcV)Uw1@M0$DGy zwY?tF0t=UJea8ZOjW^}YR`Mt;yZru(OP5z34I4U&Gc6+E)Tk)`o-{}n?tNx1{#Fvd zc6;PX?+uf8B-P2Zo@>Cv{6Zbgtj>b1crt@m)z3|pO|!XWqDNsD7KX^wn@c1W#uDF3dUHsjruK<>Ldv2@O!;iXD< zb8i&Xd=jevXn2TKzW@DhE*FPg?N=Olen+{RB8PM#V~gMWRIJ{#Ok4hjecMph2SA-K z(QzE@6*vy5U)ONN3afC|f2gM%U78rV&)WlmWuu>KGjWyw*kUz7WaypE{QF8q9G&6c zi|<7k^U?VzeddMw^_z=#J41<=CeQ4?lDvyu9D0Xb^yPT&SZEoIti@)K;u?wdp~3{g1~xXDPu9yAHioH%p}YEeN+u z5B4{+%Z$+m+4Osvuv~;IiB{T7ShDq+0SzsqZKW4l&TWPiQjO41k`d(qX_k~^Nh6`U z-Q{7FNAaH4q7k&h8*YSllbn);m;k=l;6JjZ4vq6!V_UPGcV*g>o)+ywGy8OD`ozO@ zntd89z%;#+jkwf~Vz*FiOloMyr~auHPbgByST!7ILFv`~r3Dqg++RD^X+QBK)9eK} z`~&!#mhg>$exmyv0n9C&b^M{u(AjedEM?A5Of7!bWoCIJdp`>96t@%Gwp%5aClG0} z=eFLg+9$Mig?YpA9twX<7#~&OQwNETcFgt~WTCz3u2kPnR(%QK8W|QocchvVkurOe znR8vIbGy|{UQYv_#UW)Zt{B?hXIU57LNooK7=9B#|Hr-7?r-b$s%k9vb)tiwO+=Uw zzB_N?Hh@$Ju}Q@R`y6_VqaEh0_7;ArT%lt|@ZXK}TeO3@>ibu@D(Kf{itfH8dH(am zDq9yzchw_}30nRlShx-tqj}}3#T4m}1WbC%8UM*i7H9NTF|jqGkW)#$StvZ_QmuC& zOFDBR@a^55v3&>7O|1bX9d4RDKU8@AxbQ$3G& z;qJO8On?*bbU+ELnpSkW99;Kxt0{e0{xGshUL~QxaullFoRh(eQ}z=6)z_)Gcm|A& zXhZ*@dza&71i97lzJ9#pPJ$r3!%la(`?bkMMZM$lwHMoWONoD6z+`tGFj9X)#w20= zde*yz%PSvW;8&%<%F8rMOVLd4eIZv-ho>vz-kG0HSQk6M)4&M4=!r$Wi^lj(C4)z}V>QRUZqk!z?QGcAy}UYWIWb0v?6e7d}&y}~=Kd^TiEg{Gx3TO^hL zzTi`+0G8Dqe36op7I6#3)oF6CJ?ZX3X!aX!(x2OiWeN_JCjNRgv$t~w_Yp4;{Pm&i ztG1m~(3iU@%7y@YxN92h`HoC=#3qdYkW^qJk;mZB>Fn#N1!J1$%?C}Tts6*+sDRKA zMYYX0P_M4VRTeEd7d{P!cIh{eN$FBq+9t=wxf=md>GxPoN6rE*Ra->)MAk1|1XV91 zJ@|L>+&Uaio$7uWrFs-uYPS?R^_@=5Z8!<4$jyI19h7*HrJ?`s8EEM45d3vhXso5( zi$uqM^844;_=R^bZ>GCo>G-Kr6M0NNQ#70yT4c^2wzK(f=mbWjK1gGrpE#fPs4k6o zEn^KK*b+pS=2e09FHb%+WA}D`RmW#F>XfP|$|DC)gVKDz3%~ywIUR4?U){8eg zsT--EPlx>--)FFxe5+oWls_PaW$f(pK{Q7=gy4h1u0}RR`sbqbSooD;n3js(t;;ms zMZk8ia22#t0xsBfR}V7PJ7ae|t?tvnVi8{?WB?Ydcvo4B+7=KNcsDT{#yeYG)@BV~ z?ztPEC4Ogvql5NWHmM_>%4Uj%2m;uVZ6g1que1b_PlTxIRoT29PFq}FpXNNmFI2bK z?C{R~xY(--2?UL@Fp1vDuRt5(#)|s1-`~PgvRk}8mol0RDN@m!^ESrjK<*26RP+vM z0y&{KPdlz$1td7$B5=2}N{MtS* z>AKF48x2<^mK0oCo>@!8a7R~Sz|Os?xf&f?VRSB|)v@1<@lp1hMV<=Z6(JgZTCWBH z!<6#XW`5v?{3%pM@>C;I9}ye=dZOs@2d&|z1k#vzTMX?kC@LckSE^2@(|(s4R$+#_ z$&(fINphXinTvTl*DhSe8mssvSy#Dc*ThQ8Z$CWPTpk16JFre1W+p~z#wCso+-4(CC5(p8XQamPd6P1=lzf>^(`!})28LSOfqGbl4Dd#?tn8y z%NbjsCUnZ>!|CI6;&eMX{7~^bjbXLY3fwRr4>)MHR#Sxh&FSG(u2DPniT`XYEut(> z+Ul|@v5i_!cf4%_t!um-_L-`K6!56L?{OwuSNtzR91Cqbh51=fgz?PWxbQl$S%FN= zBjuNk8!C!a^utTq2NIjOkaOyvP%)_eSl8XFoKUHU&@zize0E-8NWJHyvxpb}Q<81U z*9rlr0H43#XRi^1Mwe_WGAqVx$`TAn4)K2fegN>UM&~M#V0Ya zZ8gxvPx_cCT<#H7Y4xe%N%D{;M0~!~Qp+)KGO6UbT|2`S9#0Ks(u?xg_(fxtBGA&W zE@8vc`LSGiOBF;1;c%Zx)7Fn^TX~csIiY%rLKgF*#`7NxCIpH$E={EQds)%U%b7Z% zcDk=D`9=08Le`A~Wy&E8-Y>U`!t`RkfcCSTsB;WN8i-c8?Ysre<~r0uO-=1VM^(bj z+m+r0pF}zbT)TMVzO9g;RVD6Tkrc3BB&-mPw4dHpVB_6n)wlQoigQ|~oS!4#7oaMj z+@3N*ST}{;#M|;e$srNUlrSHyl>=8?n`=+)^qNKUwa&+F6RY-KG%6&^V0aF>mbc z9`Ol#+cef!HEvsH)ekgk^cL1&PQzyGbbOSkNR(nC_oq#jiRZhnv`(KP7WBu13qb$$ zTI0R#3MJpbU7Y-=_L42%^j-&eKZ8gly`i^EnC}|L`ctTG`$x9=e6&EH@dtH52{u@n99>Zh~lgnor*Z2 zwyre*^&s1JUgwmiA=l@~04`xSuZ_aSMt7tj4Z%8fji^sh3S)vAMI{fuMPQpS#_!H0 zo6JRjR3XUVZ)jH6c@;-e*^nRz%{IC{NeJ>twKN2rJMBYf=_{!?}7M$?%64pBcwcpR-^=T*a-NubsM2Fd{Awm(>9; zveT_}?F9vqr-N-*w=Chf_zpgfG5!?A1MPX+rpLznNH|?ynaTD0vy+@m|2mtqRuq&* zwk;sK$2@jy{_~Bxip$643#0vri*=mNY+r6T=F- z2A}f^#;#Adch(x$_gfH(|KAO4e4yGxTw3>`?EDKhgtGiT$#H}>I0Cy&?@ksD2(MKo z2p}O~jenv4YX+{>g+06!pf;=RqjK<652E9wjH^-}#Panl$+dC5!w^-|Z+~aIJyZX& zy*{>f{lczo@vTF7^=_$RT-;P7@$W)>uapbt{x&-H?ncwm|4nN}wiUTdok@q1+)^w2 z;^N+bn!W*@0Ij&ueO^KWRA>9+Fs)1x4bD_-HV6?(h~2$*k?bxoAhP`zwKYzcHbUMa z@57*-6Dopvf7j-|9}VSfqmSKvqNSw!`yu?{w>-pL`4D(Y;Oj9|3_C@?yKuT*CE ziFJz7e--79XE{;)>G=o0rXe`&GCVY!wReQ)MOu!N3x&Xxp+pS)H7~W@TDb#?a%ynu zBSY%r&j^LI{OnO-Um~q)sQt%m-ro%)YCg5`rSxB<2?p<$o!P20Go^Q4^vb*9pIs_9 zzlz>vYcyXCi157W!g!=gEIXciRcF`AecaW@FPG4L(d0TFIxMgWl6Un)^xn~@J8tFV z>OGr75Q=vhxCTMl)9^}pT{hU=$f92$cuK<@g`MglNlusL!`HD8YHA)dWQK6TR}azN zNYM9jd_e;XJ~NJ`Zl{SbBbCVMcqRKFl+z5E;5GWg$K(XkZ-dP?eA0g?{%X|VMM7i#U|{OoD%F#5sk=Za2_S&=7UixkH+2ceHJ^$JT_j&MH-C}u zsbI1U60Js|@#7Xy>-9+=2+9MybhSOso*W=qo5e}X7;B{Ad{ZgOE1_Ib>FCI_`TO(Ma;_!K0_>rwY!C)-1h39$z0Z z*PuHDrf^;GH{eU;Ga4`Te(x>Fu6qE_IGz(@o4%!-wJ;uOo}@3FoB0sn5*wxmp=0s_ zEBn8+D-JaG6-2upv1y>R*Z()3Q%p|Q=0RaInPN^+FJ;oCrXnk=vIFyNL-lr&_gQa3 zS#Q?8hDzG~-`KGgoJi)!@bwL(@ffxy;Gjo1KMa}npM9&EagdOd6KWuMarS{#`BwJW zd7H3a0{u9@4(ORidL~57a=wy!gzHK4j`OBRMF6(w9UaZsb+q4%MY(WObpm?3C5ORiJtuQ8<8o&O-_yGp!t&V=FBCnM?-?_vM(nQ5fd7xaVGvi&}p zFPM*mXv?(F{8>7279lcB-Fb|ZdRMR6HrJJJlq_n+cxRcx@kX+Hs(XvZB-rCGY*-H# z1>=rHo47S=@n`1;aGaMCNK=y-a1bi0l})8gq_@Emc`uzSJSJNVuh*nvSx-m;ndWes zj$?nW^tR}qfEf5$2Mi_`HIZvQX+=unE<162c(k0rJV>aAl6t-w)MermjB$;st*VjPF-S8B|Xt`*@x^X8{mT6|y2=#HC7=o)TjS7JrR@_v?D#_(+=E;r2 z>kEV<$**R4onp}UD{vQNL@<2v?i8N;KC7%XL=uWyD0zM4F~qa>jDo5Tt^dT)@Pc_e zx7n`*1rpBVl=>6v@;wL=r843g!4z+MdSF}7?0-qt>)FHG;x$-c=kL3WVR+tI06*`! z3zyeR4&w?0>vH+nX?tMLVHQ1*|>7a zxYz@qWenjaz2fgmoAccJx5_V8GL`wtmQE#^F=e|;*zscjaRHG>s|gIfdeF9G8@=aG zn!XE@Hq|$k`+!BYsgR**8$#g(11V45ycJKnOVT08CjFIk9ru|ayfs@GS0SjHyxbV; zi}~<&4NQ94P3256%XjdD!NT@rrBV1-ViziUqi2CBpSpz)Q5HtCz8D0%AN2)rnR-hs zDekZ}n|0ZiX5IF1ft)`dE|~WO{k8+M8TEKRP`C1|n0Ew{tr}NJ&~9#2KxI?ZEd}?W zui2yWYxg*?nhsTwFrnxtFCshAG8f4Mo#v}K>wnL3q&zX7p<|$`@Ko~KI$mbgPnUx6rHWZojWiy-3oKw2M`?Wl_>MQIX3CrT9}`S zEG2FpL(;&t>De-PbmQq5Fx^B)^=5`4G~q(eo8XsYOulH-XcC`otzC|3L;i{Rh+mD; zmTB8iye|9jsD)j}!*`n7+=UEGT`a&2F)g(!Q3HtI951U8HqThbiv}(W_$w*xff6N89nL5Et zNE=NuWt~(S?EU?zaqp?B<(7$7UN7fe(Tn{Empg2gJ8yR|9E@YOmZ3Zl@!-nkXFLE;3c#+KkhhKdncs|@phza`HY~w! z3rf@88=(h^|M|ZcRk&`0r}W||h$Vb*T4Vqq8UrHx2Qk2zw-Ow#P{Y`P&aWg`1Y073LWs(Hheio<^N-K%;{8A z=k3I|&_a=xBQZhM0U0Bfhc6G%58gd1m;AeT1LejdFO@?cjL25xvq=W3Y$ru;dxLTW zOdvX>quln390txkA#2HMBA)O3$t60h%3kQcupNl;l4YwjbNULNV*mwQuMoDL_lODf zNwF&dflbj-wD*TI@tGnMT^pedpfH9aN`=axNp&9G$m=BsOAPZ!A%H-*kSV4tgXJXc z?ajce){lbPtnE8o?#7T&+yGIU5 zb@7I7HlA${M4f`CJ5pu?gV^$_xjTqZsgPOMH?eB%Zo)dn$Vnrb^6K^eoo}_pnJbFF z|J#;Dnv?}4jOI1!8RrM@R?H}=FQ*9WDW*edLI`dMaKXnS98SA^mR^G7$PKwt4PVt8 zM4F~=*`$ruyG`2sxRslJW{F%lhC!l@8|3<QqQe&;t(G6cF!Vyt+i)D9A#}nM zc147w57Y>)uP@?$wpjUm;fSm7;dtdx>RRzx`J7lr-|W3hTucUpf&T*BP>zyxv=%Oo zx3E@trl5~T6fMs5424Bn97?RHz0c$JA!a>^#jHSmBoHZcjDljEAyY*N7j=JXyNl;E z6(UAf5uj0JMWB0_xU2a=nnh)*3In;W4MPPQOm@ADsan@TJnYlp0Lu&1hgZ*?`PRQO z0oVAIBzXxDv1;h1-YX&3D4RR&8z!Omi1`Xb6<$W6dPyO~`5bD2SPYL!n1(~-Jpk-Q3aXDd z&xqvlb!vLMGVYHj~TKpy-?fqne251&`6fqn`E>2t`vJnGBPqGAg@D zB7vMfa%8OFd7Mw6`VCrt{|Ioc9)ym^TCD}7T*uQWR#KoQt(L3ak98T9$+LZbCPW=+ ziZJMD-ELvBi^d;Ja3UpcL`z_nl|s{;aOdE%$;mk1R^`V!@gQ>)k0Lxt$rAYL`SY?C zTBHC~!B3dJ>yTE$N3WT*CxQtAd74)61H8WgytA_&sEVCHQCPw`hggQl?6KHYVS}iY zA<@tv!lq?(v6qynV=SwT_rnHOX&C$-w#@P~OkuiAio~OO2=24ZF!)^vvZ)Lf+)bR= zO_Ja31(uNJE4to=Ccz6P$tbhUz!u*1{7D+UJ~j8XF${S2(KAED zqczKA0)qM19GMP(CCM1IW1eRX1Q%>U|KPgUXjACOa#@&ZojA_rPU{Q{q9cx~_0|HI zZI_Fw*neMKD9WiX?2#$<{5+0MnM5s`_rh(5IJK!w%sh^e)q2ldS&9lUXaR= zl6Mjrbf1v>bAB*a85r1ZyQ+dW1(1k}>D z8SvL9SEw=sIQ4mb0jCiKBH0<^+Fscit75ouf!zSF=-M67gaiZnV=(>bepa<$jsir+ z5XT{v$dBN)c{4T2jZKDH^A$Sq*;j+%KqT@NEY0%{uTbN;Jj_7=VsTG8E9jp5fjJvA z&`slur0k;5;YXK7TL8(=kDor=NHD@upXnf(;tac?SJxB!e9PT6Z`+#nY`}2nv=}Vf zk@a-D`1-@IT`I-f#nIN#3@nyly9g!*-xv_y&gG!B^K5LJRxqpIGjn9GJqbVk{s1zU zVTX#$uO*i;>-`mb^3ih_M^%2xTU1i2fN--6yEc}~%g3I|Lx+ONs6NKk#Ppw5xbztJ zFi9@!T%U#~q}y@gh+Qfx+|Q_73{1Wh2p6+yl(Pt&|LFO2HyrSgCoe>({Yi^c=vjM- zPc0_vL&P9)&}yZA``C!K9_qG8&uOQDi7t{dm`WxO4Jmg0bXdB}f9Sm$6S~0l(6iB#mfYibqpa(- zafbfO_52%xK)fiU2Rt*sM3s#ZOS|EcV`jK?CqLjChF0lieC)*_yBA|o#uG~7Gp%s2 zppQ=1a`$3-_`Uguqcm257MsuZCHB}aF$FZw7=JiC{DISw9!>wT6wS!#nAz_7H zdQiq{%XV?$#|NEPoy=q*?fof%m41Cua;97$uW^!^7zJHKIqQIrV%qAwFV+1?*5Ex) zZFT&|Z(dQpT+{WNw~OuEG*lochB7M;SxL|CooR4RKgSreXoS&e&DHQmaO-RV z5}kQ*202lp>N84u$OHcYJWiKvswS2n=2Wf$vOc)=RX|~2nCH**S1oDNHe4#XQgfl) zGZO-H>m%2&j+u}8aaVtmo4+D4<O5s+|?BlH2UoV&(A7fv}J^6)VkfvJkp|D z2JBcMzy9Ow5uS*ZDk#}Pp*IVfq|Mm>x*V3^u_sf1#zLd00kn6grPKCW7A2JTvM@#_ zoN2>+SI?_1$kx0kzpmk{*B`0SR0j{_{=TQn3owNRV>i-2BSKB~aIM{u=JO3$Wze{= z_m|8?H)lUnj+zt$Qw+QeGb6upR&RE!SV|(Ep?r7sVI6*XtD&QNnQ6zQ9X!a>s7b`n zkxE6mW5$%HeJ)@fNM|t0<2NC6L*-nTdEkbPIsox_ep&elZCMonD}RJ8xP2sTbdGW9 zsnGgSP4nx0EG15$^%A9Y2sa8x(b0>Q&Cy(Vru3qI!BwwE3b;gX#$i+OS;>OBp7cOyGwg7XGCt$3ZJ$Kt+%K z5njndeC?-#kQ23f3;GkH0#7x;PgLmVLn=1SDL9usEH^&*(cA+eUC9d&RrqKSO(law5BY$g^B26^t34Vr%^kk)eK-X(R}jiZHPy-BVX* zif8ZazwP9Kcu!&hJlhEur6s#rN+}n?M$F8qBu!@YEU92oj@tlsR4#`-FY}Cbm+WxO zsYhm%%9mWJ<^#Aoa1(^4 zd3&_)m16Ej#UR~%`%zo4cBJFs@6-CD@_0~W+r1l=qE90ZGK~>Oz9*vaF=wd2Gv`B5 z3<_)^nnu*Jm{Oe;n(am85}lu}rRNGaW!&Bl-MHK+(hMk3ly*vahwBAc_Km#=M%_QV zb8tVf&Mgs5R;4l}8A$2j3#%Wrb`V~Epz2xaNLUItU(LjY&%}q@k-8j!MHem2-*U9& zl;zhYxaAFYNM-Ixn^?_F?|dXj4=DEu3v#@uR=hGANk#FU%%n44w3jV!uC8B@KkG03 zq6&T=?<@m+e?)c>#DDm$P2I7NSGBl#;ELdo#b8KYla_HpqXAFaDBoi&u`~tS#~z4> zmL#Z(ZN^ob8{{J3Y3Vkd!3zeG6qqMS*Ok;XKOV|iJ=O6>*mV3V4KZP_)_3tc9$m;s}^P4{H)arSG zs3r^8c)?Ez4Q6T*;qrsp6h9^gK|hS>S~4m+0ZW9j9(U_vPdg7n=W?9SmMj-E} zQ1APtET{g8*-|CDTrbq1%;VVTz=$+sVi3MHw-KfS<16`GjLn3_n`2-dlY3GM6N3Q0 zN|Lnlt=xDr?FiUxfjNqEGdhu0{_cGZ9>Nyp^T+ztfA42xluQ>3G*J);S4|?F)oecQ#*i0*6gu!w!tq@3ZORmX@a5g5ueewRj>G%Le? zn+1;Xsi>VExbsKL)mS#IuQ8t=0L`C>Ta5J5yI#Gdwkb^{1XekwPg0AE!K4j~R=!kH z;@qKHoG95nPxS9AoSKuh zK&vCu?f`u3?2{QL;m)|S`Ia%-V`z=8#WVqbJeMy1mVJLO9Wl`xms5p&?3!7FL&%_TvD-djThvpDYukVo2r%ILu^0^cn>edGCd9D!7!Wn7te zSL1qQLbGjgLMR>rxp4Es^PpJBOU8P-z~PMGmQK;iK_E>}e&afRi&(tg!z?qQZSRYl z-O*mjDEl{+>f|g1CzdPptF^gtRqHE6``T^Hy;w&|MTSHI17~R%N*h*RSB?}hUXc0N zX84w>0>-+RfY;aA&C|ml{groQzVo$ygVdAzI{K4?yL=8)^7NvWK`iY{!epSKu&?Xx zQf}K(kT-NrEM6}#xMK{Be7QTVI`V(@-g1mP ziYivHXHk`&-@hU+&%=yyhH;)hZn9$9&yiBiE7mtz(U717d^f@zV=zy39sg5+O6TV> zQ^DR#hPXk$!9d_9r0m%!h-Fjd3UjsC=4za@zIMb@G!6GEW=$Q*CSGjySDkYT}le(7Xty!nuR!@H1gvhVvM12k7z%^pEb10fSH>9clCcV6!%=y zsdV5`cTpYdfu~vK$p8)*sao&6C^chxa#2f;kqdV*LvVm86U*l=!K-Q*q-WBEA5*%L z%@gWv9*P-~I{lfZa8XoB0Cmo*yWeWc3-&992Yusk2u^4PBCS6R~@;(ybad#+SpebvgK+pq5>xrlT-~idD zKo0=NY0`W4NWTI8)*7Kyzt$#=#$+aL18vJvd=^+aM#9Rs;c+v8rxYQ*!)mW74M7}d zfE?{jQvUpUHiqgCNpYR&k*zodsGWSXDyvvGoeB8qhK(=g<%tIlDI~KN7(fa=ZtTfP z!b~9Ik)cI-QlloA;ewB8$8INtth*z&)0RXhh|@QJA>;V4*G5(;8HpmSG3f!G0dpBGTlRDQ z_J(MTl18m!M1<}cId9U5{(6slK=7$;TD>eIDVe!DkKUD%kDzlknC>%sEH`R0248o( zzbPkC=z!n|P?q*K+S^v$T_MdJq>fHnSXP=`o9gUd@bhF8yT7Zh+T(?W5O`5>Knsu_ zP;hhrkOI#{>6kUK94_j~e$=P)?Ys7ih1~M`nk6!Ag)`gFjk?~d$#3e8B)Alha5iz*t3hz|sr0r~PEKKVDs;nB);$BVOai z>6`WjDf|~lt>y7fefva|qmN5gx@{Aeh0L28SVHfh!JPF*EdWTI9>gqJDXw@nG!vh3 zRMhn9IAe68PN0wZ5xnjSW0Hz~N#bUql3f48X64WQ9q~V^i(?HD@*Q1wJwOkGWo(rIpdL)#}Gz znQJ}biTxFW^r2slA=(WrC|r&p@plIT$KAYi&V4bL)|&#Bf=4X}3;s1z&@7Q_nclIxCDg<)N zR{Fz_jtl4C;BljJ^dOU~! zSplRL=N?8OJJ2b3@!~{_eMqP_*}?QntiV#bo>4IZ4$3WX2CIG_?0#{=Q{x#tu`g{> z*9z^LG{H_<^i~n(ShMhSci?OR4rRcBj`8hiZL9SjxIgRQKJI)?H2^=oEa%g;b4cQ5 z^{;tHy&M0LB6Dz4BZzq~`O;uu%&kl36>JU3zh^!D@lm`a}!11O}i4ZC-kZG6

q^jXP>q%;JgS05*ny%2KaVW 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 f49v&|!o={R#EEL@`lSW_he21Q!N<`5 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

v;H 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

l;W=Mi zoS{*EJ=d1->x7I++l-dkJU#VtsEOF@;{(w#Yo>CR16XmcOc6{j!W*kTAiNB1>&1CY zGsOyOZWe8QMs`*!v7U?N6UlrAHoSDR2!K)*ORcw^OU;Z0&dl;L=tEsB*K@uOLK6yP z)W5UJr~|7y81QK3*T8jiYXZkNX`HkUShL5lpcVG_s<1U(F;Y^qwOs-@P#ZJ0NLyj~lKo-tCb zprap;j}jVbRgC#iaPo_`#N z3un04V=ae!4tqw2R9u|Tq#v11l^qO4XlM(u8wH>kNP%x@3+ny3zTXwqrtJ z?vFd~@JkV|fYHV|H`XY1%I`!k7u~1NC!C1FBvrdz>_%Yyslyi*cMD5`&S`NvCMoS+ zTFig^_G&#(PmJIY-Dak88t!X-Ci{0b;c$6}SQ3lS=#2kV)f}MB5!c>UIXUgT>hdq| zmFQt|4(E1UX(vooIf(VPrGAtB#mC_0q$_^k`M^ zVts(Ay>rPhr9;KKXZYif%ArcW--He0LJKr7I@3e>!Qnne-eCMc+v zc%3~@IyF1%X}7a@(A&r>tTpZ-rtmo}b;*zY1?e0p^L@k8&hqM$&1CwsX3>%6XQo3v z8-^~sp9)!%KfPwUBs&ber2E$*%ZNO?oR^E4&rFW?VJP-i!hj|1;9#-CJ)R_(%(;9i)10 z<3Oe83iX9NFKzmAYdvM$Rn|B6b5Z}NYxAWB%@V2VRWT{Q;Kr-TA7nlxp|h<26iuX^^*JHf8A zRo88XWfsyZq%&OH*5dXgXgu3>H1lXmNB4G{BsIe?q?~i&L#nXVE%b0-@fNF@7U9!I zMbKttP{JqQWlPt!w?-pwi{UUjPhKa0zA|JPpLCB8)8WNFi_ZQ^>Z+VK8dF-7Uc|O1 z0fwx8J9(Y${Dp)L&1*V;)TCfm8$b(DZS^VGBiO)0

F6*oirWdpAN?)9&FDjPknd zd2#cxZV&Iu2oRbA8d5d>;x&KjBHH9a)`Is7#U%F?oo1++x2%yfFVDJ%sCNdP&e-8-p6l1Or-)QrV?LdZg710x&AWXa zm@QmG?wLm!*8}NlZJoO~pKQ05W)J8?p>3OjhV9no8~Y_ClW3fhbYoZ=o!x}dFd^{r z!U?lpTK*`PcBLpsa5?^z*Xp{zReNFlT0k&$3i$_XQtKEq<4x{^fcJw*SB%1wKDnyg&MI{^Tme#dLo^&NgJ$)F_)K$LrR6 zy@mc9D4C%veWM5N?_~3dP*PKd-;Hc;)FePUo;ThkJVTMQh!nGLeB084 z-n2w2+oG^tkJBf}Rm&2^WokiG`Nr^W?(SUIt`IPLR23FfENnpk>V#F#%kKMKBtXyb zviD(xwlMSlv+{y7VaW>b$rQ{_nxEv}JGuQckBw)fS8W=B7dDwP-^F}$^m6)gyx*1| zgTtT7>;Rqu7;U9X(D+GR`*S4;JZorF^S&P$_0?comsV5C_;$?v1buWM_zJ7`83oiFw9gzp#2a%}+zv@itjuSHw>!hfDLa;G^tR3x=`x zAK~3@&i55mi%+Q!rMOfP#)=k^9LrO-Utz#IW$D69 zU)lN*t!0pAlpTJ-#_;f|`H(h_B0>7~9MJlxB$6KyMHi^j2~pqNI>t-**S54w#!7O} zP##0n-QDyo_AUV7frgQXq8&ue@yTWDX}OOTXRmuoiWJp+*LeBrGB(^FX4t+l-MAER zlrklORhIF(6cn`k*4%-CXsQM9A>D&XNFpH65+4%L<{#+D1(hSd701j4WBHSrQO{z? zwB!AwGsej$pMw8QNpt!+K*(&M&F$%7+NFp-X@_Wq`or~AI$P=uTnLWw7^~;E&?9SG7yR`9BD-P7;QVus zKPsH|anB@7zSE7J&KddAFdC1`O!|Cs%I?1Y$B4tCB;qBM*Lw1JVMjo`D}rKKjbC$e zP4El24fnp>J+m|F3WICf|1e#hN`BBSkOb#>zY~S**Esd!M}5tNU!bfHpUJzmQf9`E zDhp1PAaJ5yj`C|I?fG;Cf{)kROPs-vs81pz05yQjio)+8!J~bl6yvYdx z#WfAod!spg1ooq)y6C8-spgFco=V%C)9-K$fOSSBOXH!O}f{LB{%@H;qUJsM!oj z?2l#jXbp*W`rRCYpKcI$;`k{aATdBbv%#F)F0Oy~qy6AzFB}yAZ&! zm_I&eZIhUP^ANo<(v$*^QyGQB5;wo@kMt@21*sjKH-0}b0o&f)e@qx{cU7$&zIE2$ zD!Xy~U5N<-{w(2MW(`f%GQ$GHIbZXTDCTY1<6hFgUf`d&OSZBV`UyRmI`UcO z&!{I)`U+#bn~HZ6OVQg7eYzh41m*Q>0#RnILHQAm=`mfo#ICAHGA_G{nmvBGA$;<2 zhSUgqx004)wIh!2Q%23s_R0JT$IKJ&o>hw`%6Lg*E#FnF2CBbMI-dHh{9hCE|4ZIv zO4tJBCtKu~Tp0-f%IKHi8BYLWP`6la%Rpo517(C|`u{;*h(vxdgs4g0wpv6g(l5^z z7#TrJR`d?frHFpc*AUQI=7<~3&%N4t7XcYzxn|8hL@Lbn02l>G$j?n=-`qQ8Y5sn7 zk5_;|>UP_B7J2U)_yl8>j!`T`^ME{i&;kiwwt~z7P3G9ye11UimT|dT(x_Klm(RH4 zoe!O;g2-q9+=Fr|aJI(5d@c1SutDle2vS@AAd|;PCOk7&e;~po|9Y%YOUi6c{>DYS zgmkOlpk`DyI>3Fy8Xzp5*Hcnvu|`5M2*}aYh(QI#%_5NpYkF-!Ht0YlhXcvp1GttW zkU}%-QX!JUyfuG;5A-tmPT8e=JN|hfTXh)HC8HrwHm4nD^+R)znSXvLO)S9#v8BSx z!azV`GX`zRx7rcdz@S`*y~$h%|7~s`(a4j@KafzC#aN->bl1Ck7wO8wx~HR*-cnlg zq9|q^w`+yGg)6_**i4Fr^~x3?%jRT@A|Bfq3U8R+WD>g7r9uu`H`}i$i{-HHv^IeD zOC1s@V#?uPH;oU+!=FTh239|RfEiz^g^b2PzaD%2HlpZJpdr5)1gQrw+Zt=8uXk-p z9{G#0h@*y5ioH02bv>xS>?0<)Fw$p91@c6oUxuG-GvF%&eaSv=tvtk!x6$J|4gHMP z*GZl;HwldxgfHm=V8jU{pC>(&2 zOeG`8q$SY!c&3bnzgpIVO21w=1|5RxU`3PJV_l9mo()AHSnCxsS>k>Bk*yc z0m7V4`low>Jb`y|GGa;jcOT^6%xi#fHfGprQIi?n8i0B2xb>mR1I$|hIwhFFdC&zp zL3@w+;h6&vEvNj9Lw~VSMTdaSh{=I7nLZdW{Nq{1>G~VkTD!7?v;s6wGhj5mpuZqW zY+xk#{P?>c@>|#cVPN|jba*9_t;Z&1l@d$x!D9J;*$BsAQrPpr z#L(q@@=#V?x0dxjK%&T*%wb(N9@fXoh^j@nU9^U1E+*ttm(8&kg*;GM5av+3^N+$1 zPsf9>Hcc9A0D-loVq^Lq0Ke*O3eBMZTCBleznB;c2LUjN_Fx1B!@;ftp?~MpV>7-; zQn(qchf+h79@CtSJ%Utu-ToN~^%%suYJpB;+|;9{27cj0@oUNndK_bGCRV~2Czqh! zQrRA>Dkh%d*+=>Lq{r-Zqzu3H$7Xh6L!nUo+(O%C5&Y;rj(Dpu4pTEy`Fw&NP38Y` z=I%<`$pPE^m}SoaXqx-__&dAz1xl2Gv^$(uyflhBY z#3=hV<^FzeVL9W`(wJ=^InGnlGcGbh z=f>MAO82EjsM7-ZjYMPy>5N?`tnSY zRJf-{>JBwHiEg^&*#B_bAhf)8&ap?0czR*ibNt z*aQYw-TohQ`!7KORkV#cqJ5>5vyhXn5bNhS+qiPz-d)|3|c(`v5ei{BIN(D+_7!=6p1|eqOo^m|==3lx2wxQI}1VBnS}@G(-q$ zrVP43;lfsChamF@jpuzN9+n$4FN#x1IUG62oRj#WtOoW-1~!Vv$s#3i5H+kdR5-lI zTiez{NTeP@bp3|b`B0);UlbfK0}%V^1|H=rlh%rpwpo&%F_$3YX#OSP<>900SV&T& zNp*>j_x&rE-LsPPc}1R;sX$5vf1l1BJq*s~w1XPjBAy z$ZtN|j*d3p1_DlKV}h_L1QPB|lKR{kMY}@V)11YNM#kk`MqN|e%^md1v~==xk}gjR zHTfakz_0ABOg5{#RV8%I*G~+?;KF1~L;LOuJkZbdM82 zJv8$SD`*2Yu^8VUulvC7xHvbUzdqH{V|DaF6}nPCM*91XgYxN_o6WN`c7;6ZN8oi~e`al-(R#|0pNB)dMo2eiEbsXa7E|8B(h?O*-B?K9^cfuOJ?c)0U;Fzwm^ z`jcQdvh}?|*rRhi-er1#Ekw+4yj&+xHdQ_Hy0F2Tc!4`+c<0(F`KjQm^10q$TE%E~ zWRyT{(|zqz?qPjleh=--mPeo| zqfS7Dta-FbT}Iy3}X0^qDh8@)X};9i4O@&ReklS*xjLe}hHz zeQgQNoqT9%&yG#m)?tK-S;Ed66k0VpCQGv>CN&ATuyU~SG z(Q~FagqqZE4Z{5p{~&U-uc{ldbQ1a(Bxan*j3O5}HKzN4B$b&|G%R-P%#_6I`?r;o zDf?E;RhPNF2KJ z-NRjK>H7H^?V#KZ>Jw83f@RR2JU#7;y7C_s0o^O99kasEE{}zOAN`Rw7%9)a+$8Az zm7wzdM&gj4@fr8bwBqt8S<2hCDW2BIX*kWwsxJGyxlY3i38B5X+^O1xzC@cevCxXP zrIT-~_~iUV3-^3$9$+)-PAoi9n!KLd%dNMheW$`XI4IY*jN8KLYv({cW&7z3m7L=v z3MGBFSJPuJ?~#KY#=jkE6OOs@yo|HhO!&T5*F!rCQni=42NKG#=^&Q8;G(dM|KyK=O*(nyuLh+c7i!3b^QF^bc= z?07TnBMYxKw#u&<-FW*$P{562rpQmJ>9Ni~jGf*fpa30eTKUS>@4EaM8D^%(=00Wf zMXGXLKaqOOSoe_N@g>j+V4@ZMq{0(#251cITD%}`Al7(h2yrbMiGy@m_nR;)sV{t@ zkE-uMQ;Qh9qH^JLyxK1o_Iw+gl$*-adln!silF1AFo|18nHNUaQ%fGN} z`Wh;bk~l+Y>Ed0|(Eo$cJ;-E%ILcB3$mZK;AC>>`5m-0ZI~0@IG6x#-z}JQSC(`F% z?=#4SxWZ>lj4Wi94Ixs8v_(Is*QKI^Kg--qPucf@K3o14agJ4uAJ6>#-CJu2x2}x= zw{l8wKTK>sZ6q_JNfJyr`=woxK?SiSAs}}uEYpq~z^|AN4?>J&)^lw^VyDLqt;;Zf zWE727i?B)6lZr(YZsfW?TP_l!T8b>j!fy_pkle&$J<{_zAmIG!xzP|w8z8ALVWJ_G zLoM5yFRapxWnR;Jp5_aY=|0H7;U~dJw)tlSX*P+uT8lIY)d!hr5sOG`{p&6;2z*G2 zJ(|`(k~DvfJ1K*?W-*@)jPp3g5F`BBc6F)o>9zdU;zn?F+z<6KCjHOo_PCsl>&~^nOe!*#Yl^4L686St`tHLPnp-k?SZ_C|E`I!#nNM3|ZL6B`Dl;11KWw1jMqsi$J zi;R8JHa~c1;seg*C|1u1%q5B%x{WP!o{DvX`0nzS z#D?Q}QZ1N;L3tIbjkVS z4mQnrJDwvk63YQIJFmB-;t?vD3(nl-n|5jfYBa=mhI= zSi;QEHwz5qPv16VWuF&>@Cd9}_N!h>!vBLt*Ix#237UFz_cJfW# zRKW4(fkh3UxZLcUY@PUXDbL#Dd>O{1ALik@riZ$G(J>0cFW06%Yb`%UTCIDS9&+4G zArIa9x%#C3w=TdY#k(>-yh&TD*tS=7TW*v()at*?OW5JPIga*2*s*IW(K6Pm?z=l*f z=9>|sRSJF2Qqcy-Fweja1Hy(`RbyOic&Q46(wT2Nd~Gh>mg!LM-=lSA{$&d>NU)jl zJ1Hx51SFZcxI{7(_kN90KLo_q4u-(Cv2YA2_E;7$eggaSJO}4BZ^V~)L)pWPtN=Hn zZIdvHu*pa{SPVfx0`Fj@sT85e=wWN-3yMpp4CTI%vJ-}Ima%Qu)7yJQ>f;(IhU%Ly zpmCSBF7BiIN-CHsh*DA1NRF_91(v=S0Q$JSdX)S#Y8D5Y*f>amwI09D#5 zxJyp)pI^b8nt9N&u+$eDlrZGyKz?1qAt3kw3hD2S=$|s2e-0>TBQ;!Xt)cn?vj>a) z;8GYOUT6R@6Pfl+9E7JA$N4!j!Wvo+q>M~Dgex`=%8oPvTzfZq!WbvhqbCCe>L=Hl z0E8`*Ctpx^T>waSYv2adyTl;*xlRx+mO-=>9j4jY0I*!%a0#WTCvi-+# z^gc5dW?5hAbrAMIy>)OPx#1%9^>+Y9L?TXKC{?EIa}bbNo(D8PnV*Kt5)wd%O5CMD z#y!;_27zC}qI>l9I5d7l*lsPI%*!C9x$8lDL^639cMjh{Jd=Uy_xPG=k`fa1*wcLN z)qluHtK86A?uST)_oE1Q7e&qN1ymr5>LtYs6$;@R`!WL3i$$=IvR49(J7h(Zj)Dkl zCM2_j$|TQt4mQx14O+y)o*Qbp2?Th^)W|bd$%AYM{ zdVC>TU()k!jE4uIxdxBbaodOy^&%D_RXMtyKqG_4hb<*)tcYjXWbCuCFT_!j<2qOA#%P1a zM1vSOgP(NAXE8(5O!e?_z8U}Zj`bt)Cv%QnXuETF+4aHuR13*I5hca({2MZFt2$SE z0HtTm5b>zfy&VI*>e!QGq~H@f*13VCS$g6#r}p zkrCO1uu2($)pf|K;jFmMA2^@{_IIrRJDu_YH~Y4-3EBrVsxR`;-LhB?Un89+|MH1~ z#>zk-sn3>U0(^aUIYM>Vi`X9VfyFZ(q|dL8JF%l91Nq1t?{h=gwCGsG#&lz@PJtHONlufEPZ=g*h9X;g3!q!N2l{!=4528E zGz(hX-t{Phj4sWW&QiJpq~R`i&J^L7J&QT8@3KD$H0c~VkNkEZh|Xd3Qw_8m6!Z7# zl3lzF{vpfEuD;41PbH(g33}?ho1cyv--_c!tM}yTSJbC~OA;pCA;L^qz$ZpYKc86M zjZWd@s>0RlA5*c3h4A9~ky^=a?_<9*s~oyL51qmWO`ho3?7@zFu7?`3~VHoBNzFxn6p?|#g={Rljo`kwxFCoF zsa;lB{+IlXsSGNJSIA32g=XwPiUuL_&yI1RLS~S%XWfH(CPe~!<;zYX4*Q#=trZjC z<-v4>^ESEz^fK?ve*sPn5Vs;6^Hh?!5xO-3Vo6b4(R-<$suafS%Ge$6W-FMzld3*k z|JbD6Uhk*rciGOrAU6YOGRdo5dq={|+>X%$Y4_3o!3)`SU;Oh;!|!~Zu~i5jsAwY$ z2*bAX_5u8i{6<%`nSyE;mba!kQp~32ya=-})>{E3uqPd9Eu9z+iq@aqTq}d+?F})> zW79k+^2=#rRKb04oYnA;WdiX=NEBg5F zsgw1oQ@?!df1Z7Q^QEP+ERdYf{k1*@+ZF^sdI6fC=%FZaO1{l!57B^;>bA$fwlCgZ z7hlRKfbp)r{;Gg7lbrE0^n1(J$q3Um5L#IvKvb%$zE!R zu?$k8qC~cAsqCVxW63UKpBQs~m(Drg=llF#&mYnW$IRUKT-W=0f8Gl#BBsLyjhskx zu3>`pheN?VhvF`J$DJaA%^9rWdZMyG9=mMn^r=~5GPB15_U-nX;rWEz)Y~jjK1k7g zI(#)19u>#Mtz(o59HE-daRJ+Xx88RGZsVPBzQu>QGb<_O#r0s;^k49d9XP1e+U4*3(7*Q~*1(ChU2o8dk>6Sa9YL!il#S@OuKOl-B&7%ft$6vZ zd=ajiBJ}iv0n@4+h$n-rJMCQ7(Qy|~s9_$$JyhYq7OcgLzG?MqGv^hMD03v}A+M&S z9Rz3;ph1o4bnkX?NicGRL#!P*S>Fy-;z`tQkgSg(*q8kHh_?WxqnN~Pyg$Wz*X624 z+?lf9ulmDLhUQnYKPRamPRR)y&A z4g#Zu20q37td9h+LYSSM0`uXi878ZlL4xC8WNR6igr;U%v?ruxIvwYfYy^Ww^Cz)4 zEw{`?R|;WSebGjszr!~<*{D3oDjtw(3>pHs4tWLhJXlY-01Ad6PNzC65}A=*kdKs6 zef}rWqmeK_B24&-;O^P&kh4%SI z7-%UN4nHmbH6qEtuQQ#Fh*izW0A_F`(y2s7)aO0v6PTOk#>s|2onI+nQq-~Mp!Uw zUB>6V>ZA;;Du9hD2~8K)TDebexd0%V?MC|x?)BT4#lOFn{1=p#NW1zM^y-o4E~27D zXzY`A_a4{Mpak$+_8rvb-|aS|;9G1I9Js*_{|(GT_fb#5cUk|=seTMXLgW5~gx(fM zJoz1tdLX6BpFh$ecOmWV|Dd#g&|1Q9_ud`#FRX(rT8>77ENWJuElF0lqO>G?e*&?f zVnHB$HqN>kIOmgm2UFFwcAtI4s&%RsOvZ!aWu)1+dd2W9+N|LDNtCrQ1WJ^`d5iY) zyt!Q+{$)ZX!7>F7i$_F|R$T@a(2?I}dP?^sGEZ&gBNI#^7LoKjwDP^T z6$WsszLUtd?SuhM*pi%wUi?AnY%xL|rpppWGPyFA9w+Xy)OiGLlztkcYMy9O-orWp zuCq=1bXT~#q@8~KinG8qdY5$5>KvpFzUH>`dl>a~GC`qj{dq}2`GB*iOXpYfD~_-6 z6i*CIaL0c~*Hztl>EVuJfX#VL9%uRC0F#efuhQJF>Z#e^OIw?Y>c-!nJ2(BY1y0-F zN$32zf>~12PTx5b)=w9F->b%oI%-K0S^(g!C{d>J=f*1^9W~>O28y6C73<^=d@#G3;P0fc>svPR$%lFRLy}B<_zc|<&UVEL1Ix2qTi7)MR{6JqFMW>T0y$p6^ zs+6X%9Hp>@8}@x}yY5aC_b!PfDpT>akFw0{;@uu5T8$B|Hvm1-udYMszyLs61kWcs9pl3=OO&+CuopxTVYDWtWr3p>u ziT&Q$WCmB)JFLTzVxAboM%f}KMvcb=c0PIT=6~S1gJauBek#9c*Dapqozo%5oaU^g z;3TET*(51rEB~wTw!38Cgoo5C0Nh@qcj6Dstf0c{To$yBO{{&~R8E;AWE35Ll&bJc z|4J9j9AUScPde%%d`Sve!j{3G{ti^B6-*;#Uj0N}VVj#jM-kV^XRP1)b)VabZ>T-jn z?+|2X$0PWyxGf!92WSZHO@1m^ESR=-iz~9jg(a$ODHMV{= zbQ=}IECxo*pGX{raG|0GE%TX+_2S1i@~1#vGgBHPr<@rl<{e8o`Pb$q1Bkx2@FH5!*Qw->8VD4Ru)K1n3L&h+I}fDiExgUxV>2jY;?~%vUl7Y zMot4uki2iaC6+;TI!QT*ot=CwQYT*w{nZ=+73Yz}QjYpvq4t^Ie%|J#+8$!CcPoGw zZanNuU75razi|_-tK7WQ-JGLeK31I`|7!ldWAdodre?3vJEGW7*Y@&6BADvN;^CSo z|H;yOwX(hIrV^izZmA4Le{8u6ZD^TSgkiypq>CZ{+&AK}y#Vri53L!d{F(^lELsWK zBn@8R=Bv2>9Hp;BiEZK@+(&2E1rOmZbykxn-`AL4$PozEO`TI_633GOXGs zRC;5L&ZY$}&jG5@&Vfef72ipx!GOjI4H0kO&9V3z?KVt$2jw z>@eUPI594XL#URwW57g8Lqs*Je|r5P=~6-Tvr7sNqf@^a&t#9FcSH%h1=I_a?0QEQ z8m|+OER45CFRq88%TvXq#HIql8Bo(w4jtyM`X#@%x1X(o};L`GhW*N@E8#gUPzJrk@KI(%g zSR!MPRT6f}iX#fUFJ9P%9NgU*WDH5R@{{1AOub0}?0A{$7*uHRskNVgpRs_k&Sn2J zW?}1ez39AP%mtG00ht9tR0Uy?tW`-DVAXaJxX2L5*;2A-C7msNI z7z6vdd=n(|uR8v5~7koj7=^fRAaZ*h9+YF{c<)gIYgKW5; zBg61t(3d9Qy4Fbyvl5#37Sq!;qwEafzM46;kyes1L&;XrNcoCx z=;Q{G5?BC>a*A|8!%Kgff~H7a(1OPhARR?8t*OyFa@E}K63uTqoJqu+U`6cp%{I2; z_QtZctEy^Rn|Ol=STqUJ3=fKvYejHOWPF%mvS5q>C~}&>jqxWz$W=)OU6`>xj!Wkg z0_0nV?`Py@c|_pbS~lCOFJuYDYM`nMqi-86G|RUKv?fP7(h}5 z=&n92$f~R}2s;$O7)}S*I5N1aJ}V}CKDfKm54>#Wfx@f0Mq<@W&iJb>XcN#NNLBv@ zZTDU86tRHSmOtiGb3N4*4{Mj;4+Mc*%Go7SkS|sv`pEHeXuJoUZ01gtfm`tp2@a;H z5G%YymOEoiYpy9vR*Fpo=ykC9DN^fQLy3rAdq|UXbO!l%k%{GA6^(unLFzn+0w%H( zT4}*`I~BBee8V-&PoSFPt6SX5!_X7QU)=8epReJ6ilKn?!g0pLC;w}py+I`S{DT=u^@IHNE|?yNfU^jKbC!HLqg-Al$fuv zeo%LP0i%h{b?&{h*MEPgElM%8wUPjN2YiAqL~!h2jKqR+9sY@Mu3;)UNN0MI ztKUr;^1>CN$t9ymIGS*(5kz}+^fp7%$(dI(j9bxrr5g#6Dd0I3=6ss!l>`yPNBEZ= zAL1$gEKek!gLQkl=d$6x=#|~5_xqj_k7v>=whNF>gw@W$H=E}{uAV(rzH$67sM(bN zWce3UJ3(MN$Fjd^O<~}%z?rI)$#ga>Q;+!51Z&W(H6}*8F98w3$!Mr4h* z+1p$fdC{BDea)+8Vf!$pE_xl$jypUk5na&WjKeWDySsVv8BTynO0g=${to;mPM0v= zZkZ4*;#}9@kMlz>l;2W_=N=#~OTr2nOH_q;@W;eY+~BrRWDIyF>S$@g4)W1Hbmh6r zB$gP)rYJ`cPI;-W7wi`IDKn8lM|PV&KP*zcOW_U>e#=quWbTH)phF?oRy5^}oW@H8 zf_Lubq7+`Z*t=}0mi|nqvzveo@5b&647s3Io!+q$_AIb~DpzVq*$TfUyCcT`n+R zeUF`(`!%&J_8Rr%`!-?Ye(=8Fb3+`*^7sbxptDWjCVTNydEHLLC+!`Qg@qZpo^R7x zB7==gLo#xTgMCiJ2u|Xzh#9_-loFsAp4`<1|CCL!UZ#o})MKbpF&TLmX}W!$(WT#+Ip;K8S+ zll+K`5&co1+69uH?Ad!0pfw5q;a-T58Ws?PmSnT=)!bA#$ARxScR%Ir zbhLJ{?|FhQ0W>W_|AK#gMj)}#^T+Dyzyv07!sg!P-hz?Bxz$@=d%5_cXwn3l0QKjN zUKmJuvZ_n805x)c?rtrk$NGMKcA=wsulmRI$^CYUIq*@EzCtr~$??4ZYyPa0?=rX$ zCuzv;mFmguu@Jf4gyU$+$vgQnh*5OqUr^G+Mdx_p9INO`={^7+uLHm*g29zirLvn; z0l|E7=@M^!io@?JpfRj?(C%I2`^ZpbO;k9{BeHfGe7_;Sp`4%}#Tnb0O_h{*dbqIo zr*jXDv9xNiH-}{^aU+r5spI&%mYd=j9m1AlAK>;;Tlh`ytsTRr+iz`TStssc>lw~M zQt!oyYCdy^+UuN_zo(Poizbxf&T)hKM8?6n+cqnS9cUL<3vj@5bV2!7V_}Z96irw6+kc!+7d=WaJf>mw9M<9jjbK4Nuv$J$x*bxFV`x8enM)Gv2gtR5 z9@(9wu<~*+@ckp}&}#mBf&>Pq{}K+&LVJ0b@IgX=TC-}Vm5P=mL+x}*=IwP&0UUWE z9ArxBK_?MlxXiB5XJIsFl)7Ln8`Q-(Dc!6RZx!j?1sZW~D?%YPTH8IKqAmfXVn)=H z>4C=rYe_iCT~qvuF-&mIJ743d;CIwdU$mJEZs^g*hx^W`fIH*NUn3 zdlH}Y0147yOg3tUR#IHlS{cK6GkX}KK=w#x?yULZoR43=sJLu90N zSAyp-Ly(+&>9UMvVE{AH8{r4GE%X_h(Kd5E4}ok)M#)|*?pzzPr>mY@@tfMsZkm2H zMouw_b;ML0{w((~dr4GJN}{VI19q}>&S*3OU|R3w2AU*i3+EB-v=2b%FV8wt!tQMZ zzA*oT+{3KarZ^as2~s!Q)rhZ9^kSP&Gm78PS}qJfgxcYJZ3V9*432x5LaO_jPhb%v zS@}K4RZzu%5$z|`N3~8GYR!4Kz}JY7cEHg{1k%b0UUGy8_ka~h4i^>`Yy#kv!~!jY zf&zAnbHQC$QAG$}u8&jMABZ5nlryQoc7QscjfM66Hm7!J;mI|S=?`VCld0q$2 zSZZye2PtVpm;uK^1JNr8jQifiM;XfvM#{@!`S|{mR>0b6ja|^0cL7KWprtnY5?&NV zxq$j@1aHyAceShh(Likg4HO<|pa6i1`~-S>48!DrKql`KXgyz}* zC_w&}KJ3XKEEx3vU3}H{&RRIoThgLR0ucyN{GdNwQmsIaskgI40m@tluajsE5N$hY z&4CmT@V*d!+<`Op?N3UOgnpY>NIC>f5Lj?tAm?8L%=aIK`~26r2b_>#TJ56B%z&nZ zMAZ06eC;SZpbIz--kK8RK$2)YyGJi@?(m9iAYz%OAL^?~Bu%~|Le5BF{f9w7wDIpl zHe}$;+?DbLdd7Xi<)p)MDUuwBHPhHJ$CnICXTGxaa1Q&x-BESql##Dt1)}0n>yK=TIX6|6m(DJ@Vh-$@W6|0o zjza<2hARng-=+%-_Lh$nv`ApW)ay=e~~ zr8~}&8r!ITWd)URLgt(jif?}9`580q>(N1{o-C?uC~Y=*zwi!!8l$@YV&0hhic!Hj zYBlClzEf8A=X8jGTWG#v$g!0_lm*sDh(+~3Yj;k9a-6Z1+$SQF7Vc|!A*@0%v+t?F zI2M)VNb$nZ5Vn)CfS=e5q4d9@AcpMSvLsh{zgt^La)LGb4h(XShUXhWBbs~75eDdo0E%5 zl+j7hg2BBa-)ImmD$#4crh92r6)9#3dw-Ai{P!Su9g6{~2m!%^LtA#$)KE$leXHJ) z<21Md+O>b?)qLBJs`EZdF2W3>9)x(VWPnPby20LwcQgqMujZ8!YG<>0MgNe?)R;oL zQy{v+#Idcz^MUQQO^D`NqnULY0U@ ziL{Ur+&9Mkl8B`y4t8TnD6awm7Jo=?K~J-&RS1^`>LynuClLq1obVrXu;0KA0F`j> z|9~8ZJ}GEMjg%WpWR~a#+j8=Pr58gl;mox)9j%BQGo9i}5D;Zm39!Z4z10e0F~t=A zG>oc|-VZ+-E?^E?UN{k>rdZ6H;+$CTpH|7sn6mz+Nwj^kT09~p?eU8ZVHT`nH956#r90}(#k*cZ% zjtZ23g0fZ->muMjWkAdj?DQZmi~u0q3gQzuRV^4yeLJ+I`dY!fjqOw6K{Z5ot7_o} zATMxyYC@9M3&vOgQy7_{%N7R2Ij|p#uq3c8m|$UiK-sj>eneG`es&(T4nhkFwd;k2 ziox9gGD#AhYarPaHh=_#3*tDYwvo3 z^qV!4$|%tEbSTS(VKJat@exwsl&?va9F}Dg+Xgr|EcYKo;Yj)se2jP=xV0vvD*&=fng zZ+{@k1`cN91Pi%66&kol0Hs5)V+CB2nL->(s1yL^acr=Es&TCjtbxv3j+-+CV_Q)3 znZ{skhnelcSN#_T(cZ%#{{oam>Q*WS)F3(iU4tY-ZPC&+-NPq?3IE^|6vY((H$E|6 z2TaeyWjUwMs0RTU+M$am>gBrsja!Z#3-Ro;{QfB8lj6L`WM*(5LLI;a#{c`H1wdA1 z@A`bWW{`E6`qg+RB9ZCFQg%;9lD(ExV_zJoc7)V44;Y3*dEUr_Qh}OF{6uIYr^;DT zat6Ih0(-jvYA%oBQ#ucN|Ru ztb?Ey3T$S8e_s-sXzr@JfxAxiB`;`XHu^BuY*68`Nm={7xAe|l{XWSnlQ<`_W?k{F zLPf7p@>J`bW5U?GWg2qMZwC|hEhu|&htf%&^4~_qD#c|*ogtr6}n_p z!qwd>Z7!V3p#a+^|NGnz$nl5i9Uq+TRI1Jiu8XZJkyKCk^UD~=#RX*-m4Tnr#*j0? zhKphiu-$yVf~p5$nE?ro`{$Be_s?4yq$-oSP`KY^KCIdgH(Ea~M}sfZ4+Us{sUPDC z5iE2enBF<1aS3y#;(YD{gWRHKyf%vB4*b00b~WSymKI&w?&B`I6#tQWH^QkmJf zQ55l&WBN6NPD8ttXi>8=@S?ApBCM7~f#@*pc|y!XmOw>j;p}MW#J6Ta-ku~6xN4yV z47t{mnzg>k&sT6c;uDcE^Fabz!FpZ^bUazC*q>q1ZPk{}B@Hg79yMw+tL*h|5#AZk zTrw8F{#f&=>&FgRv2QZzV#3;MrJi=Xu1tvw79)cOK01DI#5kJm;)&iW3`ZTgzw}4r zW*LZDF$mG`*FHj1T|r0F#Dj@?i2kaP%p~#V)h~^?Z{<%>&fES9LZx%y)vq1)K0VYU zTT@$ctOz}~Y-^MIOqz6g%)*MHMZq{g3-{u^8k}@u(d*meeR`rPEIRqNi+g8Dx!*og zIdMQ|(rwBWOkfiaK-oclU>D&^+XLMUU^*`aahV>u9HmyQ{`P&L%cjq~-Ba4({w{q7 z+%Iv~ zp#Z&>C(QNqp%uMt`R{YH=Hl6OI}8|m?R3)FaDPE8bs-n1vjlw0$)T6Q*=zwfF1;C~ zst=xVRVnf^$QoO1Hb+VRe!g{o({uN67#saFo^!GG5RHS1FQnp6m&G4fek990jQ=q6 zT<({-lE=m3&R>El3CY^vjPi#U+K0d9NJVEQyG=aSJn1{@r?JYJZvNy;v>@? zbL8T-@3Nz(0h2|%emKW1M$&QOMCjW3P zzT>L=-h9#e))a9L%Rldy#~D*{zf|CC?r{EM%E-H}&!qEKQE5+8Gy*DAg{}EKg1_Cl z?)^}$^lRsjPWrxq9gPtC-CPOkCH%9eDrQwx%G)+2fp-$d*CHZ=oGZfa&1#EASww1N z*r%mPAryT?ugPcK5!edIzN7H#U@3dxQ@HulOOC}Gzp7rw3Oha-SCD+CZ~JyeH`2@- zD{hWR>PVHC_hJZNa+H6VsK2EfYb=?2Cmg`~%ZOrmmoq`V55c_4G^F>LTxJyLYT(y{ z1H%9$i57A}3*pq`!9n-~m(fI?+Z`((S5E!kGJWIkVWg1fJ9{?Sb9K&`?+T5Um?qBy zq0jAWKlQE)X~w=!-_5Q2SUX*#nJ3Y%Fnr4^fZdE_bTbz)^Q8(L`Vt9;Q=eJ>K%JMF z;_?yzw!ovcKujGOf^V=4V+tFk^AINM?Vi0JxGBgxC^X6Z6uuSyOM1TMq~(Pq7T3&4 z^w(8nNZEyGv{}Bt>3LZyLABVJgueA2(Y#7ZExTMzGs$zT!iERjN#bI zO<)G!r;|s3O!ImG!^?jASLI9`t_6e*7!jwk^u#4KH?~1sU(}oqfTVjI8U`kuOTaD} zGr*BnDiK@nS3CLX$n{k zPFCi3{ttQfgB5$LB{)G#Cl9gR1q0O)}i z048wHjpYlJ`#_~fWQo+ED-vf2P%C3J0FDeeZR_tny+(t>{-D^iMGgybNiqq8WDz!U z&ygkLdII)?6TqW=@!lq;d(2c&H(+$Y)8Wzp&qoMUsH~CpuuA?WSAIYPp!8k_HmhzZ ze)KiJ46%_dc!j(T?L8HHB?5_Tx-iqrI@6C|M|x*}&NMG!xPiz427nh0dWt5gt*gZ2 zCLeC3RSFf$9Zij2ClTGbxH}aW6P^^!5>KaDL!ZX$*tra>2~a4a@2VN6`kv!L3>E=U z=@F3xa|BDAU(y!Xg^h`&?Uz><#mw$Psqro8`N{5_MS9t1RO;70iKZ;4w#cFT0R z3kHVnh+Dbg$6SYpYVp7vP)9p#I7w$WSV{H zjywFMy+~4f%Mn`iX5!p3o$dLb{^@@Qf#Shc_~$(108E|(+E#YzDQ;l++nW%I2Y4eVyoo7l6F3n#w3%^Wj}9bk_WDk|1fOjL59XL2z=?w0d_Y zd&ZOqlSq22Vbuk?YVySrjrp8wkTC4y+~f=#G89*;=_-Gb;zh`_Z{Z8&)8X$$ zGm!-9Bq(XvxkWSGji|e_Yd;BZ7&)quxPR19x8VDwVSPJ|!;dFBuZ(%#Tp{f6sd~w! zxTR7XDZ1l{t$zWP3r&&wp8Q29Shw$x`)GF2DPa}VYokoOwLTW)QPTgW6d*>1iA3+q>}44Qajmq)r_9dxhan#$fevDs7{RndNncGP99|!ehfApRCTfp zFU|{xUYIA0F@wZ}Q#3Sa8|%DBM@3n8eb6I)aVq*TEOKPhuSGv-zn9J;R%w8u+9ByT zTJ6R(@(aEK28+z<;KmCLF#&(#04#o2U&oViul(K#)2+w~>T2$0(_5z_)kY{NqC+nX zuqX|0K-`y2M&01^@zZ0kJzLEY$LZRX>$y)){F-+hogeh7x1D)(i>xx^xCe|c;Q_4# zY<}bd)SJ+)aHXAq7qkocjp74Qnx~4H9{FGK*`HOQqA9;KUH51iPR-j)01xA4*uX2d z+g+Djgte)D6ELBX9P!f)f-_*S7$jv|=%MF_vqSaowt6`27$uy0$RIS^uwKdne(pAX z=r4$)&a>{v{l@I_la22B%9o7Wt!;Z76BW#BESSnF7j>N}&A~ly$!sX&pSZ#sN7OCN zywnq0GO+AioZ5NN4Bs<`PehiYq0HVQP-qlxDx)$jlRTKI0QdvI9`JnuIj!XBEHAZ5 z#eNaM)}J!^qk3+D$m`SZG57I&AK*Zpt{&Uuc+WxnZBKcqGEC;@(r}I?BABr7HZn6z zX{C^Mcx%&y`ees0>?06*@qWzsfq5&l#JoXDkwe{-rA?nupW?V$EM;sYYor`hw+H|g zGi*1b&f^ldb$=~HrT1Pim6&Y|Mw-SZX0bU`YCQ-JxB}!tj-Nn`Z7H37-z2Er!7oJ@ zhFDa;trA9Mk>`Z9yRwgs-57}peVTvoJqX;jtz*@eq4G}pH`bsg5*r;4+PhXsyt^*c zpB?HZc|93<%||1iobb&Lx2jPREB<-W51zn@h=>v{pHO9az28H%**E);HkxWZZX4sg zVsKxHZ)quq|HM+<=#$USL@UC8-=(2B`|aVU6gXM=3Hgh=g~4;TtsHlvZ6m_r!mGJ+ zh|CuauAM#R>;Z{qRy<=%G>!5v9)CNKbUdk2bH4#kUY4ArD87LQj}t@`wHU$$njQS0C4nO(FHD!d(QcG27-bnN5@}!Uc8kc*?Qa* zuZE(W1Az^mOW;t$%-a8p#z5Jztf8nhGAE}9@tRe-{Hjpwp3u&qHD{Y4Y@()bRqg-f4D+#S;)PsJiWbXwW> znPmnLo|X7FZ`JGrhVI7JIR@?Ul1u9yqcM;sn-gVboHydu2+W`?+Uf19UiU|eSa=AR z`TOZ=5gr!kneW@C&t^JEi>C0S5O9r)WvAWB37p7+grw!z-f8Jcf;_E%_Da9pG)8ll z@IyI;?(pQEbQiG0GbRBN%|`(Qcyq$D351}YS_Q1?U(iAq;~(BmPqGE{yItP4fN|-a z_^IVx!;C-*Cd4+O61d zNQ`|t#C>8H(^z$LFe~qNQUizH&xb<{2#yptSHyL@+&kxn;u%%NHE8E-sjzNd{el%GVw$|)?HMubiV&J`C{28xg+r8dAHT)mbEFH zR%Tvi*mk@>^#Hjc_wGrM zzbjgmqJWIZlY7ga+!d*Q0G#|Ox+v;#5NkTsQ+EwNsB0&4d%x~De*eX`ul1qd#n99m z?jV}E+iiHjr-mgvI`4||v+)|cq4Pa{{3RZXGZ#ucuK1K9;(T7P&P7UZmhKNYbZjyt zCh)eh`j1MJ7vkNe-~0>`qgrh0ZO*x9*y9y;&^<~@4;i>KBckjrEB!dQ#~p#H;#IdISxo(D>Gu& z%no)G|9njK>ENCkFgh`pU2^%=tykXz`U|#b`_`?bS@>Df`AV4<_ZDS_q=08^^+l}? zxEaWYEx(>kw1UI7hWSkr3y$O^j|)#5@M2-e>0mh~A&N1!=_jkUC4TA8sjf{B7f(=~ zB)E@EoZ5#n{#{6C=ib4GNbTOMMO>r~wF(SAIOXz0@qxSqi~W-_FR1g(R;iYu{%o*B-r?Xr7oIm#nG~N&);CRSLY_s>fp&RVcMH=f5pKT4+)!(+rYG7JRV! zB`Q)sz#>qhj!*|yv{>rH9etYZw=kIy^A)Cn!P-KfjtlwXGLvmX2Vq-hxsB&BH?s$q zCmfgDR)CRU0^j8BU{pxBaoDEziWH73Ku@T0!9iMiF2PYOYlEI1U_z!}8sD@iw3M1W zCf<2sY$P#dVI({KhFVQXf1P4^t#6Y%+NwbtQ4!rb4MUuV7f(g!^$w zbk0uhaalRbBgB9lSx>n^IjkeC*jLMKaMh0moA&i~B)@KwKj@5`YgYpg;r%x>W5* z;F{YT6a4c5(?i?>hZrzA_(Qug!I1239K!Dl1>6`V8Kiu(%F`?$!$nCF za*)$_`)WBF>YLbukOy%+vkc<|yP62PfW!tQa`$!Q3Dk8!#KO)=M8;TF=K?3@X zV`(BCdWk7f@zyf`X~rb%jb-Hum}^B4opSO#)zFH2Pa|J#2@7?&9A}2IxLnYC^JC=I zBrh3~=I*~yKs9uW-Ud<|Cx!Pd^i2FbkSPogqiLR3JuXK0=b;oEF zb}~*EMCftVNZ}OL{76I?Wp6eDK7twewc7P@)TOMypq`9)VVufk6DzfZ?&>n3g`ePU zAql7Ph>~p=*{&B}pl(aoSYP8LtP?hLk*~CTA)d{Bh#yJ}+9HyPA{*0nKxR*l!w4!l{s`WD{f6p3vF~lH;mTK* z?(yWZ=U%c-m({PWN^hMS^J#*iS_hxYwCvuFRT%Dkpc5v4XU&nSH=;*9YGZ12*P0Kv=`=G%bsSTjPB5?f(X3Ub(Q~+= z64syflG`rPheH8l-7NjORAl<`g@h9|=B`f0+!A>y*BwgEojA#J?K=v+pxfmtlO?xf zgKs!p=ZycsZTE`O*~}SqrO5Fmb?N5#Xm^gLLJ<1~>P{Wcha)Sg^+EV1M@H9$-~v~R zdm>MQc3Bq2isnw%~(7aN+y`>`~5}^)ct>8s;3{??}?PyD-k? z(KgxpK4=wv@pikq^x7yxEcJZLIjuZXmta1En%$S2WAYxQgSD;CABp%OGPZ)whF!h0 z^D$$lspcATr@!^V4HGl#R;}Vge(*p!;8#iJHo{#z-ulKD)DrJ5t{41HcG|sMaQ}Je zkV*W+{pcr0e0$^gKk>Iq{M=YF9lb-&TCevC$?Qp2o5))`>O54PY&k87x>3^&dnXI7OH;ymXz zbgcmgv1F!u$$mD*sKXwnrVe-U`LX04YE8J_+12M3>Q?QU(YM~t@cD~Q=f$3=kq2@Y z&Rx?V*ht>3iE9^Ny`{j=H$2js(_)~1!;A@2#9h4$XSATtS!lvsqz z(_YB7r2HW(_O#6RftDG_Y+_Sr*D)IkU;d6Vcxr#&A9MxOC3{kjKf;Vpq4>>r&#d8R z^Gk|;(NEcD_b$^8IVOHr7-zxHVteuHPo+olP}PhdKp|5ZW)fDrA7E+B-~SG9z5i>xWpY+F zNdvbp8Ow2lU41}krF0Viu`XPgv6lK|<$mk7bL=fG?L*@IdBvBG8xpyq(9_!C*9h~= zXhSDwr+6uex`!C*3D2dimD0_&-Pf8I$aubmbek|vioso=M${NTKXv`?;|yHZX}Zc) zd95m&a-8w=+dts;C|`WTDGYhOdf$)`ASM44q9$4fSi#auAl+> z3&J@L^`0-IK3GXL>(hi$Dh*$2ElsgV@7ifjkv`m%k24S)qLj{dZd&dp)iTgq@a#9k zxaggAT#MzL0-t61-6w?Izid6i$yFI>wUu0(G*YdoGhaJK`T1H)V$h6X$i|lcQ^D~7 z)%s`BX-RDxq&Cn-^a=|pj%W5tebxt-@zhhVbF35yGyoN%K+tjEeq(F7K6n z@nRZw59&}lTL;V)Wm|=vx;I$F+C0o%#!~7YhH`^pM@95ic5Wb<1!m5RHAx;7LSZ=E7l1bJ&w4beAHo*(GJ^(zR-tj=E zfkr)r)VM7z#<1vnX+>&$A_)}|!Evd`tKpcZbyPL*zLWN+oSg9MTxzChG&<7{U=EJp zo39rR>P7c1^G$Zv&nAwICQ_9}>FjJPQRAIU4BU%KFNcR+Yq?eshc3-}8UM^MdFpi$ zoqvBGp`Gfn7&@onV znD9hC{AWYKF&^xDlIO@v{8OI@=BfJ}2Q`v-EK-`*g)3+&;2H&DJRi7iR)c#yD&X_7 zSwF#%gQ{YgdWy0rC1G(_@aF5p+g^d|Ji93Hy^eM6GO$gd-6+lAm4unI2|{+J0+%q2 zUXOkk);>Ms9W6Zc4qxv^?Oow|<^Rm}&8XvW`M{x$wTtaX+5-80-9Y!H;6}RzZi7n) zP(*w7pt1SE0F?=ldiUH_Jo`*vNyV-_uSfD34j+c48XcP37QZ#0*uW|{_OMjaZuf;9 zz&F^0i06n8$z(q$f4;=NDnq@%*K+-C@^DOIVhP^1_Ewn1@%k%1fyG&Guf6l`L_bmk zGHChTXBam}z*GDMeOy?+UsulC6pCrw*2>g-H*$t4^{r}x*n()*W5(LOoc=u-Qo!HT z;&f^#*^$HbQLQ{R<+8N%_yc9K{NQ_}(wCQ~F9-RR&e(z^g%XgCMwLozy<1TlC(!X` zVhL|iEk&f78{M0V{0hX|k<4VLh|0tkl8m2>{>*ngUW-TjK-`<_+lJohMm+#-- z9B=!bu=aI%@%@(4HZQn4Ky^Try{7l1TM1^;oQ61duJ=~W7p=m^A+sh;169 zoav;heA&e8MmW7*8Li1Pn5?^dpoJq#azKRZQD;Q}H=EQI7+!-dNZ?j1{=R_a z`m~Xc&ibA|%#9$kNHSE~OotDdaU1ixXbS8juYeRCHI_&%jDWF)A>ZGMqP<;?WE}+z zd|G#hOl^kr&5eyAwst)!G|AXZieE=aN+aUI1gEok_wgHbeZLM%9=ZGBwcI#%*)ax1 zvBy)7k5*4!a<5#3|A@5^s0& zJer00fL{>D*J@V=vCECtb$#+F`hj9l%zbfBmYc_BB_1WaY?IF5^UY>1FKKPz;`el6 z{l`}Q1|}5yNpzEAB%lOW4%2>PW%Xw*tfh0Hc@-?V%qEl> z8iIG+)h-}XvV2Tn_HCQ+`^&6D(h$Q#tp|9yAWdPv30)3T(dy{4I$*_s{FQcqf{ulT zZ&236Q_LGnc8o*Jck~|?xoyTN?h3tr@pdNZmzFsRj}ATh`WIhmW43*(67sZS{y@{% z`QNRp+FxSkuTI{dDx}nO_Dka!u)Z75ifi&sk*}3k0b$3owp@@HEZ6?()AY-$D zMX0qa&S>^bZRP{>2IQ7-Y+v-T==pjzu>)rw@}zV4aO!_m(LA~NZTB_(AiWg>uQ(Zp zIau*2&&}=j4o|rE^u_k53WW3f(rLHNsol!j<6(m+UQfDsi1D|nYYLU!>lMB~&o^X0 z#CE7S@hk15gBN9LH$237=UiB49py=gR7&7R=f3wJ=%1~V{rTWtu$}LkwrklFm*7_2 z8*UFU=Ux*=r`hZ@?gys3=}$(oYDw>WnXOLt%J+FPed}6byyqj^in*6@(z&7Ai(&pv zU}VqJW@{+(f%f&i(8!nSL#d;g&!e{rnfRuYVAT<$b+7Er)KN!A^L?6=O=DI~{68X* z8_y)X@=^zjg~C&_3O>XKCmtaweXY=*2MdEXSgq`6fjdmJ(}@iZ(zO{@RNt4Eid}=Q zB^dSQUxjBM4gxOf9+i({LbI5IA9zXB<&QzR4!Eik!o?rM`aVK@jGXwR*>4i-QTMXT zvzs{;#tlB=IWD>q3JSem73=a#Gd|)^{K$E`QjljFPq+5XjNczgUy{HheSfFU(5RQ6xO;Q#!r-VG8J(AI!r_=B{ewcouL z4D4*?s9k}88E70R;ol^|&;;`mNJ=E0!3gjX!F!}cK3DkKsi{l>L_}MmmwG~28WeAK zI~8QSdPteEw(%9L0{?ZT)=pL$qwiK=&Rx!IV?^m1uHMi6c@A7P4jUME7xE(0MP7%~+K z8-TDDr#LBi5edYmt9Sp&_uI^$DZmIo@)|we5d%O~zc(*e2TwDrUN?EGW{!Jw!8pkG z4E@M@{x7A$KtnvLp8rLhhfs9rhsqFG!JYFor5W*>p(-+{h!I zt%Qdk^X)vI9&h!YKb689rjBnoR?9y`AIh${OcCd zODCx^2Ca8L@a|}1qK?!JDOW^G3NjI=`cbs~Tbpj4KsxrS4-_B!?Xwmdh*#qGe0X>2 zW1edm6Uc&j8F_oJWth2Om|aCdTJR4H)px2iayt#ri6<9W44p4a7#(Ob zb>Ht0;rotfC(t?MLB5_gW#1{j?2mPYlQ>4*?pUUk>; zDfIGS-u}=&ffot3n{ziw*KSd(bxtnMr;MK62;Ia#qo^zati;T?`*JA^J&$77W)D%T zdRI~LG$+wA);v(yV<=))dS?0m(e>WpRKM~6__4EhW=8hT3WY;u$1y_LC2`2kmSZbh zLdnXOI9Z8sY)RH3yBs4koRiVPao*qC`!j#n@B97ZT<5xS#d+Q59?$#nd<@p$0U}M# z(y2kX*U5C!IO85yAfYJW^b>Mwenv?{p8MYKCA$K5%K7;4uI&BnNl%KbC}HO*R1qpJLMXErN*}JWEFruQKu~~Ic+X`ud@}*;>WJo zRsZU_n=)@H*A@!fUXV?5A;;uqEskDdt#>T2{swpyrn=0-UUx?6!*dchVY0Q9_6y3t z7O|#XK3MA=@(^Q|3-wMw$;Bh1v2c-@fd=3WI zKkrepG*n$N+jaTp{r*G#AMR6}t)J*zWkMVnU1y&7(KRG}vcEIrR{5aH_O*;!jw34I zFMk>E1_9n)Yf&Iu%VcD;QT))@*@{7FUNpyzlX!Kn(V7HQb2Vr62)Nw5=Atb=2dARf z+%V>xK2X8w`Bey2oH0K2XiwiK3>j&-=2t$LDdx zidpYMkpuP^yewQ9OPR@@{krU)G}8c!b0C%UQv>x(8_YZFZ}RAB#1EOa5fg!-`c$^1 zxw~o>=KXi9-~8xv#QqL{1I8B$M)eusRK3`HGD;DyR@cB0nny1M2n3GquDo=q9^;^q z4QQuny|6hB%)sYE%9#|{jp@7d&41xF$fIO5kUF;x*~Lj@z+3ekxs8Wg`j_-5@2tAa-4mj- zE|}fC+xn_a_tD0Uz~}QhzX&QXu`<&0>~WTo8pxkJPv`hW!cEWKop!Yp!Fg=(uP@qH$_uJw2R0J=9PtvaJ8y6uyHC#Y z0R!}Y*{!=W{e@2G>nknJeZjQg;qRg_Z-EMys`mnF=^EHO*SnKf9~uDdgdk`tKN7SWEVdzJ_#ezoF*_X zGqrzvlH>0n6N4#M`|@X8u(YwXVy`6fwbcCE{nw1l*fka9RLiqM3KLkCO7&rAz@cX0 zSt7~Oa=soSO^=9xr!X2IowerqEV)>w1bN2iL3FE*ASfW_2MJ?Y^hlU?NAO-{Mq$ zE3brklzh@F<1m|HSL6TsvFCyN1BEnCMCjAP03MG9 z0To@=M2(1_C{_X-`wwI#E<3w^BR4!K+i!EKU$Ac83#~RH?^t@pp7K(t^|CdAhrKw2 zztOt4-MNBb@o^k1XisY7+#736C(~f%kJcb<#NGVqZFQZmA405IM&d8h$eShA&+YKb zgFj@2^85$#3{jWUZOm_rcXf6Zns+l#o zQ;m2mX?*&zy+f}(ZqP4hDeHC@+F{mcc^fBk%y$ZY2QPT;cn?G!V_aj>8GrZDN&Z@$ zeCiKbYMM`6!pj{d;G%Ic<|q%}fZ&T;lDn|!>tN!`8yNV0HzG236NTi40qS zzQm3=cEZ&9?`Ih%?l@MrzYxIp;iic+{-OoMD-Ut(?=Nnz?KC&eed0Y}5Bpcrwu^s< zw!*3m>@k!qbe%R$nig*w^&5j4mBZ{YZm@5`}G&BuR zwu^N}w)R>BPcpPV-?_+(VGNy+FL^%1=8k^%sbe$mMs zer9n>M+9k9AFvXs)lN0=9w1K{z68DKZllpQH`44R=e74RnS-RolKh~vHVKIQunFD3 zRm%m+xzSpi<+VqdCezo5tQxV;7lF*!QJ?t^-mL}pWMu%z0dAWpTe$vvS zR#3C;v2$`x7jgpg>6W@Ocwz|Nz<*h1BGVYhkfJcZ-D=Zu%S(@;{V4b3DI?T|0QUHp zAcD?&JmXP2P9b#e7Y)UI$MaEYsepiUlA_JaNU2a zV?!??GOJlAdXJ1M!(;#msyPpeKf}`L{4;a}1jYc35@?eYxAhIzbIldx^&uO2E9drs zPR>fEq%4TobB{bKa|tq1K$`uTC_&u?(x8ur$ujU`Kj2i90B>LU%K)84uG$i51sDnH zVJiavK&lFWy9;jh27h>-ML$96zq5_bcjQ?**i78+KW&p#HOW36Uun93h zw}01VvW^9#ay1nS1}E0>Uoosn)^61|r^!3`#mbU8Abl}4J-=l9`ujV3fC$@pIeu^ho_~!S5;8xA2I+4M z7oat&7~m8qDDa_=iuTJ}pC9m>WMg9_8B^&k8WQ@Y0;%1RJU|MR%i#M0%B`)%VCPx@ z4`gUeTv#oLPQQvMgOJ5U4XZzuNM!Ig5k4xSD2@U|%}e{Yyz8O|vI4IeDPI=OH*5?!B)5)phhlG|W?XIYU2+38VMhXuW}p9BG1UUOh`t1-R~D6> zB?>SK6-iIM4|^ZUoI1c>Bk||!sP~sojajdgK;Z|@is)wezf#im&EVYsob8&0PY)o0S z4k!CuTP(;>i?~J*pI&N1A65&$Vg4jT9!o#2 z`unTjg`?s|HIV?o7`$nv;Tm{_(dK^HgW&R*j65qXHIoSwIz~gM)rprt-36QzfPd9W z(2}8vT1gpIlooX!sjtD`XJ1N3sl;j|Kk5%d1g*$U%(qK}xmdE|Q&Hb54-;u@vAg3HVVItdbK#GLS!%jV^+TP$HRgA%|NJQf*^;*oUy0t+sf9DbA5)# z$p&=t)9i*a{YxHOL4ctdAV*-kwz7iS5xajXgTHRaxHL*4kYj$AUrMt?U|sVw$D#5u znR1Vj(3jp9CLWHrTE-3wN&N1hB0q0z8U;63q5Ma`=4st?aBum^-VSZHGDl7$x;u#S;!;gv^QR|f^dnXhHw`>fdR(V zMB<)7UHeC~YJ9&N%|iIft0IdrAW3$N$=)5hoH3!{v5UeBLVH0BbQkoaS2p7D@a6ue zmrn#KGoM!9;+q=5Hh)2rbyAh}BbnFvf?2j}xP63m7&;^HuA3W%0yl#*H9 zNxi`W4Q$z{PCQ{;B99U`a}a57t3~zzB$XDNZz10C2I6#H9q_YIMHa zn#ThK<)tZU=8KRbCJGVB0$%_i(_L6abF7nQJnM&)0+5Ja^~y$fBvZf_Ha-|>X1iJr z;IQ!|N<@^u_-|^;zetlu9WPCQ&_NMRXe_O|i(yP(_>a>FVh@TQ;VBnASJ?dzgcqKX z^jzQ(x=H5s3-sgg_rSJ#GyeA9?RzI3uNr#&if|4E7P%|+c2CtGF?g{HOc)30T@_-4 zF&j@**1jgh5x;`~*MA^sAn0)~c@C7v&vGATIKZ39Z^}EqSmq_vOYB}iD$)P?$mmXd z4!(*O1lHKddu|#NH{HjWV8c?#8*KHXqR}mr{wd_wTg{3~QpfXCPqugX%|PR1N4WV@ z^rO^Z5%U_JnCMczSZ)yS7A}L6q94dpqqBfp$Ca3Cl>G?Nm|lpum+KOsK(Hu3OqxAr zT3C3=NmXOrY~bbc=Q|7_w;Imy!Uueatfzuss1J!qarDN z*LC0*4w4b0pP{g!D{NRcHRsPi5}Cw1&v{Eg8ykKbrUN<<##;iQb70sB9P^4_LCV#> z%;~75UY`h^tS(Y)@(^x`8ZRbi7kqgjghp|oFrN`v16$I$gdGp8V$U|KxI%D_QDYk9 zj&WK00$Yi}y0$Jwo=jw?f-azx8sVb+JK zt51Sp!;eS5N;*~Ful|~)^G?d%j%jA+pmy;n{H%~DL_|Yb8?sS`0UC&29Lk_5uhS+S*3 zM~)8RO=eU|_|3=lsxPh>#|l5xZP|G0vA@W!e!B~+t0#TK-LkAq_3zWg7h7RmUiZ}P zEh(BUrV*HzD6ApK-a~w#>i*=^G;tq=3#_0SM0!l<(j~9@^Sa_qL#<~`yXyojO;tB< zH+N++T_>uIjoccttVS=8A8w zS@OO^=*$8Hh{X9`d7~qBgNtJF)?Psl%hVYp$XI-V%KiMfzXlu^{{Qrd|NJ)+1_jVL zF43HX@RtdZrNRw5xAjAKxgqYE6G~-1@`dPB$R-}0MGgWeVPX&(LvyvR<&+985Z`gB zvWyp(p%1LRV@&Ae_x%m#UWx-Xz643UzwwtrCVv2xODDgRQzgoSDd6TI+1PFG-4OdhM~)a1OZ0AmWhuZ(4aZziuYXto~Eow zwX)JqKxi|i{2y*#=FElQ`51joY=v`5$0Yh{UZRusJK*#zHgRH&DNL5BRksE=3W3RD zD>VdPJ(1Lm$bLE>?(el)FmP;*S?t+C+@6MFDuO=NUA;-rdTq_u;frKdjmd)#oPIgW z>E=A%o?-`2mV2__b{ausa{bTptfJ>xC4^kChI15BH;lW*yya(U>^prDV z5m4%NR<3$n_q;v)pxdsJAwj<2sWx&Uwo#@`k2`r;IwA`X_E?I)cxRPP$9cJKN6t1y z{#mN$Pa33*nC3LcX(0n!cM=u1%)RnM!@4q-3a-UpzS~yF{NAIQ0P-T&|6F6lOG2L= z?#(3}eZX3GA!xqri&h1AyVB_TDeT32cRoTnw2@&^dUyaK>f+%=T6J?9tQEUkL;HxX6Zi(@=iM$ehJ)Rs|3E~b;_B9?ZbUE| zSo6X;o4=Umu03AOn74O-4)Veuu3forHNU3cliBx_8M{rq04#!S5j%(^1W3VRa#x`H zO7xc`+sVuv4!;M!HbZ>El6O3!Y4LfR+R9L4T`MYDU#7g(vq&GgFAKZjaTK^R@RI`j zH$iwh0C5pMX4^-S@uPO4=u%6qT}KKxF8}eJ+P#J_F5gG4c@Hf&oD_2HULIT3rPfD$ zgpz`@6uDSGm2fH@?gOI?4l;wkZ=$``(rjR2qyF)N^6CDm%7?_xv>(u-`um*TFzixXw~ zLTjt5tKd5Z;uP@0z%dX|7VO95pze$*tsX-G$dXH!=QdnoZ-=~$Svaal>S_Cms*fKE z^!IsOiA}w;A07sW=?_%z1A2IyBVIr*t%I}AxrQKuqtSL=k-Dm&$Nq&@7>8&mFyPvB ztguixdsTkLH^vdG~@WNfc1-yz8MoFIn_ z`GD(zfLNbnJo5o`Hb{^EXxw9`5HB2&H;4HBW=S=b+EeZB%EELX3hTG?3YG|^MGki7 zU)zM85iA^i5nW=K6x72}>Yuv3)$poR%|@q*d%cS}$CTbs{CCwmsMisGnyI)OS&fez zpn)F8f57KXss2Iy5kITCxI%jenmj&zAXhUVGWA&gJEONd5u_^@bHA5Ibm?ez$7ouo zKF*52mZ=`_5pFz*G@2afPTYDa|KqIixNxjFuBKxY(=NKG_UB-R1FGMNd<8laN83TC zQXXb%8%4=y9$TIi7sjN8$f8_0yP&@lj~hUj$I109yXn|LT{>_+>8aHCfc=ta&ZBTf z8p8#H1n=YNwQvCCkdu6JL%1O>Y4tKaxdnuF!%qvr?0ZzO2Av5TfO)-!&g?Q!1|t&@ zm`MF*{zko2uM*+PaSPRdAmP)Sh#h&XAAc%ff=1MY7sWLdmut{&XZ^C>0E+HQPNN(X;j}dNZ&q8_n3LAqXxR_SPha<-4^uK*2 z^#@Dj{OgPm#(N*>o_z`jQvIYLiP9l|-qt$}KbYRVD8Z1zv^2Y@1SaYi+ua?K-&eg~|GL`=fH-ddqn72oK*NDC7`?3wHucGNwW0BKjl*2G zDlnwK5{RN56pY=kTsZ#@2ZEjR=9z|PxWqDbmfm&9iLV`kz=2~~gkc%p4N#3_%Z+jG zfX(ZfC&g%Fz~`AVY1+$qd|74Us_W>SoDF}=s^KLX2gGTZ=JxlRThG@>=1?pMOp88r zsd~-4#1H;~ZUt#`)|u}Q1SAP4tiz7UqVd@W^>p38XwRXvkyigowWSrW4V=12<=b9X z#|1<#)EerfD2%GJ?)mad%^R;F@&O#Ckm9=+};G%CBov&;?lOK zkS0$g=1dB9^!d|AaQwRNak#4pTv!>arDY{9)y@sJiRPnjuEI4Pq`Zj*s59|%zFD-0 z(nnNtsRuvp5l>d;0dgUU>=P+a(8z-y3A+dWEo`+ zJqxVm0F1O{aH;}v6>-(E)K6F3VqWRW7vvqnms=a~&X}2rT6q5Y1Tx zS46%GU2l`fqV!exN|Yd55}#kuWf-9}DoNlF$XdE0pQ2&Q05FSWVl>m$+JaMq*oSG- z7g_Ptz=^mOc~Y^BMrFY8(g@Ik<4-OEOXFX_kl1?YcnCW{u38gmg~8{h5W;_hZn=R) zqa)&}7f9=W5&<3t!199$1VPX)CNlhqs4A)NMN#fYW!ivK_W$T$ar z$ciia&vf|XSYk4A6=TwDbX0GTak3^xC7l~4(nwGbxGVN+o!^0hbeb(-TSs(Bi&7q) z46WhG&O|hWYwtQbbM3HnamVNrb1vj+!LY%;99J0tUW{i~C?g?uhy3{z7l@LuSLTGD zFCme9>xU)q5eZ#P#Aeyx2-TK2N!!i<8>t=yU58=KL({Db08c4f)e`uLDK!;ag&JlI za%Vl)xHO23rE}ltg`YtYIc@Ulc!S2Kk8d9CMs^Xoq@HM`;)2DSa{V(nTYd%bduB8n zm`YZ$QR5Hhj({vjGF{x2fYpkt?Es0t>hkLtbpGeO2d_Xq%&A&h_JH~e+e-*00aX?b z28`kt7$k50`Oil-Z|-3qY6XvWNr;vdr9)SiEDc_e5&y)bg`T#oZ>v=kkAv;kPi}J(BrXC60#(TEl_M)$ zK_QOnC<2?1bWla6{j|u|h>QR58x0@H{2LIacM7v} z<92yD&Cf`%RLUu!`%ao)_x5pn3ExP5yRK9*i>1(z%s&uiqFdR!x--{}Ya@lo&5Ls% zvx0b!{+ho3xPNO2Fp#y$1IW2sx>l`Mya_yD$EWaa!H;6n@b|sb97I|0CEZ6+ZGx2! zM*CYDG#QAHkbzD~qR zfc0;B@)f1e@xHlwLcmDG>DmPKV97aMaI5GM@OSn3a!KMnzG9s=?~dQq-bYkT5Ep!b z+l&53**X{MScT*<49Cb7Dk9D7vLi__I7*pu{xRY=EaLRI{ z!V#EN6khsb*(p!B9BdYb(QIJC$E23vM9x(mlq0pL-%T ztyQ}@NQ6bXqpX!2>R^u=UL5d#!ALO|*8o9{wzKKymSta+*i`dT%;eIb} zML6lzAcW2_>BFu5Rv!n6T$`d>?(>if*pT-K$GwL z`%*gBX0+@@wU+?~9A1}T?M5ai*)>9^47QcOOwGC~_Ndw;!Vp~&C`=8X>{*q}jwW|_ zIIxv=a}Y~pia&mzFVO0AZU3(Ua?C9)H(E!X+f(^d?gwn=VOc5dIbNuLBT|1@Kvk`x zRz6XWb1jn8+Dk{^fdmL@6V8>-nEu!FS3--bodoP?BN8SO5|;j2)Ous+bj3b_fl!FK zo|!&ylX_1u)LK3Y%?D#X1 zJ$uupcG|)=yqv;b-zdsaAlfF+{OWtWAl2!&VC3d!Okr0cc{nFl^bPiJ=QWCs{IbpLLHe4ew)3S(nbN$AbPcv+P2I(&2{co>`}`$f@o25}rw=iNCv=V5_X zMkMmlG@)A1Vl1{WVP~JNFXSg-)>}kweBmn}W%zie-&>89IeYHIt&mrQ+IWQtkDt2{ z-iR(51SVxpr3Xc~x!B}5u*fjrU%OQ&GmvmFU+MSpN=8(c+1ST(>ZbmKplXZ}~x*MIjd|Equbzu$Ak ziy+B3<2eK1nP;#KTz1xZfh)X;4jDP7MncPtME>*Iu)r79qUiG=5vkMz(obD%Wc`f$ zr6;B&CSAYbkS&d>Aok@*wnzk{p{~X=0-RDYQon2-7~ovEc1Rk1#jml*N1H!ROfrNG z(y}0LG`VtuW+C~)_9o2+VjAJ4x1t}4BWwmebXG?*X#)rD5e7)vh;0phvU9r%(n53!#!Ec-xSKH2o^cFzp1W=^i9fKtw4-0UYJaRld58j%2shfNg52 zX-Mc*mEIo!p(A3#jD|YqAr;Q4;#86TcpAvf&zwpz!cQvr09DkOQPuDAeS<9~F1b=a;54XBm643=pGm$VD-QiOf^ z+dJ;MC&7h`>Ht+=7qhwucJHVYbylgom#D%W%UEMvIkPd|)hL|W*YQY0OSFTSrNX6y7|sc`ea|*XWAniKpg-m1yH=mUK|ut62XpL z10=X91fx42Pe;i6L)u{VZnOSN7Y@{|r7^KYaiFn@{j>D>A0)rPURY z2EABf=i-OLp2ZnOOg^exj>MgVqxb8?mQB`N~ z;}`7}RpjS`QR+UIrD_;sTY>xhtVn|*V;gB4F{}AkvR?^~?bE^gx4LY5Uu*ow2i8Lh zTn)3-jrBx~ZB#Z;r4cT~8Aq%(4-EL0uzGh~0XbDng4o@%%7xdvk00F-V&QvV7W!~X ztP_bBm>zIbRt&)n9TjDVk^0O<R7(@Xo5 z&1tD^H7+HJM*WNk#U~NzC(kn5Y~R~>v<@VP-x~Fn9F)GSGk+D$z}U?GmoEl8H_-Ky zwjLXlVEeWC!vuP2KIpoowcD9g>JUXzKieQ&jKBby8UI>-!@01_0(yyabuEdQCQa zf&{*>yt+qWx1mFa&$IN?5@-(QO+Q0)fqM$(6`ut7B;h~tg(f-6OVf=t5A2V!i+`l+ zY?zoRf{&ms?iGekDXBb@e45OAyi;|jA-O->QJST2x6q_eb^vG?v1X%q(eHaZ6K~y) zT7yzc#=W0BH%NpR%f3&jTxkzQ{JwuGqIW79&OQ;rF7|xh^2Lba+K*R93C`1`?PY9X z7D?iqWVwbfg)}|fC0jZ)J1AAvj9Y!b3$38=64L~hpMbQ(Qo!Bf{R}#z62TutEtmdq zk!MAS48^{)SU>Q4(dAT$ATX}Ux*6J*GroDzgK_SmT}W(zD$=4;EH6Td5XVb`w}_MU zEmW{cto43n*{MfyEd`y_s-yP1cl=yaM^@$3{mI%V z`ZX#c^}1b=3otSP>kVJAbhwq0mp*$*vfazvnLJ9c6xE0|Dts=kUX{rE6|~pIYb*zu z``K7GD5X;IXV~l7_`ld5YrONaT7i_O)%aU@$?5nD)%ypvSVBqS4Hdcqi9!3jDzijYr7eG zbFoXEqGp#D!7=o;DR{aEidUIF%8d5sNJhD<%6RrTf31hFE}(AAu%G;Xz%^=fm0ex@ zJs$_7gnjP`jUBv~?aJ5a>L*!QXwUG`QPc&njiFEt3pV!>K%4t;*y?a#)Sva(8G6I` zDg7y+fOSD5I3oJyjE_>V(|0GmoKbJSz1}OOGeCyV_=W6k8hu%AN}(|gN;X$taAkH? zi{mh2b}&!cI66&x1NBDX#ZU<-FFEo}BJcOM`@SXio7R-J*FP2iB+=03JA8Xe9?@;1 z^7|f!iy-E@yq)r3ZP#02Yxm(CV-aab3=Q=vV^u|Q953Bd$@Z6O$2C>MXLYaIP+OYg zr&5bK6@HW-k7=wy6>I8<7vcc*i`QaGZctwhY&m}|+9_JqyT-HT@9{<~AGLa@^+I)i zFSGVDKGfHDX68TaM&-WYTI{_&;@>3Kc+`ITMPw()j7P8rlj-4ume#yKY|K5e%ARk{ z(jgODm_%?yuy68nbntFN83L;A$uxyg%amnYt8~)U3F!4rHm4Z;<@U>}?pyVD%d`53 z!@Y>+-EadiC(poaPzx4262_Ok3kOoPxG4YH#UH(6Gw;(M+qw;oE_>`G+4{3kj09J) z-{D=(14&I+%s@iUrB@I3C5iK_DiWT7vVoY~8^q}N{%aHNBl1C^iH4A2h$&eNL?Vb0 ziH_&ABhkM=R-pt*?!}#pBUp3I@|;XfOpHY!;&dUT`THcpN-WpRbB>>GkZ{Vcq65mL z^-G5#67-Y6sJ%Ct+YnOyu5jVQs5?;yfvHFRdEuD6c%(S|pMqxCnUU`tDg$V$#^%9Q zP69syJbUGc&kX_BdR3(5OasxSYzzGW>pJ=e@(xjs*t}-_c_2T!jfY5GelwzTk?PqV z_Vo6@P)SJIE@K%G_ioYNKR)&aIb>wD@R+w{H)94XIoDL##!UQ8q(M|F@7a4agreaPPY4L8gL2it`TvFzS~TUJWFj2~O*@!uDMix?*^j?*MXK)?F=xB=Rlu~ zm~A*}|BiCqo1N&fQ3~~r^+I2=S-%ri8a8-^vlz3a%b@6335agcc|;ciYsxsQ*aK~Z z#_kDma9}x=huD1_`j0j5-M>FimGN80L()8!?bV!z|Axo|28l!N{GrZ@THoPEf^J2? z%oy@;IaOk?F$iSblB9}1j_Q-u-qY3Ia3fWJ@J5JvpvJK0a!I%|U~ZWEro)%weJFHz z#IN7$Q@hI=89K&ssga>X4VjO^lmiQQRNVQ|;ys&YgJ;K)qE&Z)Ui^gJtz`;n4fqr7 zoCULUg`{Gp#|>d>JNjA~h8e~x98ail-pix3M1VVDjgfP^o-AjzZP!=!5_0juvTRu_ zzrC}-X2@nPw-n@?SC&PMV@0*5m%>QwhzX=)sAa$KUMT z3%p)bcC1SXUf9OJH%(G;p6d1`7bTmsZ6nha{((GI6H>?7)9b!}LM(I&S|EOjT6p_1 zVD&*>f)M4W(MSYOIK`J=tIOP3vWyQFosAuF;HCn&u;dDZ#3c>==023 z^g%BY1hHv0Q$!lPl1G7HB3QBwkMDK<5LHz_?AozErAyRi^12(*DAP&wQ?~${5ee9g zkch6EnpZsvxjUeaZp4e9r{!#n+AVsgJ%tlrcbEF~Q`3|bEw_KhtP{ANV|Rbv-P2T$ zn4TLc*Z%e~f*UQ=l+vzyOsldX^;cMjJ1>?$OU+X6hhg*e&okk}p9^3A17QHp!*Y00 zl^*=csbh3gjo+UYJOkM!hiDwD{Kb_4fWAL&P9|!+PvhetO^zdzd=fa^xv1GO%g?M3WTT z*LXu!$w85I?mtLEk(yjW~onCc{Mf!m0&`xNawZkO!gf6YMM^hh#?Sg6yC$Q##-rcK7G(`MbR)Xg(Pcu1WtQt;CH~kx zF^4d65;kXcjQy^gk{O?GG@QkWH#&J~Ue=P18w+36MR2QJ{`-z(F-3?$(U`U0gv>nq z5|vWilgl3^4!WsVY1p4(#yxd46ScHA4X=%T@6V4;W;C>DZW5us%X1&35*RE5Y3s0A zvQQ<87bgkOcu0sTZQamXMUyZwPntKI&_=RmI7;L}?NoaS(d>SP2rz;v@0(6K^6D zlNv~+menIS-v{oR|6@4xf7K8E4{0Hi=0>M)BK3fwiDF|iu%P_`+CDyNr9!~?qD!65 z6p%TTd{gySK!hGSP9LH{4}rK=l@6$ufk1Umh~GJ>Bad-io1B+hZ9CWGsT-t1LbX$A z!GBnmqXTID0`)PuSM&wOCm-bidx5Wwk&)NYb)4myJAmIf;2!kTf${PiLk3DkEy#L| zHYewf;f%(GQVj|Sl~9uyXK_^Od2IN$VGCii5mN*%m`geWWgvBlEF<@3)F4~ZWb#BM zg!ho`)u;{0dZD4i0VK?v6jJm@yi)@qq(!E}Mfs99dBSA8Px1`Jn9A9Bu;{aGnKVKY zp@}(2B`1;?{R*mMQ&J2DW6F3RKmN&;s|>;r4cZJUjKp@}SNgA^oYtlmbAf@9`AUqz zV#P0m$E3^|$>^v=^9!n_vix=x~N8)aBBs_Tg_RC(#3S~}FaU#tmoV@vRym_ekE>~Ac@BlEO{sKJ<_1QEY**6T{ z)1nuAKFGFAeNa}M9!ndOYa?EME~7kuks@V=8K>1sN5ls;j3I0h=W2CG#uva7vLD_y z(E8sKtOy{@BEA#3hE{64=B`Qr2M` zHYxf@;QR&l!sh67C1A@DMIS{jT^rYLr;yQ!C)+mc^8J%* zDDhvw#DBc^f#);45)2&y1&~D};$-uDF_HMpZoprLPaedg2JfrZ8l+Wdpr82aBa z{I9VD@5$7gS~S=8Z+$ISC?(7I2~s%>83+;^;$%2yB7-a#1PO=m+aIT(-i)*F`WAaH zTKx=44u`TlbnJ|Iy+Vfnt|#<~9m{x5BBbdG0Ru35_I3@6?7g>^x#XSZeL1Grtbrf@ z(!tf;;oTAMj_)R@P0N*Q_(t!2D1@~&rrvc;Dan=e1v!=f9!*19q2|5q4Ebi~rGt&JB> z1jVXbFYWQ*{f7D+*rz=A4z^Dp7L4ppnQlryCb1&-(WY!-%b!uk!3a#=n4AGrBq&Z` zz_?xUC^GC*lbwOg$NcbbE_F@LuiidB5a7$N-npMLJ8HOA#b@xTB8&-as(Ljt`au7# zMIpJOjs`IwVxY?6!vdo zxK#C^m+X#+_@=bUooMKDlWTnPHmCKaZE;=|NA2{>s^rY0_bs9sZ#t$Ru=|#;H3O2R9fc+#L%D3mYyth=X5^89RyY!`0wdD=Dm@RHB=-ajb?+oi!IX&0_-lg# z-i`WXTY>LLONR>Ed?SAz*4&i{8#>v)`0Y&RX6nIKGSgR1zDDei>bwUagVru@4XpCe zf-A;Ug=LCjweUN6`&wiWN*xg?X5e-6-zo=)u`2Ue)UkzjS5aQx7}?9J754mP&x;yC z`Yq^RS>vuzE7BF|7y3Q4LDuFv&&y;}sy~+h&cB!labg#kH&>g>f~#s}8$l)*>(>c5U2cu;X9bmU=2F#(pvv(adJ*VQ~zBo45Wsa!dR#LRpuRESP!yL1O zxqg+JMJ^hi1QBVuz;mx*a|mp(qgzuQymqsFjEPFYj^^0Fs$bDZ(-~EQif7YgaK1xA zYq&T*3Riffd{*SCj#!$lZRF>`^VmFWdH!6Np67wSU62)o7%)}iaCQ46CrEh%;l^ar zAmHLvT-X=l2m~4)miV31t9X^=UF0LD!d`BQ_TT9rx~!g%PrtJkE?yEXydn4G{2=5y z=YbYEtAp+Fx|eN@kdK?RLFvCI!@s8_uwE?@Dx+yB6VW<$BWV$OTapU)N(PC{Tp!4j z@)8zb6JX^JDgSiI*N`e4UEoG~P2#T8L=dA5#Ku`p18#J3N71hIf_<7h%cnOVwx7Qs zUl?ev@kOvkDBu;B9OGpPwkwXzbDJM8W^iP$t(m?n!M)?>)Kmll%Y-;&o9&8y#G>)U zd(S!Br0Ul<-c^G5gJV$fM}J0i-8&qe`v=mW8Gaq_Qe$r$%xavU?&()y?xX+HCt|$_ z`8`|#`}r!4O6ksWp19shexp(S?&7WBkP&|)nYZN)VRGLK+xQ!Tc3xv0kBTOc%5t#< z8g)~eT~~)nn_gWs2~EFlUAGxpya0LcajXOWWJtu1pQ>jG|3KWHDlX^5*Z#^ecyXWV z*^{CHm*HyWPb=lWw&)SJ3maT_=&{>J$#}<7f-T;DSv7!O>dr?4vn8K?Q6Uk|!24r~ z2jO0^#AAPi;&8C2H{rPUjQKXwB zFVFHl-9s9}!xJ{iCuQZ~gCYDUHp)E34L&EXDhx-@J1lw*~3h*1ZU<1r#sE*tIxQ*xdk_0CU^#f;)~VxdW5j zmR?g+F^WMYV;uf6V1Ih|9_L+Adn#sLS}+(PS8soBFsXkD`^4U`zFzMgacmK5wl}+S zxhwee>z1xI zCF;fE9P}s$H@!oP1DD8U*IpS1qkP?yr9!^@QZ(y=9Ah+R>|oeA!3uNQ)#Tf{2(3@s zVO(Z8k(un4quaLj^LJl|-1r^l`WCs-p>(3U;^)Ac3YB|P^{j$tG9kb-1llwIP7^A# z++sDLYHd}1^z^gc*=ls~y75++zlm6~uif7S<J#TmxFT|BnmZ`SSXIzitFCN0RyuU(WjzW_k~1!^Z0)PSTTM(77R@ht3@+5GSdq z1@^{yrrPQS67q=<2|ab+!&1-=Dt9Ac^;e1&@btd3npKdWF|uV4u)m5 zOF7L90mk({1T*HF8an244QCAcTUlg(O3Owt7t^B|=pmP?LL;ec48hMa|>O+(a*Ur|sA z`?vvTb%Rl*G6S9lz%~~XHXV-AV!j^BC0b?Yk$tgQ3a&gSo$6C*n|ZI0xy`(T9Xq_+ zJUKV7UKEO9b&OeTbR@9nfd24VwKJ|AnOKovICj{{YVf>{r?cKE`uZxF9cNpBw2)bj z#c#ZIOdY}Gonex}O_cRGKMMEt!dmnU?=%+{w%nh9OIj+3!SVO}r534eD8CuNWs$BT zE*ueVZ3n1{%Cpq{zUH@&4G$#mZ+*@Co*Vd}EvBXKPN7{x!_x-4;)l5GZPV?O7x32b z>H071ms4KP6hO!_0d*N`?ueIy4J>jI$2jH~yO5lAZmpD2;Z98#6DT*wKa31~`clj5 zrJ2es>p6SKE7N^UvW`AU|>t*4sw0wysX}WK_!=6L@nUzA!VR4(?ME8722MT={Tfx?V2vxlidf^>R{C zgRq!-fdSe!G$)Yer-jM;>5R>E70;?Y~n^IsMFt!p(>o~DYLDj>KMElaFu^xFby z%Bk2T72r)XS&qESH`RDaOp~&xGEXcBzq}6L!ShlSbRHibmQ_wBW8wcm#Ce--44%Jr z8&-il@$OL~jls8ueZLS0qCqAAF9n(}gEgN=9Ocq0YF{eW%frucXUSMln0b=#X^8sM+zD10OnVB=XZ| z*s)S9Gq5f%fBk@JFKs04s@(C0IBja#oNsR0NK#dHObFA;HycWW#&yo3s!KYz!X(BJ}0UquU_rFvw~x9x@3DyYg;&}LSVgi5jI1MnY%KdmJs@X7<VZZ*k?+qHJLzb2KOS@M>k#YOKx})!G(kwPOWkNBtj_g{f zTA*f<-Tw@E32l=BzwN6qq~aD7ZiDXEo-}HOBdM ziWQf@xjegJ=YMbaql7>G3(2NnW-q%R41Ba=+P(c2OS^P(3#$*KuYy;%Kw(8S=J^qJ zgx8%eNy*_#P;IYK^%qhAAl})_n06oV9h$(ykFa}rMcP$1t4JZgcK(I@Ym7deW_=Q~ zG^a*iO^N+b*{t||CMy{gcop^&Tw1n=O0CSZJg+mBKT1YP4nKOd;_TDodV4MW$dVaD z!S%MTj{1L%>IaSLSaHUXpRW+Mc8)N2z+WSM7);>aMBClgxf?ai0h@lSIX|r!-k8GL z6ULF>YlV#M3GRekI`~8Io1$FW;r9Za)_mckNB=?yaarhNzN&vA>SD0X<-&u+Vro%9 z=uoJ@vrC9=*YEAIHh#e%l3ef6&{C&TlG3q35AKz=4u2|X*m zY{GtGw^^~R>wIw0j&O7X%d+65!jJ+Tk+0`o_^&5M>F&QTibsLFs7 z5uz)TH3_5Fhz*quE`qU3uXAUG+{_7UPXA=L^^TI=b6beY*-j_yU%7*|YYSyB`>E{zq+&=`9Xx zh})PpT^JE`NiB{cG-X>0ct4U1+-FXbZ$IY6awF5cv$Z{07t2%ysS8m~ZR0(GebC6* z8$eGOF1-8JrV;ubRC0vNqf-Ol)x{^+67 zwY|G#kZj(Bo7Wy+a3cE=2Obp2lWjf(IjAqsDSc7zyk>U$jw$)Qu5-D zpBM8a7gMow$>(&z@$59K3rF@)``$wx7^)zVm#{B}YD!b1i_VYzxS+37Yax_a0mTw&p7*^ zDG%`pXHT^73g>&vwR+nBb(_h|Sq+h~)CqgB8}_5-Ak)k zT%nW@C|Kr{epR^dA1(5~pMo+u`4JFk?1u<-R=v1li~OZB52}1wTppn3|3a9RtP=vM zu){2oX551U$0t;}NprxpNaIgi>1e0D@deDXte@^Dsl^=SqL~;ee!aqZgUjA?M{m1k z$vZ9kiVUtQa%!=!o$GRlA1mN8JZNSLDEG{DfoPfWVlw1L(#ZQ)4;7ltnXBRm-;(g# zk|C!bS}4?n;0_xd^EKibJhcOaV*JxxT_&cg>x90LK&o^`hHHU3#VCsuC^PRit}Rwk zq549@9j=*h*;d2i1mOUNSPMAg=s0X-^U^w#ZivkGMJe3WGTMo68KQMG@RzZ7_k(7$a4%05SbPac^GzHu8zc0tgT$13PokTdfnr}m1w z$R?4JA6lVJ_97Cg$>#FJ-?*;?gUxsHP1h%M2jG^0KY!GP<}djg@Hl70s4&lwf3ia-sHlI*$;0fbynCsFUTBc7oUinQ-kAp*r zolDv7yZ$>j?cUgnh*$;XQx=%74@fF87RL0`zkEWkSov&j)H_{5Qw;%?h7tBhhFYQV=jvRt46$kD~u;QEK z=2Qd@`3xE1$y@ zy`3Fl)pcf8F&$bdzn@osM;w*NkY3wr{IPRB*%Z*I1NiUKze&%@(K--mCLik8+gjSU zvvE#PldV_#`Ri3b?~zz#}Hr9jvFMGqVd zxkJ-s3j|sE{q?QtaIMft=cWA1p%1^@8$bMLg44aWF$Kxh)EqK)#{g_gjgGfXI zu=U66D}*VIj;mI6e;n2u{`f3Ryz_F??%e7#SIt=!+PrSjTVDO#Ch!dDM4FGO1-qQw z)pE|G@!yk8*?n7=CSU(}qjbtJI$-CEHP`1OW_*S}z0pKW8yA82t_o$GI6$?e0Sw^M zDwVuJ?yiVX(+PPcmhf# zO@D6EDF6Ik?uXMhFtYo>oQxwZV`||Y^x*plF>T$GMzX$l*M#4$i-bF8LQa4L48dP2 zG-%Hz_l!$Q&Y_#ae<9(j{%z4YjNZG^QJR}HA8i!Q@g0@s#=s7LyP5IW zW59FXCS)u|NInB3uACv89ES*#2H-Y+)kSOPc--m^`CxY|d_V$?Az|LHPN74YqPGjIR@_P)dLo*6BfN0G-iE2svl2*JX<9Yp`Dt~_ zFtbQSh@sNQ{W413d9hD8zy}IfUymKMWw+4NN=Y52e6?1vF)KO#o-1<5LJlsW+O>4qp3EL1r{?Lx7>Mo(XO(XH$E&O*l zE$Y)t?KPWze`ma*MzDDN-aHqeI#SR@&r1zuJj!Md5DW;!86AY=cNBc6hUGUD3+Rb9m*OgZ(Ko4Ojk)lV3)3)S0<7j_?w{CAg7>V52d)lz9e%3! zM$jdfZoO^oz4OHTe9@YSBp924NfaFv0kvJyjyBpnLv`|$dop>>hBHSVr^GZt&q!NN zSNjHc#8tT^@d@1WZqRtv+kK~)#p%^SQBf{S=o#uB_WX6sVAhC`>+_XBb`Y2}?V)0r zdD_#qo?M&Zl1?8gZNFdt#r0{AhjErNyUv_jW~c>H;O@iHs@iANqy`u_)4~vbm^NRu zmOoUCWr&O*-c8VKuBq9+y{nERoJ{3}J&%Gigfob0NoXl_M}cHjb(Q(T$l4D%!5w*%hAhx~Sth#bY; zXMKepRFmm*rls7JuY4j6RA8N^lE2da?NoZqY$Fw9iGlP>hDlxW-W6WH2%o;g&~xy} z-PwPXIY((a4&M|;n*u1<%k?hus#TMxQ_;n_6wDNs#eJMK5DVi8ho@pVS+c9pfWhl@ z2jkm7nv+BCxbq$D{+;|e;c1t)P<+!~zL5#3TiLGNp|k&6cJh_szmSl<&70*HBK8dk zzg}JCX#fdV)1Gg+Zl=F4D%ala0A27eP#BN=Lroge#)6odh}yxKGExe1=IfqmUmx2i z$lhZ!S0)eTVlmRew%%^b?3xOGRQ6c2`>9j9Z#CWsZOt4dF>Eg6H64t2mmNJa@$?L$ zTA+QytWyK&+1I@nHrw8HvzBhT>9CSIcBC6O#ZJs=Rn=ng?Z_Q2%a z#9pP} zH$9Ldz`eXK^d;!#O(}tLU3@RdWpV{Y5MXelm~X>jd_QRIOYp%B@v^4dZl9~_rhmU; zeCqt~gw$ZHj8KYWt3wotiImm>?-J%NosAYyLtQ!n4%tA1KF=Xy}8xAI@65 z?-l&aj&q-D{&0bYCkA59rywdR-UZaEu4W&)ePFj?7(#gYzl7A-J=4@zxI;A)YH>H; z9nTa|Wo@A1=_rOFt&T2@Oq?S5BS&m=%tvL_u7&3Czh2819FKS33AvZepI3f`8!tD~ z2Xiagp(z?%zER14`DB&MYBkBMldM<>=!2ifN#VO~RzIBv*5C zTzxv2R#uPYgybUwPH-}}JdDcb+^jWDWK)0XrklD~upj{!kse5a8z?U7%F zzovW7%>d5FBcGhhuZNAVARIuj2IV%V#)1A+phDqHRQp-mnApLsb;>bEH7?Ysra)3e z`B}u6>MY!N5v8_cqnKu2cK3&qCToZAWzVQ@?oS+kg4HoMt$!UC^|{djJN6O!5L!2| zzCnJsML*kx6TJ1}sg$q_yVqJW1N2m!3G&Sxhzto}^^@A^7{S>eCH)zI8TE%ISAF+< z1_FazVsT9rqwH%7<~yOn!GW76?Cs`KlqF|#Pk*C4{VX$_Yc}YeFWun|OS`No<n%uSlvU+;))l;O-^U~0+rn-W?H|Lhw=ZqAAz5Oeea-V0QAR?5Id>UY2`e2 zwm#5NZO#*9k>9#PGq^-KoT;p0sSw+RQh>{MIP_dy+EBba(Sj|T36nWPBhbB!*|7&0 z;)l-|T?y|;V$+P&fW%1CxRD6Wgr{7=SJXUYH3#HKuMENQws%F8^F;Lxcq!*e~SH;Eqq`?Wpx zYM9Rg>|u*85Rt!?*@WN^KhMda3|-f54(VTfRslbzSRYOSJg z8np-j2-RsbGuJWyhhY`;PxcEVv7yjm z$7wef&js*7!J{s@fN&Mxd07FUQ>@|j+me-yjcYnR8`Y5wqw(NK*1-^(F$>hKY0d9c z!O3MCvJArGQ+F_@8wd>H02WV*FRv#cM-nXDnGFV`yq*;s4j%h2SfBO1R+8F88NG;! z{>1V_jC#Kyr;uu2m1qklJjvRX)!xVqM~Kg~sJU95Xw|fb$;i z%LPoE8-Z9dL?+Z?QhUCEiT|J!#t>Oz@m%HXr~4y&D0hI5ud){u3>f!r-2E}jhB^Un zN%+^-0A8rfS+74jrLfKca=|W8<+ME?LO;mr)w#fwquwTaF;RxWe<7XU?lR*DUL#wW4h@>z zZ7C?t4B(cuTsH{V#_7U75p1y zl6&J=c!g;|U;la1VpHSC&;hNq&@Y`k@)?K;k%4t-`q8eB*a=)KeZt%uFlmO)>h5je ze@3x*UXc6R3EY@qd~(YH@?9)P;V%MEx~V*T^ka|^?O zm1<@>#``*WFCUZGB(Rqqh@nlNnJXSSqFKqvo&E>YVQxMAp3TIgB4;`}HL*W0dovp_ z0aNkMU;)qvpbNvK2WaCvjnMUH^fM-35D)|5micxnjuBJ>pNOE|GrmtdLL_aI`(J?& z^PMnO-)OmF04H`cUX<8(6wS^dj>W`lx_C%I@A@ zNSC7|VJeJTUU$}u`37~DYvV73+i`rypVh9w%mA1!J^dkTA5y(-t+C?@fngH%U)4}J zy3>SrsxQMIp!M%T76~i4VI;x4Fl|-eW{DE z$-cQmd&t-Ge<8{E)x|$7F7Rr9|EP2G>^hJyeYl3@?}R;myGtNGgpsXWdNt4;1c7_3 zUk~DU9hNS@vZ|Q`LLeYub)vg5E<*okxnmybVTdoFbrQ_k$+bB(mIyE}k1!4zm^MBz zNwe&w>Oj@AT8CxN&MWLc@PqqOB&$<~gP}FcYCaVL?Yjo1P$FxZofSl%Q(y^?fV+X} zl{Ed146bIg6Ala+Fzu{j5PIl>k}ZSxG@$#ybL;*>@aMpt;4Ivx)C2#X(mG26;xEKA4a707h+uyL{G;t6ET|C4e1`_-Ad-d7KWZxhU@SQq)8n0L48_I& z9{E3C&mObb+aw5@w#jOSlIL)=~c(85HQ3_Cy2)(A3)vObrw=@kU26Bx5x&@sDDN2{^JP81XZX zL>%J?W@iOt^1WekyGNqCT6mz=Fwm{=0@$~B&!yksT>?_fCOT`}69)4@yMnVr+#}Wu zW;?>j2=oXei)n%b#FmW1O@AT%Pm#cyfwK`a(hs~1Ks9YD1B{8Bcnp~xJn~;7Ih%j5 zJUJP{Vbra^kfJ)uCQG2C1VHj>=o!TJU6vuZWj%{|9y}8IKaYGyI}2)>!N?@BxGsQi zjfZV{|JyBFp{!;pGV861Im;7_#yqC(;TqkQ2A;|}jCrKSFbJUj+WdEcGG2^d6<|r3 zfSaqpXX=tMEC^tMT3G=Xz$}P!DT9BXYMXtOAVp!l6-{R}f*sq3x@L$8fJsB?X}uPp z2Sk|l7GT@--;{>FYd(AF&=4REiA!Zh;{;|Ogf?m0-D*9BLu3N+HC05!=ERbOg4 z>&cOm*q>d1ObAoQe~$^`#^{Fitk51HVovw>&~?L3V!OdEF&YG4L5zxlyZ;3lG#>v- zp~A?nxIm?VsCF3HLhZXy>z{q|9hj<~_^aW|J*KH3dS#}?7FLQ;tHss!Y((FVvmp3k zq-xC&8}+L{Ftte=S4{;rj?ZHlGH}GVin4WM`YG3ovPr}v_p{EmvY(It3wc2)@3i|2aNxM9<;hA zjg#541eQr%8F~t^qjuvI3kIl#l%^nk=teJm3%~jh11!PBMNEGa?cZqIf(`Z2znw8H zboK)QG@E<@tVwPGrtLRdz>zYmWvt`K(YLQ7aoU0 z&o&bRX2o%IG>l}Ar710K(9HVj?z8~_7Q<;y+ogo}i1qu;FdsE84DpE?xg_|z4)sFt zZXN7@elIGJxsF=e0_#=4klAuw3OzQXu{5>~mia{tv23=k2#TZPDY1JqP%?hS2L4@+ zdLHxt-CTmD@-fq2LK~US4UWNnaSPBKmXe-UXzrG00QPixINKI_lywT`Ho_84$Fu=o zBk1GQA54u-ifMGP0BGF1+4p7ZU`Cp5fWbL@fh8>P-HU2}A&XgnI%d=$1!GuCkwr=| zI%ja?%LJOD@L=9q*rZRFW{wyHwLuF(oM8>2wQ0@spsV}lVJR)g#%m1imltLN0%g5r zQV21I2^fz(f%P1^9x`OiLyj1caP`_BS3`b2EaPsyz~l!i8xy#D{;&iITnF?4jO&h4 zVRH?V*pOtCrdN@V%^DirS1jYFpK`-KWfh}5TLo4eJGGLTSyKqiM~lWw4_b%L>xG^Wj+KrAlMCjV?HGy3rMUg%Mkkl>OQsmM4ElodS?-7%xjjK}yj z`w(+dPu>Wx`na|2w&JxLtjhf*xZr+T(h8yhM>Ym1RFc@Q+^7zJY8-Y%b5O@t( zH9X7#C@)LuQw$+i>$)L-9x(V5elU_P<8*KyON5$;`6U8Om5{@LWQqg19z%Q$CEFf= zDRlNneUDBcwCB%~FM}nfXmgq%ShEPF9s~n}UdG-h9CY|a<1~R|aG)qy#pg-w|ew2GEOo>`wUn7{St@H;nb zp*G!pEc<;Ysxe#&!!zDCOTL}7;a1G*18MUE5B?-7CYH?gpQJ^ZpFiLl@V2{0n)<@a z#m2_|ibjdF4!8`anhU&x2*xnDg zIq>lf3Q2{I?BS1|ys49(mBh~Z>DuRXy8By7LO;)f1X>n%IQ=}jyWt6Pt4D_Ki7LDE z(<+bi_lx;|>;|tOilF3MK#{>F`V6%P6j{+Nk%har8jYmaB8I8Z-*f9xSQ2%Pa07M= zHo-}9+IUEYxzUbOU>6eu4tn;^ZJ{b#IOTq(!>{un#2m-eIjzT1k^nO_{mLqWvk7ka zt%UWkT=h@F4juL4V~o+YkDwF`qPywZHgg6u{?u~hTM*{PW2pKC_T2X|^A+d_OdAA7 ze0K&$wu4X5XuNYb7Y?DU$=cOdW{$>uP^ zZ3^*`osi{F%U6TO<=Vu<=J1mh`5}lQs~B5+1=P((!& z`!x3MU#Yyd6KH7T!EdxxxPaDfk?z*;`$d(yRZup9Ae#e=pA!os0*CX1A+GbRc;N?% zXL@a4xBii*I+Gr3e_B_)(1qh;XP;#r($!|%$Fu>*2UL3t!iaf<8ks!GNPA$&nWS)K z7vz-j$@4Q4qHKA=a1EovojJl=ObkJiC4(M<@!Wx>!gvi#g3Fg9S$xdX3EF zItkr9?6OZbG^8tRMvek7>@~megOXJxUTJNRnBh#Eu1}^xbEGk@k9%L6 z@)MkrA`L>hdm|UeQc3RS;?6hC4UIPu-R~*2<$t7DW4vb~}s*BL$XQ)ZN~x=S zjuWqhY~UANQ1YN7nNlWJJy9VM`eTgz4*SaAHB&S!x5t5p zW*;MY81E*KjWINN&x}&q-2rMjv(P{}WA#X( z6{-IeRjQ)NqvR&6Gs*}S~v z_KNkE$(#7Qd=TN1S2LEv=o{Qu=v~68mHbgI*k+puAv$g88`UIvDv8`y@_pTK$Zo1c z*n!RQK`~5Q*hiS{f}@($NvC%zyTAjBe~doFuph$szX^UAxiIdeFH=(XHuA<73v;o< zE@m(E@XMxqYI?{=xwq=8%=W2=B10Ufl{Sw~wrL*~D_}YogD21ZTrp5@PyyJakk2;kC#hMYz(N-kIQ*%QV*PEk%_w0L%#WtN2 z*RCpzIiP$t;M)G9UUioF{N02B1w9-0FHF_r!o0)TkNNDsDTF}QEWdvZvoJX=^@R&$ zdV|O*h@L`xl0)MJw1x^s>*bOm(c%M^{`sjw=ksgN6>{38=X=LnvoC^IW@G;=9RlhE z|C12;|NXBdSpwu=Aev$e!y8f+`xoXC4d!Av9bQh$Tpb?_!}IxAkltGU6*D2%EaelctT~m^UPWl_nlLp$#vt@+q5i`)0*xuNQNL+ z`qh*4U|;jirB~j9RD0oJ!T8jn3`-kd$kD4(!{D2+SEzWsD%RbwWLryy=&>8MjPlLz zOBL+)1^G#A9a20RN=@M>GH(7)2E_v6=4KV!Xakj8!>#A>R7a|YB>n32x$ZscC_iZvI|-S<2lM9aCS#2)33&Z{ch9nTHbYNhys$@X+}NtA@Gs*X81k*4XUBUU7%6`+MOBN= zo;kaP@W-_IEU2@(U4N@wHUCtx{X=DOmUbqfLlor83IKBO#M3$h@7&tqD6WV2Q`T?C zRPVy9+b&;b7`_FGvjp7&0DEv$(gLE$jnu@Xx!M%JN^SYHYbOIzEz70|B73}aMf9*J zPLkk-C;AHN?o%`3ZKphu)boC(){yJvw`n7Y8tf0~FooLJ-g zd0@nc=G;t|qglXiZPJu$W-3sL^Je7d+YJd*G8e?Y9zuI<<3GTNHIf-r^Q^*iqN=9H zZtFvkctv1@B3TgsNS)XwKe`z$u0Ic7dKcQQUZmiu^8rPrPl0n_HO%@q#&KINHq zPRDdeV`)76iX;!Y-Z5ZPJ6HBk%MQO^TMn=G)8ap$cK4hFwocwK@I-ZJ<>SGl(- z(qPFRO zQw}Y~2g`j+gWV4`k}ZpGCrMkJx6nKDr@0zI?)eF1FqS%j#I8>sszhF+vizd0A4&XNi^Zv5kp z+am%(_4UWQ-~>4Ad5a)^&59Y&VW~WRKlslFvH%GecM^R9J^C1_iv3js6KyarY7kE| z`-vc+g~_E^BpAoajLhKM`M8iHO?qU6G5OX=dE;n}kj90A^J2Mo+g8Ae z52keiMx2%GI8#R1HyZ!oMIECT{|}w)myBRO*$L4b;1XJEXyRoZTxuwocc7(&dQFM; z-IkPwYXe}N?0Q#)egY8~Ofa|#(%2o_Br#X5hHzfb=`(5<5On}ju`v4rBL_BV;8Tng zp3f>J{|Mh3|ArttUoppc1&hd6{O-LDPHVCV5WB2n*I(-5uI$;AcYx#H;mr?s#!1SNB+n~K*CG?O6$-C8 zgVf){V+GP=kIqMJ4f$c)Z)VBo&kNjVc&_ROJg8@=6y#cIWO&$Z(>}M1w0}2gtR81& zqaU~)<}_RltY^vDx#1zB-}nCx7>9{U{-h5l?8{DuoQ4~ljx77`jl{(=E<*;@O<`aC zenwjqvvIX*LGrP4Oh07L4AWPUGxB^9eH23E1V~)PDS`4*j)4%KkokdNwVtevc$)pN7ovVJ9<%^FC zK^469m$Sp%lDhq3{U>IKTjo3sZw~Q>&k@q`w3E|qIV>JWN(n9%nd=^fUQyXw_!jmv zV*~MNoFSTa8b&OIN?hg(qG%;y_vW%{IQ?LcRR>naS={%3!x%za#H~-Kdq}^Y;ppU= zP4yhl(tqSg4}h=U25aHZ1}G@cpG$usL)@O*Tt_1pOUo$GSJ@qZghq$*?|ALnUlr^v zY3&ZPRW}x+k2ZcJ5iI=^46SuJdW0ddAjK|fs5;UvVOtndZ5O+&cvKL0DxY?Wzqe%G za)r3Zjt-@-DxJZX$Qn<^NT;uYKb79!(GuPUtl2;TCH3Q`1=hxQpQX~%o>y9pY~lxE zq)m>xM-&A`4cg9Pws}AChAki#`4KX;RTN^_ zT5?s&T-sV(rh59dX_%!WI@6i`wgLM&IjP)p!ZJma65^iSjeQF;iOI&}uL{$dB$F6d z{fL)wAtsM&c$D&<{)Ozll3#)ir|^XxI-AR`xOr)B$Rx(z2B|goVuVjWZM$7_CQ~W` z=6V%X4~zjg$;Uad1_)$ZrkZu|!=WE5do5|^@5k;8?q5xMeHi_lF7XY#2rJ^T25;w?`gLaW?cOw18ykvDhKPc^-*(+^PaV0(0smy~#ao@yuN-hkzUbQSTIG{yT@AfO^=;0)n z@v)6%$2g4!J(-+-_CP8Ezr@SpA7?gcVN+oe+X54uZTe-;ZxXKuGtLd2am1In%lk-w zvw%Iq7z6VCE({k+nC9?~VM|&3a4<3BrQefj5BIO6%YbGn<3~OlqPuJZ)CEUTbC@Lt zP85yF$yMrB7fY%BV`W{k#?~eI#}CpkhuTaRvYmIz>c)F56_xd?cBtEjh7C6(sOC%% z>-8l}xy?P#X~#?~kxE#N)a1u@pwB>{0^I-`4JAo0s^n0Woa*uTVN`w!ozPD zh(raK_fd5+-}ah`*aRbC@(FA(KohxXkpniBHL1s&OP)9MNcnSuWXgRFpNN1<)%I%C zw7}r_duo^@+ z?jIMFM;YoC`SOu7uH%W}3Y8R@q|-{&H@E3NHR;hvd7qCW%s^HrC=`LVBc{TL{0WpT zl4!li%(J(yD*ap`i>~n47I9ARE1%l%;Bk7JEN)(f_?+>dZ4cNeIf#Gt4T1|phJx z0#VP5&$W!=*DbYWJGa&fjzsqi!f@?x{By(Km3wJtZB$wD4P`#j6F&6WluPd8vA5-) zUgSw#kAW4a(NuI8k|er~Gy0orT_^ne07+wQ<{-s-Y0~6T$*}32z9d+aeHpazD=61q z#WMs|sQk1dFn3tY)M~C5B?&a0Bip~Riir@g%g5)?{q$Sy#TS~2m1gEmU}X---}#|!to z)PvOFJz}r&iC$V6AKM=A*hF2?c~JB54&5o+V&6+y@9RkRv3Cg1;|-dYq|37Mm8~+1 z1X(aJK+4UflNKm4yIwp4JkG)Ffi`_QlK}FP7gcByRS<}e%&m~dEvM* zA@+X2b+ttlb&&#Z2V$u!1^VQm4`*<%S9BhRmrb`XXoV_$TDWV-_4dF6krmpA#!&4! z{Hq?4(v=cLYXxx?EvnzP!t>rp=twZ*upEmAOA?qOkW5;#-XSjd%vXp`92?snyUq9W zoJ=S`as0>KBM-HZYE!u^BZ9ZUBz!Juy`JJ@Nh6TUI)$89BX6yKdA)GF^IqRYV)%Zi zZ4imt56pgCE=mXdo4`079c+B<+MLHREo`U$$yA-L69+t^FBc>d`UfOY3ONTBL%&pg zn&q~e#_v+6rWl4eYwR#GLI*jioDlpd{8u!}IqtS!jnv_1!qLjM8n*+;LMvuKph59v zcB^oY4AXBr^_)>^a@B%EJrGI>z~w_3@Z00d%9hUdGm(P z>7__(zj2t4bbFRcKJ{I)$SP^IyF$m$WLmqy_v>wkloVIZ-B{4@I>6S7a-s91Tt9we zob}Qkhzox>>2Yk=>DZ5q4Bmq88wl}!_O>UHckpXI)NW?B0Vn+uLuL#uiu`jvd3A>M z?LPl9?}Zcm;_&MSG3u~d`1*@^+ys0+v677G(!cd;EJuFg(pu->;k)k*Y4}YL$!-FW zplfJw`TS4Th>1!ab$-~hnYFd`Wn4D(V5(+UABITlwB zo;B>L^q%>mMc-Fr5~*qZ+~lNQ9&r(Mobo!X4zvw_5a@iDQ%dc`9F~LLm4D zlsDb3^(cjmn-0wFtg_=&p=AO{1Yjqe=rt_PvV1em=rcM*PJBjsbZp9O_^nr69pKMR z6wBl=VTVBUL)>DNAH7y|yewvCh3KlvT($UYvw;iadS&{_IHOmq+U{~zY{Cf=06fx% zb8VvRYhOZ5)ZI9RiLLpLkm~!fqr(e6!l{i75)iK&{CaPveJ)vja&d{e@=R~T`X(Qj zT*Yx!z=I}cf!PkunG#IPJ_TlDc*Dzd?w z)jn6JjO!LbWPBkTk9`;H^L;8#m%`Z;^0(?%{w z0P;Y=9~8^Cn!OA^$o}8Hatixmq<*A&$Aqfqnk*%=wS84T$D5{}dw@+PS9sx05sknD zapf+S=krecCruNh-U^C*KvB%T5OY1f60P{NkNzv^5zMulUAYimTy&d9(SUO9tDe?> zo#T%1t*;c(p!QMC7dm0ywYe&H z6|Y77g}9`54;JIv2}R{3C-EuTm=i-*MlAXUGND=$R;Nq~>aV(2h-pq`)jW3xa*tkm z+lDgY^Cu(thThnuUe2U%M}LJ=BWUV{C=NjXGms!7Rqeb{9jL4lD(rP9)|9%J-G5Jq zAp$q92d5SN{x@8?O%F{;NH;!Oem-FPGRwWk3C(#CV1CR)n%e`$cYrChCPKJT+>VfAdc@du+EY&e3*?_en(YH}or2Tt4nI{x@HCtFfQ zqyXbcBh{3G+2AH4OlT=YH*PhxCYfKqCp+(dXzy4&%Kxzy-;9=Fu+j9$Rrh|i$e{Fx z?>sz(88^&&Wk2iSaeOT4S_H3gn4i>x&llt$a2?&*FR@q>pL!bO6tukOpMP~+$m~p! zO~32P&hWsd8SwkpfxE*zB4Q{ z<4o9=q8Q$f3~d`og7Q_1q11`NoZtPjZ<~t9R@XZ2kIKI>uG#AJ9ClN=U}amkK%4Zy z%j@a2_fGhz7M~K&Nj(nk6#cUn=0Q|-gR;?EV-yu0pthI)HW8>%w} z;|1akBehFkE1wudU&gLk4>Qb@h0EDrV9NadHy*G)O>t}~x$Z3;0?gK>J{N8sO?7Dc{u=+S5&YPu+0 zdj3N6>9AZ@Y+!|@%dsV0dzHKefa+sIyFm8ph%lvJqD2(85|<+VDwQ}_MO%)5X3w%lp?oN5a_$rf zmgw2N&7-nIJs%Au zwiQYFRX?rxhox;nMol9SzEZXMoPPpK*2;@La(vCNIvlD-KdL`q+!R~UtE5G~1 z?M{4K5?@2uV%n}UUW>pcG*VY*(Oz{qV1fjgsvDL)4o`L*4%W<1==$BvbaK$dV!< zyPk+NC9jEWlZx3lM!V_AV@mfV6@yNKqZ7I&j%{GyF zx86tUYRKAb^N6qChb{7$n5OpS7P_7rmQ`B5EMV{H{`);51lbWvy8O<=;f(6#N_Rrl z=(LCb)vb3%BZIvjK13lxQn@WjYEP;n*kcoR5_8o^hb4L_a&8%!pw2=}DQ-(>_F5b? zG#a>_+t~b6xe(#5E=ZEaTXh*;3}mAY@ff>PdrLP4dp4=iUh;ebs%k^5{I_wUi#m_B zm6+|qT7Wk+I{9ua|2oXk5j$zU|3HiGe^WlYz2rQ4`?@UG=_o!nr}1c=0KGi3?h$As zxyYb)J`buUahtsZ4XK(xjF<~8NST~YP&v0ku%{NUc2E>#i4Zaa=ZP{BZ zsqSu2AU{oc!8LSE_yQja@_{wB26N4_61Yh9l(e0j=$;YCFTNDcF zc6=FNkR3N+xfkFpd@a4K!4Mosm0M^dj;0wrVY<_H>^^Qn&AL2}$ZAtljxFen;<^pU zUyfnse|o6rqqsB=<@}XE{$j~oYHt)*sG3B_s}GoYHSueyx^j@hW(@D%5O3QQJe#(C z%MGfI)HKMUYzz6V>$8UQKo}97na5crxJQ{GhiIBV36)$vw~gq9RSd5|bYj=F^a?#> zw>6lj=d--T$_~nIhBJ@Gjzj;LP|N7UK74oYKIac1xlPN0XtkT^^D(?tyA!#B+dNdu zK}%E-o2*}OFe|VvaLZ=kEW;y96se+;eoi_3NzbM zuPfoumj%~z7p4DvUm}hHP4l+kP@Ae?}trw#XBuntM$=(>Z? zeX33qV)R|58m3w)+V*bx<%6eUq*@aO-n13~cg1iaJA%v!+drz|kA|mjT!ZTo(_Fp1 z>&my=g6ZnL*@k|VtLnDspIA)*!r%g6>H@VqatcI}sVv#vd-G#4u8Ji4F6+(w5QS`!OPV#K4^o%3i+gN9+T&xd6D~3mlG3A z6#UtIwZf;qlc;+FzA84U^jk~HXR>*3qdr&4Zk z8u%jzw)qV+PxZ@BJ#z>?b*fB^OlzmFKEw5YA#rIAc%R)a>7NB>98%lr)KU^q2(e+V z5ayc1Yk?X%vvIl|nV|T&LcWnRp~P+d{9KvPi8&rJP5;a0)Ykl${=9QjJ!+!i!o=v) z)XQA!(dJR~nnqL6`VfBFhZjv5`NsO0Kv=d=;8J?3Aw*0ByXKDjYn2(|7+{b;9*7S? zGfO|4hv56Vcj-I|X!df_Xerqh_rAKnwVu`H7EK(lkf8rLxeqwKw1DFsV@xcjFs7Yq z%PI5M9Tzf_9t2$Oi#@tucHzB|{lM+1F=C{)=@d)z=~tuJ zjab#(XO}DG*0>MlFs=4=kU|NXF<0`eOCruZi=$mGvlrVtz^d3M2R)$Q1_XZEU@{8s zI2YE{DacO|!?e~Q6hyNhcu#Wl7A`!|2ucgEA39Y-j3Ajn+q)32?9(e4TX3~uLQc)* z8lf@hJHxT1U}x7SHOW0Nfi?r*pm~I05bwXjs3NFXT?V*_hu3<6~K4^s@&`j=I&=#3DDJh#ST z8jn`omIZ8!Pi%#Jbx+I!Av~x~!kNu`JJ-srh102pi~1e4HR(sjQ^PH_oy)6Ut}xwQ z*X-jyLbF;e86^f(5kWPbYNFtt1I~3@ercDMbQkN5 zYsZJMEaig2mL12X5xX>`dRi(!b#wA|JK+0Y|7lY-39) zw#jm36uVHBxAF4NQ_RrCisR7puo)fALX5IrOiasqL%LdbtIfj<&s{0%O7_@%C=YP4eJ~3veJ&=1$+cB`VOG{nd z0?gFdK_;czd&)HOoDlGZmAqBJ263BYIyUQ%(=HZL+RsWKILV`+D4Y=>5w3HX(KE4qksWo8~@kwSGr2e~Ve3oO6tdG95< z<;=TkdU4!Bb6B1Y$h!lLHgafk=wob|BD@EV^>t^>uJ`vyv0-44pw*W#Ws2*@MOo|` zuzD5oo#hUbkm`(oAwGbnw$Q#i({*o5RdYG^W?{MeV4cIgD1m>5=X^{Z9J07pUtCIV z2%0R;UY&|JMBU5h*I}%x`1TzmZjp91dvw*IR$;3TCpZ8awQ#qOxKU z4t{hb+HwoV5M};Byy~i@Xsayyl^>WPC6$I_uUrOg_K!b}$P#X7yzITl7M;&q6?-mv zUk@GP)%?0`QEkd1Qc!2ArIOl}t@srKy}Kqi`soDALnHp{0}JglMHsVdo}Y;u^r=%m zr|+hweHbMW?TmVL-q(d0qgxf+E5LwlHpSTeK6(Adz~{*s9XapM%pl0*_d56a&+?}E zXL)5i8K#>9J`WT1W3u&i1U3;rb8w8j7al{;5YB$#fM}Jl|+3D6@Y@n{(X~?!y ziiNYrJIHT(O{;EQBP0e639PogIeZYY*RkxWv@a=ns4a&-ELZ8V!O_vt!pzR<;wDp^ zJ*UoSiRvd8xG@`R-WoABb$f$ye+CFyXozoszLAl;#I4wVIY^d?Hj9LriU~ydp^4wE zwYL}-{xB2Bt@Qju42?kiOKshCjdN$&Y#z{t{tb><(zMIeU;=5pHcsS?$p5p)2hPB2 zfB9js1mSdpc@TH&OUupg22A7o#P-n;J}g#Z*u&;ODwu?z;!@7@WE8~x@gas*$NTW< zNvfM78iEUylYj(h)7>y+0<{E3`D#{_%4jOcAV5>YfTgN9&0&cgq~wskdLknilFBxb z5yfOjAcOQ7`YIJOFUUhIl}7un!FL@wNLlLa=!{6Dn;1R5cNr=*)b=#i`ruepxigoXGkqR}>!1(zFmvckjw-O6 z-W%rs4Ke>&Fiyu?tE;rBv4VGlPGRJJd|i_(Np*v|l{ohXprHMKp`w6(U@OIRI~mxz zvSEZ>#nv@gX#})j<7(wy7>JJUJ?dC}ZB~i2i417Ou%^DUzk6YvQe*(p9BN&o*KD$^ zI_3uL=<_p(nrqGKn9M``IzrRjo%wF43}Rnd!`xYAee57tI=0tm_i_phhKE*hDjIQcoC~qUhzG=C3K0jixG_9MU?L zDt=wp|EAeqG8^!nZpMkyC8qcns+~6$A3oXnCZ&H zyPErG!8m7~Yh_INMBXQgVB3rr$p<^Pl^k2x|R4TE#^NVo2Avl1zxbd-qhsKA)WHJ3?m(!TPG2ME$ ztbK#&27UAs+3MTZbbD*h`bu;!J&)3r^Jf5y`!Cd-UYjZoIT@iE30MbsakAftnfGH? zSY>8=j}Wsg$f<+8DkP!qZz-doYJ1weRc?^MOl-_aohf=|V(s#M~TYoEf*#D&)QB@mBs z>^)pQrb7@#zEQ)AwHf37$W%bq5M`_60(f_wsjhJmSsd#2^{}MNwR&CS+QK247M&I* z!8W<6ab_69uYzT53kQfd^D4?yV2UgeEPebIY+in4>I$LUN zW2PJ>{vaLYCgQI$0!g5Uk&fjB3>B~Uf~F`(4lPHgFs-X?h95YONbh`#qPq9*|mBMDW!D27Qw0lhc`%5ME!U|GvEF zi7e)0G!2>=o2}Fid$9&stFb76st0>1Nls*sJ~S(NQo@p2C#BhrX8XEJR5GS89QmEv zMo5Fxt({!htiVgs!COQtSEkg3LYryr@!BGn#0xC(_HlWgkA#wQ*b+2=K z78nKtPvnlzcrLTC_3I~9IR1<%U%wQPN^)weF&Mm8g$^lAxo@ottR6u_r|5tW;3SP&m`^AQeZKl`2?RuJHuooP*c3Dread1h;N)rbOw!;0CYHiAqU zg4c<4RZn~3=A-wD7lYd9ii$l?B7!Y5J8|2y=q&YQR!xEW-)^04dBen;u@t6Cg{_{w z8l>}Zih#Hb7-j~;_l)fB51qEinmKnn7MvHMJRu9OAD0I(=_YfrKiB-O^+55H$~k+H zv+feqk8(<=fZr9p(5?~R7*xco>|xjl)hO5s=61nlrW))A+95q^d-Jb)l}8`|W@wLx z4FGi=#3f{~(?&3vB#T`1VRx^&|I_29zb!zqqciT!>3@747QzrFPkLG#a$CbFIBsfTrfaEf+x=A1qmUILbKmgo29njMcbbMRZ}t8fMRmsRdgI~SN*D(a8Epp2S0_Mh!Mv^ zEk5r@Y{{KX^s^)6)jvT5T+9G( z>GBBE5IG6?0GTM*VBFgxyT}=J_y=0xc#^%Byj|Z&;~hW@tX@YFE|8z8vmv?{7_O>w zM_j{io#n!rmwV93tl1luu)k7N` z31}|8V1ASQ5NBq1>DG-Qv!k#Jbpr1pC0C6-O69|m*ZWqPPwwKs1@EAA^d2T{pTSGXhE`%{9^N7wjFSqZ&*ltJ%@R2dnA2_U>Ku`6dt(bNzH< zAoGIUK!tnt>AUmc#i6>q!K^a6T(LvcLXOL@%J%kNwYN}aLQS<|^WR~pp6+Gg%f3h0oi)5k;i#9qLp*9GJ61Skr&0mB0DuJl3 z<*G2O%&^?5NFdt3d4!6YmFy1XzFcm#8=%_-YF*RJ9{Ot0jMRW0h*Ego`A|7&->}if zDi#P+*+h0*rI64Q(d+~mBx+4ejvC~4p_v^KfiGxqg&>OBD2(Tp?&HGF6ig`0>pLSF zQlm;JuBP6{2nCjY5Xyu26EOTrCB{wq$|f|C?kN+6hAt4-`KS-GSUO07TR@#vbVwtG zN&ZwSL}6Kv$o`(Bp<6%x#WwV8?;TGGMTUF6Z1U&I8AwKbmYnpc-l5*7e0f$uRwKt- zCu7zvS42LY`zMVzo;f-@!Pni!QL)daHt$=A|17l;a{%@O$z?f>nWT#Uv76ldprXsU zbe*uR_G@-KE(O&3_Q+Ei3IK&_7q-KF${ivY_I|b23S=r|hyP;cnwb`|ekBDKPA8zxh+p%r_QI^Qb8CIC-_uA|R(5kSa#l{7f@9+;0cby*TIH-vN6% z_D|Fv>VA7_9cx#1O@Fa};o{q$ne)%jDf38O5Gl=F&Gh>XX6Xz;lv6k9-x4Lu`DS$V zZEG4^ecmEY&tsHFuDk02b4gmW@Vql8=E1Jf)5&QzMqPTTSIba_-M$i-D8;ge&X^1T zBz-8*BZCdut|oywr7M|b3v=e=UgJ70xysqXBDCevvUMsf5+wLqvS>duqbc}8(L?io z`9E`@zLWzu=9RWB#yuZnqDn~wCuQN&=LUnMnWm&#p4+@P)lHNO>Iu8;#)-ZWj>OxG zZK#_g&x$7xpZQo`R{AMo^7DO>&3TcfF9KDvBHunJUI}@J{}pE(cf1iqc~CMxUl+^u z-CZFjM80N)b>2O=P~WK`1cRoXoeMB%K4J3W=I(VnyY%O`M0@PWxJ#6pyG0b*d7V1O zSAsj53l^Ii0^&~T=!;5shV2ifM#4sY1+|16!^b`e9D;_|uK>iB&PKW$6m-5a0J0ru zih5qc+p@proF>$~$=U4Vpw(Q&_z}`W#$l--`|Z4A?m0{ z%jrX^BUG-E>AIMg_kv15Nu39wK6}HhSH~!lR(e2X zgcGn9IDGib0Ied=5bZZ4VWPB#2S~q<{{8nV)cSO4ZX(iv$6Y<0MV-v9Lrs!!gBIOG zAnMptKa(W-X2*Ps^Eosi?Gz3ob}xTh4xJi>GAvd?(})~IxrwY5VL#p|vnA)I=xNYNW$m?>Y--T*yKQSh6g+7~ zcn+keJqe$Q93UlJHFqhk4-8?a-IQqgn&qa)Uj>$`^6!>r{&W{TQZFNC!o3QoCJ7UG zVbVY3*ObdYLt>3lK2mF2!-+yjo!bKx`HfXAN15-QM4*X`2>@$Ln*zMll#p&S(!gPa z)kjKDFT>AsS&(^PZJ@1wE`e70YWY|CDlMdqU~O7o8WjS&K&Ps-*R)twd44Bl$xupi zMyc30D8WQuP}}^u&f79GzN$?}k8eAPial2v6y@ey)^+YoI0+Vnnd-im`HDe9o-E}n zC=B)7%&$ykfhgzov0$^#h7NJj7t}$p{{C9@+)Md)IfjI#LSc|;a7u}65BUk!akjTX z5Ti)OC%dsojzDpFevWRQ$2qwZV`z}fn$o4lpc=a&*}&BYE1|mXMok3BVN4OS1FnAg zZ;=o8yrzR|Z9X={7zB`uamce;*{2qzq~3wKl4RUjHOXUJ3d;=mi_iBcGY&Lw$c2dO z#F)#(m$(X%c%-_9pjEBi6+1LVv>h6?>j37fE^u5qi`p?u5Nq>BXuYI1KtcZMHbo1#bf2)*f*ahq+B)oFSbsJ%w7GON37Gt|lhz z^$3w!J*)Lc+71hm+6ucKNcW`E9%dHZ%Ac#PfD0I7f$Y#x2g3?o`I(1 zm)UG#XH`DG-hNTZ%tyGj*}b(cEg@kw&9ZDEyyPA=g=H078jb|GEPMCcoCT*h`_6ex zhSfcCpbyD4aTLk0IGy+?u#$Rra(m0{{Q!f{Kw?M7PfNFSJ-MY4rx*Ub>h(g8@joEK zy&pxHj?NDej~CnGTQ3rwPCM1FV2heRv^Ba_3B9J4l;GOEe;v`ArxDO&p`3}4S5%R& zW#JkBGf7YcZy+S%0y$0fb`>#marJq5!25oKf#0$`t}3t-tI@L>Z`Cb&RR4*sXXxHb z&2ClUPx@BF+Iq>`EYczU`cubBcCHLRJx!%IpUrB5M*yHAB?N2`qMQIN%Pgb>O)p81 zE6G1Khjt99glvGw>hIbT@2|TDKT5Bi9f82|WHNhrzssPY!WhV9nupGE0(Fy0ishar zv|p9fB=qQim2%FE$$j;(b#G}LMklVp72pYzOs9s3E6THtJ!#}9XYNk}H$uh4QS zTb~$~@9Ew-Z+h3Y{4KB}cfzN27Se5~EA6#@W{H1QW-B+z$)O0jl6<`DFbv`db17#e z_BaS#6VizDq#Iu{b@8;7gBS1RC)OL5q{4s?%P|{jO<9KRHim5&TzDDqt`x& zwIH4o&3kf`hw^y}UPffj*lB;ft!kM4M2$h7VCNGaa#;^BhPpmwuV1S!kmQ{~UQzIt+?JOA5u_e` zc}cmZYTfyANkLj;`e_35S3fR4;)q_ailt~`rPfu?1`|z_+pG!m4cQY~%=M&@>haUj zySSs1vGo^+Wf%KbpHhu#yoty1R{tQ%FDaxYj#SS7N&>04lz9sd^l8&D%lh-01?tUV*I{T$o|R#*+=TLb65 zU@qSXTA(z&^@vFq#{}W!rxoW5#;D@mOO_!#5Qx%JzT&dn3-0wlX3?qR02=~$_TL~- zSU{dSc=`TqU1JZsrRT{9*eKSr)H!$K@)8KNV^crO1ezz#|6g~5rV&0WafD)&zH5oz zOsj+lQCEeaImT4Y_KlR8w7HfAQy>?Wl+Xtn?Hix~p{G_XYU0s%$mKV3gVMs#xHVXz zrUoq+I!1YPG7n+_WI$}|dImG#s2!Hu35aoL5U!_52JhTY^{g_MJ?#Y#7ha26`0ZCT zy_tf(&F$##ZqTd%{ZP@U(A6l8G7so7Ot2E*4pM%aoB4;^x|O|*n065UYM+$=Sy_z= zD3IWO3k_M%W9(vE*DVncf4uet1R1T2Mj2zD3wC4I{Qdx(!r8rF&Ltq?=;f$bq`P_KuU@YT+H{*z|Ri9kW@~ zBBA}ev4$a1?OzD5z-c$&V$IjQdo_7(aCx$8XT@oSi4rTVy{QK~zD=t5v$vN;_<)#J zP7ZElLba-9HOVaMOovUTzxpa}4#aFH!*QlD)yY}eabtrgl)z>g|5<|3;Vxs9=s1-_6?$0UUzu2 zfVc1p>oO$AP%aM253JrlzocQidqK!*O_0Q^>7lc&v&TOEz`W}^J=_|Y)-*F6RlYV) z2q1WKb=|V(dUPXiv-n>~yYWn=`rf^5v7-uHMZ`dW!xhAQTccO?)-~K)lIz=$C-w09 z52@4V75^lv83sDwjeKUBIu{y!I$pM3{B8LLOXxhD@wjRIHE}PU$ZkJaP5X>s9I*fS zVQ2!uijXF@Q|(Ei9~qOeKOBEae-(FEK|#eyiUDWUyMQ-?6R+@>ZJ7s@hB#=93XRvN z=&ZW^a&8d$e#6f+sn@i_eC^#7iwSFYYYO3_+_(#zUd#$; z7whPb^RMrC@%SZ|Zk2>_Nx_C{jy6WR%-3GS6Q#wRcXgWXaz83*9KRSeEkZv@*QWf} zJob^d95JsCHyni-|D{B=}`mbSqU0W+uKQ(cnG-aiV0?5(gKsn>GFfo5GIcdX!@ zx+@d%vX8G`u5An#_|Dr^Ak=N@Uw5}HyFP%RXYKcVYPj;Io=j)n-d{sfEYbYbIYKF| zPmuNK*|*#I8E;f{c(_dnI|8$kI?zyl22doi0g^H>0KPbKd9{u z+{Ww|@AIiLz3XRs5RsGtpt~^?>p@7@5gqnGy&!gbTrAr$$&{PD77uX@wK>JAnavZ1 zq$)9b?8~glAm8w}XYCnOaZ+ryY|r-_%zeUDLCd>zqv z>Wu8rR%JOAA?9>-*-<`V1|(1Bg%pDZ37@8eM^79Y-U#39yHojAk;!j% z;=C;LH6?i^>*CRN{3GOxkvI>SZE`~-Tl0mJOQC*;Mb*yV&nK)oFasqS-ozM`#`VF^ zo`}9$+zR3`e77pmnJ=iBIX(_Z$e;{-hu|dVwn|TtREUKox+C*%Z`kPo8)8jGmaprk zfPR*(SDLvKs80X=g6O>vEq!$sL%f;^jo)U6#QvI+ZDa_hHlgtvLCU@KlO zuD3{$S+3_p3!M;(Z`DyoL!`8yeV@#3q~!l#ar#}tJm44Q3G@iE6sAeM3Zb_lq4W57 zG$gnuUZzRAISZ;1y#Io{TV``oZGDyC6-%O7{ZejK_6##j9~ihJo<-&cL|Boo4u_{?N$>KaQVlBZQ4YHfste&hf-@e&1Z})w6{gHha5}F5-Q$H8 z*}jA1aITM#eFxSB2o4!WHDEX9&*5iBg$A#<6Ql;{?H+ycQ)(4tQP+Nz`!FrFUy=Ui zu`1F<2mTeI1mE{lIM0u2 zBZXIcR7sKdM^pIZdTaC&uOPb{;pBT}C?5fc_MRJZxLE7<3r!I1o-jX_X8XJo+I+in zDwk!#w7X>aT1ma}>f`!>e<9&5az?2k&k^#=h4nt$#*1lFwu>#3-PN2n27X>XQO~=M zU#DqEt}MhC;_cZI;OCyGq{yoa-nY?>dXfF>D{>%z?`N3#%Bx!D>Zh`^Ys{OA5=xrx zk>^OpquQTQvIqS#n_w}W?Z2LuN2(wfBXJ+v>aLp9Pj9z9ri!*1BHg%4fjK#ylWb#1mSF=E4eE99TnQ3TT&{iY0wu zJHuliHFiy3jt8E%95!tnzv|rf@splY-t|4*l;;DXG?qRnRSOK!T<7y-{UEWj((3`p zcd_hz6Aip5w9TJ7f16L}?2NO?j?A}essA!N!_ixIO`Np88xbElQ)yK3(F#YPZm&~@h|#Kp{;J=4n6`y1KUOtzrzs8>;bQLu)7dk9}l`1v=yiqSQy zS~ca?_?3<6e}+zHT-A#|9m?<2WatQ|{@5}r=lNis`i*Gk&Bl?t-vbg(g55w2pC_)zey z<#;1f`rCyy(82?hXC63vpCjHfCUcO1eSjXe1$%Ly2|r8}1-IGcb3`q$x&DO!r8+jS zIxuaADUswe{$dMGb4{p!wLn{2qHQ_a#N>s_=cTAJ#WMDTN3n+hn-R_TYahnOKyldR zWD_cB>PmXRgB4{909eU`pG+nUd?UAmZk?|idur49HM2mlfo!65%Ebsp98fONnKy{%Bm+`VVizC%mn zH@2?9!mMNb3qWipoN9iWw||;IhJKoP%Qg9j3Rcppi9;3Yr4F<^!%fZxqiN0H~otP-J-!o4MWxP)2e9CrCY`so&w)MZhOv=0Yc% zCsId=D+%+uLF>#ubd+=eHPIxxxKfHi5lyo@B+Y$PqBJa5qzA|l_Ah!R+&Ra%k1=Sf z*dc(>**Rlh84IhL>k5rfnP~s5(UoO;udwK2Rhok1-LgkVDkPa>*l2ii^0@?9AV}Fl zyl)%noY?_Enh0#DPxVQHqc2Fxd-LdnIomGI!Qu@m|KGmXg#$9w7<154D+BxM?W>geh1#;Gtv{;Wf^1a-N`Cg74xY-b*u6_@-b-!i zubzHa@oqgQ43j*eFi|taJSykkbA`^z?E2l<@w0xYPllaVJVEeG2jWEs5^QOc!Z>+) zZ)ToYYx*KWeJ?+KopL4m)k=f^&(uwRRAxNk#OlZ$hvfb(pp5^KhKi7R*Ae;v>5(}# zXWP$xIVDOcMlA&?Q;7FlAf3bZ5zdg`E)%=(NnJx;_=Tl^hE*@%`%Yg=nu*=$pbWt0 zO?R{+!Iq=@4*2~~3?eF(Tb_7p@IRhPvwPw&&`?P$Ufy@&uo~xG zw)-wW8T-Ra8GqllB8H7$%X%mPVnPRyE&*0w8GQ{kcT_h|$a)XtQj8){L;f#?NhZ}u zOZ9NISSL4s&;8bbM=cib+G3R6AT^LDCcOnO@A;fE&3 zpMZ-S437i1?dMCFW9JhR?f8=SjBfa4PvDSTAi72UY=jRvy3Hffc(DwX6Dihf58Z=g z3>-mCrF!RBIt6}j#uD==FTr<}w178HV+8jot+q!q_nmg`ZL7L3{>&Ke$usr)>|z1A zLvL0+DZdIFjDff5C6+Q0%*j(asgU6)1%8{DP-63k(Jut^V7y2$Na9{#-=l4NcksCZ z7Hh{Pq$h}5T-Hf#y-XQ3cRz`+X~-5RbS`zzoewjv9}Io3tse6Qv$Zki@4c|fYIOv+ zevuhZzh9=4IiH8p=#V>Vm+vJ&JMNMZbxrinhK}^NC!)HU%^_F2T153QS9Lo|#HPW- z3qvgigJhX~jBSMP7K`;VpMuM$vXhle3by`BcrWv=fF`a!Hj8n{2$BoN~E zR44%Tr)f48DL9W^m3(hMqZiluQjZr(ziR66QX!WmSi(79V5-zO;`s5s8fqnKIosp{ zEo9j&f2WcI-NU;dXPLbiDjlrVKO!EJ+SqOC!@gHqh2FSXE4*qqk(N+TzO6Zq9+I&W2#SZ zUO|y6me?xnQt$rUK%IA)aQe?>ASDNU6Pgt8zY_sI3x2)ot*RNoCm@N%-IK3~h+b8W zeo0@m>9AIoCcrVa*!;M*`5k=gNU-fZLFA-*r>&!~#CQJjruj*|>+ZF}z{vJwK1Tr- zwJ2&-xzkQR%9)9|qbwzqS?>n)6FSrxVOp*>?THx`VI0<^~LT%vp z#82G9f?6!oVYr%ffu6#2yu>zhrq`b&P1LKt{}W^lR&gubmeBqoLX)x*d#pwuM!wyJ zJabTZHb6^1!?%dv)ofb&@?7h)J=uuWRBP?ip{=%Wl(=TvJ>xDAp{L}tRLww)A2a*- z$qqA=-!XFf(8{`O0kh;AdY&|n?BIvvD%G!$lPFhi<9C;CHmdD*wS@h8&>9nZ2KlJJ z8>;O3<^)ASsSAc|c2Q*T_uGl% z-^TZhrr}~!RTZo5S&znR^%=M2zo(A1RL=5^o3fg>RU7m;yFCbw1`f-<}5R8lU++$7Q(_&s2Xy2)C0A%0yFG>x zu!jl53W#P$^MR=L{|m2!7USQfC-%W0F2({`pN4IWo5nEIRT_X^1%_>4;1&#YxP4_4 zv8l}_eyQ$k=F!tpu!4koVZK5SsK}Gj5r`YGZ$&}qRPxL;mtk4dvB2DR*uzp}8Knqe!Y zmgOL$M{knTM@GTu?@g+qM4tx)##;Be{i`aLO6O9%k`fH^Zmvc*Yz3t+tHGqC2EXDf z3=#nJzH774I6jJ^qkapMnxAv)P;)~ln^46G`G<|@FK24t{D$NjLVF+*5NUJ0f+OYbR|2z2$;^aVNuhc#6{-a{$2LR>cHjHe1j(gF+Q*G zsct?7etwQ?DlA#w$n(UwO~Wf+;5RZGsfN4|XsF^quC3p`;^{a#N1S$>)7@Yu$}hQ| zeIn5-Spj#zr(%>9w=DFw&~36!c;oH8Q`dEYGqodiZ09^P?@hN0r|Q0c=nD(vZOCgZ zL3U`sAA5%9^W*;Og1fmM}!zv(Pj}j4<%$>JQ=_i`LNqH~awb07PyG&*KKD`AGB5NYkJl!Kf>1d1&@tiN?`tt&qgado`BuiumGn34W-?d3RMl1aRRB1XW6Y zNjYz}P4Ha^UF?-#e0xP-+1l$%Wcu+M>3jg;0diV_V7f5F?^W;GRi*|ue)jwIR^b+oV%;{XcK03s% zV+r;e7vi=ddBL(MhD1_sE zTsy)Scq2Ua8n=0uk^+>*mTxx5>dGemQprz?aB6Peiwx$~VQTQ+I*fo9p44YLAH0-C zoXJqw0j0@Lvhrv%V<`Y%JY5$JWo}KcD{I8422Lj?Q(jiLvnASdv=DPo?Hoda?=-ta zy7p{C{C=tu#rB7gx963-T3N?`+@+HHQ}Zt*f`@d8Tu2iAk_&`-i!56)_$GC8r};Be;pd#l+w{c=gxUdg`;B z!UvQ7$Dv%i+3A+l zxz6bl$A?#N&e)fsftQ}H;B}O$5qI5{jb3M`^*zXcWN_nNy_wzmLhvjJj5TAtLl(d= zq~>_LyOnwTD75nZPW3Nvu3Go&xHz4EcCysw+A14yA4}njI6J9+c|*aSkH{11qf0vtMNISb zB~fHH96YhxzcTg>+#fDEH?0_B5FJGG_BMtH&QhtAMF&Rrn?H^E$rI0m zwb_R6+h(sIF5I1HrJJ(*s#X^+_3IfP%1zZelG!1MH|?+H`BhcFERyi+o}jPbqJent zSjU53VMc~61Iyk#x4za?Y893GL$4Qi!wq8I+S6QP9PO(oIgOekm1)HEAt za9%dDc*f5X3A8*kt>iaPj8l=d+s}XO-Z}nq=hpPE#IGtk^!wFO2vOif2KQM=4a*8^ z_`KM(eJf&-*QCW_rm%de6)e9POG+=21OBiuo+tJ7p8hKJ+l@>{oB8?sn+y<~>*uQX z^-C@$C#)np2AD0#=3lr5SeCn z`C+Wh>Uk_}pXBn{M{~q#v6{p=X3W#L{iMj&r#;swMmBTo#!?IOlIo!_Ypzc8qD5Gm zdbv%>tBAD7a6xZm2Oyq2fF~dr|MGM9a{F4j8h)r6S594vdlPSqvz;MJf>m&mIKz@zCfLb@s;9jm9o9DRXt zC1=q1^XL0k|FFx@QSn_!W}j~JYNZ0L2=EXR?xd)pOm@qEUpJ7TC!&}s^?y?^={bL{ zXKtuNnl$1A_%>EpgI2775)!XJIHzMmP9}*Q>-U%&Gje<5IIztTpWi9Pd9cp}9Dcv< zqky|Z=WLDGml`^Xgc}qVIN*KBD_w*H{80I=^MO&CN8hqfhELgd9i}|npM8x|mGw+J zB}nckQCLOufWUxgH0HRhWwwx9e=>S1qIbS<>jy4dvQ9eQE2NJs3v)@l>gASi=WcBr zk}&88h&O8f28&is{zjj&8%%DF|E|2N7XkQZp;LdAA4+<&P@Koi}8NlNWAcbTTy;!Ub(J9uj%x zMVEwK+!{r`h`Ibx2sk_gB5Y5uMKBVzPSw^6*^lPn#^WUOs!k~%pIwXlXnHQK;X78K zjpiUx<8_uAqkL4QmksF+T5AfX(wqB7vT(_*ZfRY%Fsq(wO?{nt= zwBHrH|kcwj+IT{Cjnf+)K%+Z9(*$s1NInPEMTJn0tvIpT4%P-1YCY zfLhE32tIiLgJwGnf^Yo~ga&&l3!u!PsQ4et@Co3(ZwQj7DOzAQ)wZ9J2F757{}ekJ ze-%4avlL({hI>^;xo)s{0sgyiz3{{+duQ;Nexg~D!uSQgOhaLJrX>CakV@9=f42qR z9bQJ1bm!zD2_8@6GK^&A(M3~)zlb7(Qvd(3E)!)ysultk5-vRnR^JB)VP%1$`#-UGL~8$>^7)TLiu~0t(m4Bf}f}65Bfu ztQ`wDnX8hamNdSa^g^dU&@%n+pNcP`l-@+0zlRC}21V{!O*P3T)LD*BInVik(nu2s zr=a}eihsH5?WHEvjj5fl$M1Rs9T@*V&fYto>i_@$f7+X@tivH>B|F@eR20V^ z*_>lV$sQ?L86lxTRA%CkjO-Q1i0osZI8MKZ*X#Wm-|t`F%kPgUeJ?{70<95H^ zEM+wL6pjOk9WB%dZ>EV^2XJ@D{7#!*nQW>!R_M}N8lR5562x3Ech>_RO1y%0FPM;6 z=qIr19K3GEt1djN_E6|&bd!jfD>!rGuI{e}kM;nbAS^@s*twSXm(0IuvHv-J$WwrN zJlY)Th_gUUd@y6hvzo@iA34y}r|*6l3)6J3CAMlisJ_64(9y{%s=B>A*!pt*rx%6ucy1+o!Ng%ktLy)|P4DxrTT zi!6XXefr{dKR>r+TH;iDFH))IG3re1Hu}Lsc2@_SUQckgA_!V1 zsYHcYhnXA3{&T%>S1P>j>Pcnk{$wDN*BaG6nR5 ze35r%8m?TmJzt)=hqV5=#y35-KEr^|z7Z*hKeqQ7v9spz8gy{qQM<5wAz35F*MR_N z?Sg-dk|~ouyj8P)ieDsV{dTNfUSC-N6!)O{gDPD7yhe&}74f;xx8${*em>N7wnK8; z+}amB?8N84{#3KVW8==n4!<~!=qRw-`SiB@<%00N$E2FYLA$Sl`~->jryloK){hp} z8djIlaA))@JduN&=GsqOb-lFdhS%3=84GwQ|MOCx@uSW2*XE*ydkhw_5S87@0XPgS zz>7;fA5MweB3y(&k~MjGb9F#MtJFD;T9dzFA8hAfXF;r_@a*F{S0J1xYp{zsc}?J& z3&NO=M*l+ji+nR#$60e5lnk;z1>%Httsx3dvdNZMwTNv8&eeS-^aW%a(lYEPfOhPy z_74R6i!FXQUwy){>i9ms=h4+8>jcsVoNSC>+cCj&GvaX|Ev2f3!PVt+RqE7dqcCP) z68tWPC}>i|$(V?!BX8bXN{{3bXzkE8^S4qWzgPLKBb1i>fGWdPxFyezsJ{InZBCtg z&2CJA{6k$`RB3()4{ZXaq0otheuoP2`CQexg_~mv)K#96FOU~6p7*KX2AjefBY0qF zrzi!-V$43=eR;x1EqkOmV|MrCzS0(6l8V6o*y5zT@ooUld+uzL8j5~!*|mB( zLA%%@&5-KFFGL>7XH~X4@K+nZOQfph&s7%tZ-#UJsdAdocFteO41XhbC!eKT>}~_6 z-xoU6y9npw52(AARc<4BZq+-^HZ1Cm6Kcu(N8reklY;kNZ3tjcXItKmVG5XA4$c>! zR}*+O)6?-91ONTKcK|*|sN6BkaB^V}yE1xFzNg%4eX}&~n(%dj91-y!4a)S?AR1_Q zhTq4=zVkZ6&G%fqLQN9^iJuHZQ4%`g=Vd=Iv2yRtRBj{QHNmZt?_XzbqtLQcVP3mo z(eqJO_?nEd{IA6{6IJ%9Q~S zh^wD8SMGBL-g2L9P{XShDvS)>FD>cDa25h8`F|c6=B`DNXJQ7-l>kFg>6BsE-FLcA za|b*k5zF#}JVACgT%5&AoLq&JI~u@dyV9YS9C9ur)qo7_9T#N_CFW@RpKkaRMREw1 z@&=P8Pq0x6RzYxpKW8g>0?;UZatRRE(gR{9(2jWwkDJ7_7hWu7b8*T)S7(xk3hTAS zLap@3f_jy9L?LqK96fA6=}WnLktAcvg*%~x0i|vIgOMzm^!xJkEV82AYWE`P11Yn7 zf)8F8sNG3T^v!`}lJ|f(4e8R#bwJ|jW_CFf~AibNiRfi=BD#GP|hgyE|R)mQj_+lSaC&n8EgX+iX?~Q3aLbl*zTl>*{0<{ zE=Ep?E-RMI8Dl#ybMIwqN=#qGKNN)7?yhY!XPBL|1mtO4m;OP}PIX%IcO1C??YaTX zc5$mS1uy8h0emS_gNF-DahvzM8bu=JV@HHOKUt@`I>VKHYgw5clhEG7+~bwJJdM-y zj{W9-hd0EpLa*yY5BFLbw;xAraRLpHCi|;2G3?P*JB=qE_t(M{J)Yh+=q9VC6kmSA z{*EHBfK?>q{SpE%p-|OBY;$DpZ9se;5Dw%IgNyEHz8SRm(UhP7kRc+7o!rY`nI59y z_ZvJe2K>gp8{8N)`99EjZA;@>Z-@eJC|^avrt3sepkJEPfgE$|Ro{rv$;ime>>?^t zGuoid+f>dGRIoVTl=YA01T8#oNq7XVI%jY;SYr?E`ZW8ZCoHj^H*?qVWu@00leos+ zgX|v!sn-P7ZLhoqDZ%4QW=!^UZ+(+qgnwAeS?#p4mfpGY$CUf~+TD;2@xA@g-XJr* zl(r<*gE@TZw)a8653~z|4blCfr-M>)d-`2-y^+J332(fx&YQT)|zPW zNVsC7r5`?IXJdrOq8!efJ;;64sB3l^^>|*xUxy(+&N|L!>2TR=>+aDBMsb^0Err&t z?4|rl%O06yzi@wtiS3-(h62@BI?v@}i4GIh4dU`9% zmYz9Uf+bj6&qPr@uW+}#oDb+&OEssOw)M~bfVv9X>mP`& zKZTwuK@sk|l@{QHqktXteRblemR2JBL1UXWE9r%=PX*4lCYr|paG2GkMGCvMvvi^kD zO`!*{z)EArxYklvwlU!%*_m_r=u*x37vaDB&Lv&@0MdLb(e)|oEkVaR#4bK@yh$;1 zacNL-{Jcsx=X50=OY1n!N~9UVO&y%ag-3T<4--r(^bmFX6&#@Nx}&yJj<7otozfE& zFXU6&?3|^_%rF3n=B}fV>}7(Ke;p-}nNYdro$=<$yc%2drdF3n%``gn&!G{Hig5NA%l-*(%ERDq^RD(}n&a#k^Un&4 z*0!~TA;#5PV#kVf)xWQQ>S=iQsdM1#D}SWFp8dwaiEA9j>`{=AJ0{$^n9Y-9=a>l=w_3L#dwUDo1G@5&77B3E3jje^U4XuSFvd&prNy$wj#?^CEaxquH@wvdqninm% zq$ajH(eLA1?d9e`3hJ*1b<~H3J^~)GA|JxW7+I2xLSmOjQ-~x7e@1}$UmBb!Hvy{5ICBa9VfqioyusEFP~8h6zG#UjIp;l<^d7Q?zqoHx-@wX zU?8m(zB4^A-oeayh*-G3Y<6hz;ZTJjIHpn&@sStcv-kXoG{krchv|Du{go=xpX|cutbHJZLe7(?eg}kd8TN)miAc|tqX|) z`SUzzaV{$DUXd$T8}G>`3R0PF{-l>_R7_ps>acqne)G23vk&=eO`r2aKy8+c-@bc% z1vSJi(k>Ne3!g;aX)w_kzQOy&aM6cRW~>U;hU%Rq-)N>Lkl@PND_u!HWWQ)*ZzUtz zc~bm^ZjOC-#F@eI=RoAGi+y1KLJ;PskrZ?A=sDrKC_{Ho^?u)iW}q_erFWoue}(f_im!AKI4QJ3a(TXMjg>yw zUE3eSs%Km$P~z$`4ePz*s-jbloh26F_zP$Pi{e7cM*TJh%to>j8QpUHbe+fpKQ-FE zUl-&41`j>j&N(5)hv0U$zG#sDXp{^(*Z3(go1!+kiRLn`*@_umY~BTXqZA~pQ7iwJ zZ?0-o`(V~Dimi*2@K{9M(wid?_?QNmf z_3`;^?~}hM)H5^J-!i9^apXt0S)j2=N6P>v=Cs{|y;$SvGJiiYqTb|V#N%MwVu||< z4y?!O;irXV`-C2f{pRnS=B&HUYENqd=<28|hs1w&_+x!WWG6?s;}zwmkGuphzhI8( zx7G87t+F^LIvpONa(L?rFuHBDWhycV#X9%{0|XT1-{H zY4frc-t?$+)t6fBA>$m4L(3l-o{rqiP;0z8kz+1c&o_SHS>M8eShvM72g^DVFT9&E@ zndhqev#{#{^i<5|HFh`On*1a@0~c8v?VO?`Wx&cp-)ud0d)6}?e2$8Tz%r`Fx;+Fw zu6>#Evhj)H2j9t6*`n}@3*K4NVa~R{;RKf11EcM+^=CNAM&WIE7kAys&`oUM_hein&qpVEEAm-cFRsS5F@)Pqk}P$KnK z31YVz=Bu3yXL6t01(*o`Sq*L>fEHjLyg~28+4n9F!u7E~ZB0TtvtdD_hT?xxue>6Y zxajYwIdvUr`XcbW49MsdXqH8{(V;!j6Fr@l_#SZ5YY!*A^Y6x(5=Sm&n^^F{G1YQTVA_iS!J2Y#;g7rc0KueH0F-TqxL)V z!_NaV-rFLs$*u?7h;Z2Z*o)S*>0~a_t$0>s|8Bxe3r7c zgV6jp3Wli&tR7AWEcnZa)&Gc~kG|hI)j%73kEz4YiTt+)x&}XbYlTQU%3!fGDa<_{ z{4rQqc&h#sWpMJx7;93Hsha>XrO&lk4R}ueH=^nP^d%0w zibY@G-V4n>PIFQVBTe2FQK&eh1GrcpZheZp5hNdzP9jfKH3vMW1ZUaxjyfc;R( zy34=cr9j997BHX-=QV?S4%B3$cFXN_c`Q!Y-oPc}eo8l0rflKLw;FnzQ44)CzbVi+ zCF8#lt#^Ta^N>h0g_|PM3Lne4Bd8j0xX^Qd58U#d^YrfFyH^le_;j4_v*BF)iz>1H zY}3fRlm{)?36Qh1*TO~Q#vIdy2XBj_&*D|SOU2glbfY3{uQ;4lD`*mOO0jIH87om8 z0$eb(GDwDY#Gk7Hk|Nd{FN%m)Guf7Ft#@D?Y$T~t=qsKV6Z#{ya3wMoOZfJOvZbhp z;(nBk&aP8V<7LbAppi{(kr&eZ$zqzAP|ZuZp?jsT|2a)0OSz0Ze0!5Vw9^A*QT;}u zWMtrIn)vpUsM0jUw2J2qR7S$|gXTZ493 zD82*5XERJtr&HYAyq9FSa}8Z9OVup#r}g9!+Pxq2t~fKM$=NITskuty$DYRRpPmCt z@xXZs)Q|^{$H9)&VH^$Le9K$IGglH{=T-gz&xe>oH1_F}0_tWlAidPbx3w@lto3HU zNoU48dA&WhzKQHG$e9uHZHHUj7|NCp2?Z|xbHkH12rtClmPwbnf(?5>0|!%w#ItJ_ z4oJKG=$m@XI|Co#@*b+PxiNb+20}jVX$eGsGZq=^;>d4;2BQ* z2+VdWqk->V3J>dxGx4q&yQ(uNWJ*td$q8h&MJR%ZaRly)B;ueAFBY`TGq;q&61>%Z zwpTqit&kytWoMwihF9Xx@K|k?ar;qK-{aTg-JU^6RULnC+0-AOa@qO!7HtnatA8Vd z=)@AwT#cq)V8??Er+?zI6;$1ATk4kA_lu|ryGy|Z`X8=HNUzC8&5pKIgwt>PF1{tU zeP2HQ@D~aYIdHtzA~N(|Ltmqt?;QGiQQaERWx+XcEAUuwd9>wn3-%E>%Z3p*4r;ge z>pN-^Lg@#B1q~XmQ{^#}S!ABkz8G2q8v|7kc{sS=!06xs7T}yI z;JT{}1~VcQwhSX;kI^AD4a?m-${$~4HM0+WtiT<%sxU=0sw3xSnO1xzuTM<9wmHD@ zTPIOLkVL8Z8Beh7mq-Cm5>H;tAxdYKS$g>gc^p6O(7g_PjTY_{n`}vKU0{oBIJHb7 zu!Fx3+73;{VEWQwobJS*1u37RX(t<^S{2M+40IF_(98jQM8dj5xkTWb? zGm1wh<_r2yMAmGk6pO$X;S*-@zu3*rJWpTQVBK94p89}U&Na>+DSi}OIU`G8dNQBL zoICujOu3B5iDa_9tV$q2P+j7fX-fAi{$TTKDyj0TYM6$WNKQcS0PJ4+wA$b+(F=w^ z?yym1Wz>7FP)Z}@fp<8(ITUci9>jO#%p6S43BS*uP80HrqcBSAb3bYUCmI?2eBL&( z#fd3?5{xNKg=9Duw5Rgy`jn_%n&Di|O-mqDf`0|D3~W?i0|wDgH#ZwL3YvrFPKY76d@NSIH1`pPd+f!jE4N?)Tl>X=Jg= zFx4n^<9 z0l5sr{LV<$6(eb&hpLN(azmFA0k%mdL7GxdOJf+ywCFCSTXlt?jD(vM%T*BTSb|J< zKlnmrR~NjBJlA(LH4c8_#`FzZ(}dcvxjFWYi#fW24FshBu9G}e`#suJSL3N3(@r5- zJv@nPxk`fGQnYJLlZvX0Ej~t_3<31m@@yWMz@#g_N+EC_Uu0-2FmG8Swy8gx$-`%@=ER!Xuj)AXru}MZ=VF zfkS~2k2`&gCUr6YV6xJd0T?Hm=vP|NB)h+2*u4M?vx*;0yXuq!Aw|d_fY%Ns)>a$9 zEGZYsG-(a!qB`WGa#8n}1U(fazor3-qt;9=M8-CZwTw1cief-D4w)z)?U$5(;vD2@ z;bY-&GoqBW98r_O4sw+h6pQgD;D~p65aK&d!N{M4|HEWj;!&yRhk5fMW zfpdK`h1NvoU_!m!8fruy{JyWb2R zz`~e&!((u}MCzY7p1I|XOS83cS@R4gn;aHlYo3x_JH=VahW#k_^k(*v8k*UA5stLJ z7deDm@BAJ$-50#OcCW>1?fH#s0uRnAbwU`UI_}FiG_LXOb$Na_e|YG^YsfBbX1Dbg z=k05h=uE3?k;`lfMolOn{R`sLsY?4L$7&{rMSLTdW*%k(d_ma8T zLg~l0GJnKBOtkH4`LIO*!rB4)q#h->(!xqWR`^O2?A-}p zPLRT3*9$z`VES=$-#Y^;MUc8xOvsZgZl0QnwKk5o7cb{?yuY0`BqJH>;j3qUmokhS zg9FjRAOntNNR6W?T~fZ1v&u6jfI5KY;%nfSw*!|AMoM>NUN(3kS#LKJCaBy4bqz4HxM zYwD#Ia=AO5s7LtPoB>E>FmegWhw8g(%Yz-`24;H@f3yVYoYz&wB^_X%6pNx*93)LX>AUlRde%U)1K z?8@~5^j7+ZqPo*Aap;i1>R&L9nBzFfI01W{U{Z4uv9H}ILZta1cT7b%_YaQiU+Aqq z@%7cK_z9WuC#e}nVE-ov$&0Dd(kTa1qo&iAKQvGId={Ei;s5(te)>HBe}4gBv`+4^ zB|DUHWSMKuq(mhTeerVzQ6nAvfK~F(+OVlnn`urO_vgJ?Kd+mLk4G*y6mU9Kp|G11 z6e>!pB4!4{(jb6D;W^Beo=*@Z75)%+N%n$MP_?+hmyE>u2in0@yRXf7@jbfufVl|c z&iwO@H-PE!UHa<5Tt9OM_mg{s2mEgvy7ZMxtkN9mQ5aB%`5@M%j-A7O(UB;sQ3Z7g zoAh6EgD#^Rm!;jVl%)LB@G*P+UCw9162U}>0*0$YC9U~cA-P$ZU4UXnD{L@X?hJ0NF^@y#>;^==x^b&Molk_zY*;lP|xSe)e;%zOG6suW>7JCO;E4WYY1I zgm9ZU1%Er+i5{mrNE|_t66}VmB?A~_xB7n6xkg=W`M~hB`i{55GmN&x5gBL<{1Mn! zZ&4Go5C;~~fj(17(7_46((>^V;=5Po3|-sgIL5#f8){s^!}mM7O{5v<$8E+yhI-y4 zR`p_t5H`1V{bD%RJm&z-xm@~_y#DRW_%_~0y23ZcT3t6BbZ6uf;KxRX zOMpM)iGAD%3?iQ6ZOPE(H*24am3HqRw^BW0O)k!lZ>TBqPfNSmE5H7g?G%U81$wN- zUljn#UD4Jn>hT7o^^u)kr=o&t7Qnp{F-?2d(8QS^Ff2>vvqF?gN)w|);CT?D)ZdX^ zgDseDK_s@ik!D-uOQ6bVTIgr5y%TK`AxkD7_Y(e$(bQl1ln;u@x-}S3U(xR{>wn65 z^|3Xr-VDKBbIpV!hn%-vXzaD#&;jj-XBa6IhlflsnREuNjDKD(E^dy0fEiL|!iHEj zxjKLUvf=B@<&^F!k#T+bRwPoCJf_#=DWga-lBTu$#ML6-u5#9%IxVGWq-6YNn8W2V zu5!k=UUzrC77M-4 z<;sKNL6nl+O!$4=OjN`i&)P`BQNL-Om}KIWR&N`j%}GWRR6%<_lWz;#;^24ORonNr zzm@|1KM*Qwuqkkk{9pJh+Qnk_`}Jm4N7M=Tv$UvkP&58F&Azlen&AOJvL*>kGxNxu z#vz3;&dt}=cWzn#7-lA{)kfccrbV^p_{_T0=D~H#@+#5K@`omTpWF-$-JVvZwQUV% zh5G6^v9%hkn9E=ZU++B0$<5999~5X-9SI|qo!+9aX{);KxoFiPl2~v1*nroL?7?u| z?YPJOb0>jcRG;4{|7Jp8nc8}6vk5KIvV|rHSo)-A+UHQFwE>J)K9P?=^P_Bl$cGP| zHLL%a+bgTZY5zSeXLBxAPnJUMEg4x8yY;J)uU7-aRqH=HC79b$QyO;7XUkh$zn=0G zC|vKf6GTg$NsQzsQ!Qgs)~=8%^|LvVM*I0ESo&)+I7L}B2>Gs=g}NRMH&LcL+n%|w zoobu&XZK+7B(;&`_*yvENHa(9vR=aN0I%8GADt;SN-^J7!~7jDTbtNkNjM8C+R#LOmaKd+1~iF7{#_hs}Dx=m`Ye@X&7GMh+XeOZE1!0o>`DKmN;b3fAg zYlNSQ?e2pGI0?Ssi-uq$4)ajHy9!8Uc085H%t}2hHpX!U9ycR?3qKSuXy<3~RZG}X zBVsJ$FBFNoo!sqqOU8@FLCR&(DMNmTx@a-ukL^kg;Y>D>hAQOPCb)0*-~oQ3$QT|C z@>?S`Mx8MSrC)NBo6MQ>KKitY-GUc%xz}jX6Fv#8RD`^?S#%g!F95Umv@?~WK54OHY)^UVDIVFip494 zKh#Bp4>+vROX#Rg=Lv}_1xg@?>y+MyV|4Dnz09GGTfcPJqWcLwc z)@Bql?5>$d8d(edlDwg@Bx7N7gC_TfzD|cS32exywTbW+pYi)f|F>zJXA7E~a0ZEwkj-d?Y=KG9r(c$srS#EEc#uwxUUwksMrd zN3OdRo5*UQ$;)&u5>j62;N*Efn9IZ^AM2k!4OkFF6G0!j1erd6oec1cd`^}z$`h+C zgnx--t-Y{y|GEQW2gR}vAji&!shnPo#rjMU4M62O(zf8K{yUQww#ovqzBM&{s zu${$lmMC@c5eCe@%V^SIW(XY;&gJJaf|QGs<09#^RcL{$l_`>t5$9E+XA3{5Wbw9&LUL@6ZJrG{c#y|Pc4iKWkh^msm5nVHKN>BG2b zz=)#Sef2+uktjEtJ{gnc&80|ob)*(A#kmNv@q@xENEq{mN!fx>g163|eCpStn6U)0 z$bYJkQ&NIjq2c*VJ*vR{@dw)rPHJ(vYehO&xjX=KAD zQaitJ04k;;_csM=ml!8D#@NKiR2uks!8@}aX&F{R{M>NtDkGe|>_Vh9+$iSo2Y9mPMB>4Ng$ZJZvY z|9>w7aD~^hP=#KJX_XIe9)@6i!}QK`t_yEVg&1q0>6ms}?Gi>Nw)w){HIw5;5a7X= zS(12*)=1jS=)ZR%tUUtC7Vj08+9-EGh$c1wK$ZcF=bmo)nGu0SCw49{n0s22(muVJ zv#6m?kbR5q!J)s-(kY19DXHnam>xGgm&>;6yJ55Bl+xj4DP+vr@GH7znY&{A+HN~ zZ>I`{l34VV~#+!^3lIM0#M{)l-t9-#l8S;=A z-_uedrp}x{WFY(4?`JvmMBzSva&Z&tEV`|C7N_rd<66t-(@Krl&&MfuB~6NOf#Li& zshrCeQNgcNQmJ}396vQGhW2d7$K9_v>&k)R8?D7xzuuCZ0n5$;eR^j*eF2EOkoQ!K%@ z2>-eLgN6|9pp@Zf@(Z`@vq9z>i%%5IK`2#WjAM@rfn%3Qqv{Xp$=#EpSxgI~JkqQk z4!fy#WcBKW)n_*u{u(0yrY6`KEJIM4F9bE&Nlo+lurPJQ_G9V?x}T zJn8tV^O|(sEose{QubP~j7ELSo9AGOOIZ)s#hXfq9=9JY!*;DIS%+jhJ zya>iI_EhYjeW;;+lJ{giIKACaihfk{ndf}}mhjCW>+YIYI%ESQykUB8#_sndiCle` zyfnFVHerb-Rn{O;`dcebi#*Y)Ov5h0#Di*=3mkyw;530s@Z<>sSp@^zwA>0?tN=>F zJ3qO$e4`QBmp=%9^2^xo#kCzUvGdZKI{35Bc1`>`!E7$chHgXE9Kftqdyr%pc`p(h zOOJUyjJ28*AbtnCnI_3E2zPj@i^HLw0c z@*=LQ9}uC66w5WES=cjPA_oGIn(mIW~%*QAKZ8D1gnqRV_((nw2-jx@(}{CKQ?L33&{`Og7k8#7(T`YrCm78 zOZK9YC?aHiX>1~8^(eGJAb*=`c<6ZPkMp__vkew)nj2-mr2v zj*GXGN`xiv2xUE@xGwqVERgq&Mch)~QC#jfj7kg}bB%ver1?%(XMSOHD zf4ZS0%MBqEn(NY3#^(sL2?SoTg-Dxy_r*lpF(<)$tg*a41G}NO>Hr~j){Wr~S;r>D zs}g!4$mmtJ{n$>7JR{e4EA@(Pkj}= zNJK3v&*k_cYJeyZH-tHRwoTqdZ{DQh_2oi{1hF$5#Jh&CSs}v`R%|N1epI`dUsw1^ z7k8fv&uw2Ovv+?Xo&84!z8^y^7kv6utW7jFgf+Q#qioqTw~Gk z=Eyal{`hoJfBuGw_jjp>Z^9KT5-zQmw~|MM1-R65AkxG;*-#>1<{)jV|9%-`Cew@O zl}H(TG404W@-w<9u-vujqp?x>oF}g}_If>#Yh`S7< zIkyu(%oiu;+SaG}p7m@GE_>KI{42vIB4dk2~sO2#6 ziVAv{$@_$vlg30vi#j#jvV^V;rX@y-LQq=noj5Xh5ZH3Xky}bVY3r1`K zY!GwIP#qQLQnDFkc2oz*cPKE@TLi6VGPIQHR9Fbn)3cf&h@V0*p|`t45}>QRE5na+ zEeAvz;su2mhrcf_R|IH&^ejC$CrXk=2gVrs7ECuRca>nbJg6lEaE04oe!G2I;yTj4FQt|RkT zhD+JEcugRIXbixJSuWOc6GLv1h51=XK7Dn8 z?K7qnlDNI1X zQI5I4mePg+7%oG;hLrn5En9t0kR=)2g=D^+6w0Q$iey}6Bp7%VB*{kmZ*!ECJe+Bv z$sX{ZinRrIT?2tFDd0*uxw4D?T~f1FY5Ny4{|o(xNtX`B0n-V0PsagICC)mA*)C)M zBQ$tydD5f4z^UU;tp1~8j%Z*PnoffSf{0g)=iny~x&J9>i(sM}`|KKutD@_MhMlO) z+Mr%7t$(pAz;FWc!zcmv>i_b&6c)J2Y-v((LGuaJ{caGI(SO}q+CIK|x>Tc)Xa8vc zn82Lh>HpPJ^EFUp?oi1Y2Zx>hnvW|ipNj2S*P0O-$r#uMN)hBNAzxkr+K5)aA0R)` zv@PtEN`6XZ9vjT|GSvfvhh5|Yer3>)F3wWOsZ9Mh38R^XVPZg8G!tiGWNEde@LIN3YLGdkrbO{9IZF2|;RLOacy{=E?qxmJc7 z_3#H*U6fQi#wpv}J`4!Sh+3PImamIV$@=HJBDMEdEKFXFPxbJ=9!tBB4m&5eGizp- z##+hT*1`6S{I02~n7*ki=V=c{s%yZg=ceMvHw=oRf1gXtk*mne1A9!BLMCHeBs)YV z*d*%kQMp%MhHY-IkO_9u(BdQ&+fuK%zA+D<*qT3h%-&bO`Iya5@udhi-#fM(iZt(Fi!^klQ?Mz8&uKkL5igDtu!QqN^F&$J}f z;6+k4+O}&%B`i;mfZZOdOND|x6Tk-sq0QtdcVa1f72 z9$NsAz+oro(wfi3SPeAll<5Z`ed%KA{h}?JxXj2y6RdA{ZGJ$J$W2XavX&Eo_`7+32>pdHyUq#9=c5#CQ-Vid^JB7KG-4x2KXFtUQ8Qc z7#cOj$A!mi$9H}bLcwwOY7UNRK!xzWa_7HQ0iOsEcb)dgVhko?lp@-A{{7r&^L29{;6> z5Z~RJl$(RcXs8inM>II`lLP&?%ctb6#NPr;y_(I0^%VxS&dlxZPev9?#;6->co@FH zx*|RkscO_WQ?`QvFuGX;H`>!IA=?2EajUKzyft0WAGFllxlkm8CCT;blyz@Ai@bQP zTR?4W42`>r#-DFV089wLtF%?Q8MyN^*wG@b^he#fkMF5#EO58=t5$NL}gOXqTbTW6`XaL+6VI_j9-#vuBhewFIYNj9!a zN!&lId6i{Qb%pJ&$O{JCmnY`IWx-~h4(D;U3AKMAj$pIv4R40`dd4^JANK@_3O=wH&T!=V zieGP`c#w7F%J6L$lxNlGp}Lx|N_x75UBM7Y#4S4@dQV{vYz7I}=Y^Pq3oa+ggBa*) zrS$t2&-9Pm3p$%INr$&GCY$xZ<5eFNOlp!Xx8M`SX1I$l!Y|gH7;`l9H<*1sh$9nK zk2g9RmCB^g`3p%Xbygc%J@U2uhWI%>E9D*%61an~YQ3QSMb`%Z`WlrWWv0`M4jEH@ zJ0B%=iKQz4H$K>1V-vu_TV;6z>c1=hTTM}?IIF34rd_GDm6t+>rjJ4+@=Pw6)MCyT z1mKkE2AKR%bYCCTodFxiG4FwdWk)w( zYR4ATD|TgvftDJO?=SwCwlbyCF^)Dhe9`ZuNt3QGAGuH`x*P>mKTX=EF8SD9Yw7oH82ACk*=B8?7?RS=LX!+ep8)LArHmYCI>-czBIS&<03s1+Ob|emfz`vJ z-_Bfaz#?5wMw8@d5XeJSW2M57(t;pqMva!CDV6J*CQWph=aB>oOYW1(L_(e7lNLFX@5Fdyp}k>kBFt!-ivXXrj}5N*XOlyXb!IYVzf=2@+(R%vIFEjFoH;*+7A8g?VAWn)E<8*x;1FGJ%!Mr}@ zLdpuRPO;%g#-eONP>Ee20R?L(^0l8Uy)Q5XR&40aZE1N)Q^@CnlhREdP(q&3{c@QN z>)#Dhdh^9J$x0OinRfq8T>{leqCp|Wnt^o03XO6nQqO-ka7&XcS&SO{Mm}D-c*$bF zkR%FbS|x5&R2TQUdMZA7p}$?E`e%r9Sj+c&y_A zKeFW%uP=S?kdt>_PtKq}S;pX6 zJ}4KSzj?VVavprcQX#&7_J;W_qcU>5&u6j#Sey}G?Nj-ZvmJt7KrZ(kwk*Hpf8u&a zYd-Q+Mn!oX6i4BIfPv7T$+b{zdt`9wY%-W41{Lmqz2*C*^jiHl2Q6k3pj4NJqSR1S zZ7pPVZ8y=X0_K~hNq_o_l$z5lrDp{z7t+^o+5bd_GR%}ZdEI?N7nCMQwsoc?%JEzL zh89>@@11=12Iy~Ir-i$i&WOOituI8W24WX(m?MQZmsv}d#OsF(m0vZDM%~G+>GCxC z;J!J)fSdx7R2Bl8o>RYmysf= ziq~JLlE&_+0N5POYNjMw5%uxwRdZ64h*rgiKioq>kdK`N!>H{m(tfh!4o5^3`^N2~ z1zgS2XCeb=*`xEWfr+$Q;j#6eZAV+_VAgrvMfAj>WjBcRv^{>_;7-?eHEMH>)0hlV3Rwb#Qy-|$hVUUDI!hq?uIs}6vI|D!PtwoYQH?Z zLtGKb3Q6-)SC<=44*kkD>uKq#>R->+Z@w|*oCP~$%O5jry|?J3X1kuTEXdEBqLR+6gM(?`(zqI$5v_?g%YLJoJK$9_y#&Bt66Aq z7Ns)jq;;2e?U%O+mMR0Maru856)|ecCkc?##UV|w`SX>mt%RNmv{>XMU4#nFej(bM z=1N|F6o2_-?!u{&s^@>q^IP=GT=Op)X1ZZtK#8>!_r2|!S<;aDm|Xq%nV4Ay>sX=*H{ ziR?75Fsko7*xB73^B2!usW^`|KOI^`;tmg5@w~4Ha@+Twvzkc0*LW6Ezg+*6DJ%0} znVrx{9GQLJ54OSYAre(uzDV&R-)RNCy!o#99oB>oWlPm?uBEsJF9d8aF0Fvl0qkD- z2KXps2sxkVkY{>N$0z-#ic>#}B72iRUrUVHa$p802GrWo_VCHabKhGyo4HpkQrLg{ zaC}9^1f7As*yp$1XeJ9Tc#KWlxz2}Fz5Pm6VuR+s`!{>k?h-!xe4K1_hlRt=Y%jcD zvZFtp`uv+Z22aWHO}EuXFlON2Zr9rvUpd}Q(c9Py+ct=0yXB+R8&dFZ%%+sSB`{7VpV=M{7pAtT{!n6)rYAQ1nu$4l@wn7jYzBdbZJ$T{NJ9(0i(^D66 z;9MA{B~otleEmL~w0f+lVQ0KPi?-T>QCgUhSJ%1hBfQBCS<>n`H%!tQeHc(S#J*Wv z05I>fuFYpXsH*uIIHtx?Zcu2_FT4LDPR0|2X;)>z#QZ_i8_)2lv3*Pbj zv6-YSQx@6_t0l_{`1PXseuvTN!vI^0q)b4^Irn}&{CerE-n)(2U!ssCjIz(DY-6V=DGD)^>{}R1Qj_dblNjruEEPqvX06LEieea% zeFzQ4^n7&P*L{C~_whY`&v87@A49~LIX~xleqQhQ`?Zj-{;N9yQ1F^1lirqX91juWh zD8Ab+j1SCBHbT~ISu#8MBVF9YW`pOAyhSvKFpWh$DE?}+ifRJ3=E_*)0YM?QbaO1s zIa=;A=&vxNuUbrC>fp#RdIX|+Gt>{wD~dPQm;7-YXYT-u&n|!zrFsz6Ge}Eje*`Nm z8RJfbti@RsrPPT`x<3l?Pbc?3jAE~oYr9A-$g7ijAis=1ZXP6T9m1TJu(@c;#-Fez z8i-uX(RFXZtXcFIxmQFS>@IkX9c+-Ytfh#aSAgaM&!|VAsY9>=M zVmc+#p~LcaVn4W&hoT`-3DB72vjn-FjmV}JOHkt?nyZm)!y&m&5{Mdl68|$9m^(4`KcQI$=SCvPQC+M)knS@U2`P^~1jvV&$=#<984lrX$z)Cp@a>ve zSWc+*+&r+0G$V1lZ9JpDHEV#3=B{e}j+c5&!P!t#_-<)5i^ zUiRnH6Z{fuyRcG9;hWtOUum!fUBH=Py30}A?cv?U{BqrnnFmwph}?UJBxMr9u~=w+ zy-4l=u!REV>3`nS?)&Qhr|0qKvx?@mzpooj>%9j`<-67M)j|H9s6e}iy(yq#^fDQ> zxMhDxOVU6Nq=fo|AP2t=#F9n9$xLUlAK(DIW@=}SY}_bJ33rD!i14L$hXG#;i|$CD zkyPF62avqXdOi$E66{9VV<0)Y`)+ku07_Hnm|y&4G?N#PP{%B~Ed(5TO1&RiW2z!w zQXF1-tlJ&Zx;tw|n&O2|n99l$AggMI{|UboIqm~Xc+h@7yl{Bo5aKbcPNdaBQ}!{J zzNB|(|96YiXP@Y1kN3YnOz<8>dmE-0el}-t0kDwIk+}+n} z67gaN{SF_$=w$g09EO3ulW|^&5c{=qems*9NQ<5F~`R?|3cG${A z`})|t;MhFj*XcKW58mk8q&~Z@n|gC?>HLV^2yTDH&oZm=77&Ryc>DCqV$ z?yHXc{*~uK#hiRjyN@{>Sy}|iSQqs`{g%8M`z}UO(Lf_~MZ+Pr>#)%ecV>)y(HC-m z3M=rSUeAs6Rn-$B_rf?jb1uROLPo$WNE8>G&+jMgEYL~w_l zqfxfmA_j&e_`@{cgrefLIm!jsx!67&SREI*SiMxnL~kmeZ`k17sn8XQ0O0Qh?yvQ<}@GWfyWYp;9`x2q7-<^w^)w&UQz}Lp) ztuIRuUQ@2~{qULoX9&tF(0LK+ix@>MnQjQ;l*>6W*u{RjVcy8Ry&B!_M27d#q2WE% zF3Ez-&)Lb*ydLC^>6&{I=BMEHPFlsg$2O-MT>zYd|8Xw2gh^=RlPcIAW5}-$gn-J~ zEcX#_IxL69?)x%bbR4|C#kV`pQ7&qzhszQvV6-2zjio#9 z+{IzP@wGd0t8qQ798yz1sef1LG z_Dzw3-glN2@`e|LY9wg!-ShSzn7(wGku9x$%DBFLLW#crBZDJ{L=%|2G28|`WyJzs zeRBPIjQA!lHev?9;`qge!Cp|z_%i;=?!I=&^NU}xK|}IKejLhee{%4!A&W1^WJB(< zF#QDeW{VDLTeWpetg0b$Jz;uJBO^c}HsSzHp|M^r|?M+lV7Lb;)mm`w8l{RqK_!h`!L)@0Dnlm~#r=(Aq~}X}dn& zh@xroZM^Aoy@R?N7<%o6p!BUH$m_VL5!&`6JYe-QB?Z zm?|-GA}8pY14q@Op72;Re^#Nb;LY6WR8yZ?lvh13uBj)2ZRq*cn}T%p8soqRjMG!s z>R<09Hjpc_`5HVE1)Ip@aJuHu)rWzcZ}>Re+XpGk_2?bI!ytd^;SFY82)np#A9gi7 zVp`{=@x&sh^u8jm+6$x8xDL)8Cz{eWGHgGMhuAc+9NWN$>^jI5+$>d9WjV$4?L`XK z@ZrdVkrA=FiojTVw4St@oLI3DSFBjLQh-Z-?X!`l1AK0oxQc!|EaKyBf z{~L$q$@n8J+04Ng8Q22y31#3bm35ZC6L1mukNdADu$g%^g%;{8eItDbaH~0CG$SW53M)6**|d?l3Yk4ryDKOYW@L__dU`D`>|8MxPyVd^pL$6ys7bI-& zc1XD=@hDC?NQSqN&;l4-36BbsqtHw?=I2H}BdUlXnUx!i0pD=Z(%LhQgfuOqzs1)Kb1Elc98hRzh7dZ^SwT?NKr8kBN0i*qMD# z(oKuY0u;u22TP(r2eC*3 z6nO;qRR1!F06iYcNQ;YU1)8;GgOewjBcXh!RC68m!s1R1vzjhuEd#Ban~1x*FhpjIiMUVaK--aws_eIS!ytB7GKbTA1}vn4aHrN?Po zy-IM0xY>-BF@jm+_Fk&J)^{Yq)Lzt0CZSRp9<7^pd>L;Ezk=q0vj8(2Iqj(`Rvso0 z*2SzW8{f-jXk{)bjLs^I6p7E0KQ)v6-TZU)EL!jfD}}AUF){8OHdpGd4; z8E0=;Ko8*7PTAEeeU-4sXN#;O0%f$h2VkNDJ?7X*mw+O<;M`=6c8oowq8i^Df(V-b zn@K4Ni2{bkzqzH*&sNEfIjp-hXi6I8i4s5z2?r`xQOfygH4&CNj7LnZ%57QIPPalr z8-)*NM$)6!myO#(fMN*im)yB!Y5c=X_B5I+(Z|>%CLXWcW=sdpvx^v?`2N_AZY=z= zfjg5Nuu_HG;03#fNU#eb3|VSU-`*Z{D{ch<3M=W{&GC!qrUA#F5%te!iHRRza+I7V zgdp=%l#TpMoH*}He!nV8xcDSCmZQtBUUFEvGIsi*H$LysJ+j^5gYV?^KA3lkeky6p z6fJmBe5|a*n-3FPRn?=oH5?sgy7Nq1gIt`|-!`Z9rRn>=>V1u;w}UQmm^cJ`9lrE! zUx(Uew^GZJVAJGNp&<~cEG^Lfh4;XoS@Ybt3*oq%&)}4UWvBf`l00V|LG$YM$;dG0 zP=a}m0``r$_dN{}!5z=9$QzeMI{J;S+1d(mmnL~Gs3b^!^dn#j_vfbQK0De~p;2(i zhEGk#|437;+_L2;M##^mh^;>Q*pKzwxp+3MPf$(M28qfFQpE746i^Y)8K@&%l9SCO zDZ6#=xi$`@k~9K(s(`Yt{mi~E)OMu7zGb+KwKV^YSL0Em^Qnstw35C&{jN1T<#Lpp z*~b?jg109P?vbAzt{)1J0Yi1}(BbId@s0KSAmNm8ppTNt*8W&o9hDyZYmt8?`z$S% z!u5h@K0JYgpuXoM3oL&4QPmjbF|YA@`PA646*rCI$L4pK9g^->ZN1v;xvPD;%Y#BP zfgd|h@?3Wt91{n7W3GNaVvOjssD3PuT*+;`YXM4D5 zd1qE4?qMe_c;_s~&W~SwzL8N`1E1^kU+v`t&WZ+-%k0610_B$%rceZ!(-7v~oV&(dhP9x^6>V#&qo~ ziHHH8x!F&Kg`Qi(@Vj~OP4D@Q$5f2JGGgA_0=HC;)^BQl8`ZH;e@vE#lqC61x4wqs z*c(j_xqNF2WF;6B;vo>MEq^`U+6v8k`ZM9u_z6}OJamgc0h{lWY4f)9u%Yx#1Qe6n zg(@`wmJWqzBBwngwvZNQ{I;*^mSEWpFLJQgjcIh}(dz{l&WjVH4HPzkBq#b8u1UTO zM}F#9SvjUk3&{Ntv7fdzXY!Qild;*c#e3N6pVwg8q!;CLgm)kqRcISQ*Vw6RaBVw3q#<6hXdg}<`5&OZ8l!`Zz3`%RFY3<|a6x|QRvz~2gH z^o<=>nX?7Oc5Cg>vg>9U`SNS8_Gd-g^|=ix!2&JM zLMFz==(;QHDMLS8&zlHL%lH0<5MGcjIoWZroX(%f;Ji;Rr^%Nuym6)o$ws%gi;Ls6 zBt+dWQmL0pqGsggzjr=p+@36}6#iBJ8~Vvsb7A91rYL8CvUdm^pTr%HZ#-okt(V^c zw35a{_^YC#Ha{Rh@V4d^Tp4;8$?17a12HGZgwM~y%FlR%!GX7GwmpPF{A!cS_cjN! zKd!Ct-<&Vosm^CR}xgL*5~6izF@IiS2OW)q~Q zs+mq+FaO>!30&k}ZTti_?h&-S$uiQ4#97bq18o_W4#6tE`qi)S*Q|Ko_oj(T*fSpB zu8xlk0P~CDZEc$0MilMRvsOQn=i5+Y86$GtbK1_-F)fwX1`4^QLe}%X*TVpygdzLdIhXh14)$*!70z};QVpC8`}K>5D{BHqd}?Fn9lr&E z3hz$61nm{I!GRVs8Lp{S%j0ND7@6d-JMy8!j>T?8YuhPIHk(|&$VE|#J$a&3AQ;y& z{7_2JDc1PB5UP1o6xf03KnSA zj7TPZY2G{1V^MWJ?*Y0Il4EX2koe-fk-k%mB0tWqG~8&veJnui!1>+)lx&NxQ2p2B zODxxszEYGd?@v4C;JQ8rj$v^FID_ekTjZ|n`|n_rTDMSpFP|G98_PYL;T!Z+_{S@P{Mjjcy8`EJtLq1NYK>bt;tCi9RHGt_LwBs3T8i>a{}i_hA}m=<;l#QD_LnrAot)K3O=-xu!)l)|P#2Ay@YZD1s_p&Tx?9mzDf)ysUNW4`NPOz#E>7|d(+{Vzk+|D#E)5A5UT4KZ3i#xb=> zNR-#lNN+*QI#U3QI=M9gOEQlqzJGCspt%aS*FZuivv2p{; zTw8GxUN2fT;ZBASi;V@GG=YY*c>CawpIViZh7}x1G7$U)T1d-@p#~6BD+FOC)1!dx zE;2=*B`goQ$9Q9duy_D+mJ(t|K1fyIx^{KneOotjT~t znM8L11WLjmjkw=j`;OQ@Ah_YXjN`vS@D0QAk3ac9TQ-|&mbiI*`Cr3+(3Q>DIbY5J z_Ll2>w8sDO?YONMyItA*_A86OEcyv~gEl<}34&|wq0w>Nf#CfyseemYtBhOr2Zu7T zV7F`({q>(i!~aA~j^$SYGLqY=7vjBT4LZxxzUm14x4ZP>7P6n2Y=wgWnp002@5O;| zO91157PpZ|frp}m(+SOKl-%9}pu^4~TY5;|x=x=bs*!OPicId$PzQQ((Lf^H3#MM! zhi3WtM-Q}1|M?Fsq2%*q(rR!nK5ZQ;G;K8qXVuNE+jK%_g)?|9T;~|t=)U!(R8KcptMhXg6E?% zezF}NJA@CKZlyS>Lr2mbo_xywYcOq2_gYt<>X|c18rHme@T73hImgd&l#Qy=af!GH zg=#G4vFn9lDQJf9ruByt3?qQlG9QYILyYF#+d$;H%HHT z`MUdbvmO%riRQ|*uGtJhi~uPdgz*iRubk4JjFhUC;hfTyHVSA4ewV@2TO)p88n=K$cZ6s};>~Tt@y0V^f|M=}Wry0Se0o0N2Ar3Xy zWR|X4P%JUeeYyf9_D6^W$4yuyF4L8tk7!EP1XiXcJ~Zm=XuTo`tME#v^ANt%GR3jh z`*H`&vfDpV)u`292A+u^KaX;SSVeU+oFwwP$Aa}vv2{tjEh#SyvlGJrQkf>edb_%4NA5{nm`t=h6M__TI>Y@o zd@|;H2dPZ8Nvsv8S+7eA*w$k-0*wbxd+hp&cfHzvWS(FXw-5ou$F?uVXMY(&xAu5$ zeb`CR6`8-xLGuxq2gLTpGj|S)7U?Lo0(OV$R+TAFm{iN;zI_1bn{RV?oYd3NbrRP;M4+z}jKCX%SA& zXkL`FcYn)@AHC4}HR{v7CF3c=GUst^{MPpY@&q4E`b6TkFJ0#CPA-GXz3(FZEcH%if8&e8s5gpx_ItXu z+rbX;mGAqL!nVCr%K+r1rW3y*>^xtBBX54~J;k#6R*D3#NsTR|SNG7JGpN-*jUyJ4 zc*X~iDw5~w#A~2YK69GEdmY@b&|`FnmW*2bzO73$?upSh49FIn-QX8{d+w+BvFLK6 ze1_8L^)iC$^smshpQ}fH*}5$oF1*?{q@Ua=VXy}WQ5u@-0!4NNS^&DV?Kjv|e&|Rz zA@}Vfc|%k!`u98{h4W_o%33-eGa!KX1;vcUAUkcw^?(G@A={8Z-0evil^( zxi$Nat5R6Dd+&*~@mpW!ufzosEf!~) zg`~EnPlEKWahmhRm-xvZ%gU6oIgT`yPLX0;Yd%Z8^67?+7C>e4M0q{s+DIK*yZPnk z#HYe?@(s6(A^uM`g7JIXv8Bh9dK*y+Y-es@n9^ohL@k zf{fo8FDjMpy@l=l?((3~)hV1^jh>5Z`q1#|c{Ur4Owv^yRP_J3F9SarbN-c6wcW8C zmv%Y_&Yn?#y9Q7;p`rOEu$O7zT7NeE7F@5Yu2%xIL^o6PIrAj`5~iO6FYz4%9^T+c zyRzajZq9~&V;}I>TxR-p&FBc?@Ikx{mN{bHaOxyoAaUdDC!8m06puB#4Zx6KZEN?mu(81W! zBHC>k2Wj^xJtILa_J&K9YWI!$3WI4s9d4!-ZwT(&VVYeL3EqFyC*J1C_m?h;JzYIC z#RK#`s0p{lUlaFx;vLx~UsH!$%{03R{TqVKP0tot?s`FU7U*yKIIRf&mAFN4gJId& z$t!r*r&N^)3*K%Hy_c~1c_}fX?fa$Ry)B>T2iO`aXPqw~qHD@yz^$bM+?38NM}v>n z4RJx@GX08*VOlFFOw~2&FMR0)r<;?C;*vKiL_}OAWM8RfiOEBpEF(f+*7+$oV*rJ? z$(JFDYtj#smw2Cj*Z9W7Z|I)=rCeYGkG*kNx&eZ&#pvkG*}e8$J3O=~>u`rsg*z}( z|6tn;{H6{H`>VHVo9A>>!yY<+37h3*+gMy&Pg7WC#k)#M{TaY1@2y_4gdh0QAB@gPdi+s9lngwslGcc$eM6 z%h$M0W(2(;vbJGQ`o$O?@*|v>nn6M}?e?cYlCI!%s*h+8k(K9j|A2*d7N3Xyt_hfW zkq(wqU@VgtC@_G5DXHwlDhlI}@HjV8fTV?W2k1u?Ahmw;B~~V54c*EG-fGY>2ek)R&rnh{kNQlNXyOEs58%dvci0o z=P~MtN9`DEpP@)zKYhT?=1t2h_?P}?jp$Egs}Ym0dxfD8;O61(%vq(sA@N0E03a6XlGY>bWX9AkH;`D&V^e4gh|)KV5H6!=P1q>qg@2GJ^vY z-iO7g_Y*ghkmEah(wF9cu?8E`1>6#WR}>%Wr{pZ4XLx?gSmGpr&Nlmki78x`5TY(r zm;;};R7SUg+J#A9EL2!)pkpn#O>qL@hf-`STgACm#UXnUJ60+&WPgmt%2~_MrKe{qcD2Q7<0SBNdaJJ^2hvw z-2cx6%K9yoo0Q2tNpK*TIl%WfwaLkfPCgl2FE|Dl?9z!0K){j-a&~~!B_C-cYqr8q z2xoD*nv4ERVN~UOytn6f37Q0qNn-mJPcpW&{$iD0r+E!sVL{}H_j>@T@6i=p z%g=wj65{pJ@ZdgIFY@rml@ofWQ6FVvi{5h}`DNCGmsm|hq&Pj{(w0oA( zIwfECJWAjwe-@z_(b9u0GAp5}Kz*3+4;lhwHp-PzFG(#INb=&qJnHT6>W0F5@q^9I zqSD46bc9ja9@PDQx639Lmdf91_gEOzZ0^%&4VT*HrYn3JZ?OsPv5D6{=hqbN!}+%y zS*b3oThI30v-NbjIlHpy6Z>o>-oHqZ!nJvJQyQ{csIWzy!!(k!^iMpkPA?qF`%}r< zwA-J9l}d|d0+nLCeAgjC-84w~+feb?MYe83`x>(p9g1|RX)Zy)&hR1q#!hya(8?2| z{o*ne%)#CBx2kEuJwx@Tw5o04FheTr$phxU<^rV}hFX6O-okHhPVmrC>9*Aaxi#^v zGEUJNEOzZ~4*bb(Fa26kdDiivIG|F*kidSJ!{Acp+hZSWXnzm){W;;{nVxmI&pi_k zk0qGChw0djSm9HDjQz;WyY2heTT+#MeZ~)*`49)`LQ6&Ki?h7@3^9+Zxa8`cRVZJF zf8GbJ>ZXb8%1`+oZyOeNCN4&^2^OpsXVd+MB)|-MxnX49F>*B6ggkQ_nP?btEfeT) zx7FX?ch9z4$S}ZRRB4IkvBn2DgMSFW@}dWj8u^x)+UsyiBnIzuu=~bJs~#%9t?Lz4 zS;LE3+4@cgb9}fC_>(4K#@9E7#FeQ_k3^lr6K2uhr0pyb>CT18W#dmgA zi{Yf4gAM#(J;t(WQd!R^ato*%N~5QngKUTLpEJ0qck+a6lSA)6i`93OyTMFky|`bo9!g~c+f*ulG?3zY);6CLXFn27b` zdwKQcn!Mbh>(ePu<1Ars}>wXz_^}?L(Nvk`?R|A?@Bzi{o^%u8_`4&c^K>M9`6x={%LEek$&5@LuMq{4A zG588g&n>cJcXto9y6nWHwrZBPdJVHQrA$6s_1+eJ$yxZsWtnk}c zqpA^#6=wckMHeI zNS8~ZWUkktyNz^O!w-{=u&!<{4FW%)a@6Gjsz6ddUpYq7Y4RZ zFh1+D?qpXflrL&M3BN2sGdXtS!z2I7z~*}*Iw33|Mp8`!6+|2u+{c(E-;)7ntl zdE{?#du(4{|H!Grd28=#tXsr|N59m7bUIHETz1^^Td2EhCT4!8-r*;^SHB#t%3}Lq zEF$OXWN4|!R>{%Wwb#ph0WFfkBYmWU-IZ9O-l~}-u^(KP4k&U0ju}NJNpL?7xTWr| zBr&^xAxxQ~a2`!KFemjyV*a3Pk&c$#Platm#$S$=!*qd<8_}r~Gp*sOR@5H6|HX!r zhC`K`Lee$oP)9dbW_yoMxvA~?*XJ=fim9>;P7Ug@?TMgPMPBv*qnr?J zjY*>%36K6I@4&ZjtZUlra`x3}He9YM(Kd)-ftyY~jnA9gY6NN))8gC!8-T>^i2ZUApsc2BT?KdHYKg$l3@uOt?oC%G;u zwcA!4)ikg2pHvShxD1z#I;NShu7*|DDxdGYq1d-Ic0l@I%44?xojjyP_PwV$f_s^ zK>XvTCi<)qJjQ@&PQJzo2;`v%D?IwMMyvL!{G<1NXs(*K0Q(me6v9SQTl{NW@ zl)-xr!4N=nKT>c$FA0ira_dp6a; z(9ej2IicooUL5u%@50Vp!^9$CZatp?FXOsS6#BWMk>&D=%P*;Z&}=w?Sk%XdxmiTrt^PJdG*pKWB^b z&n&E#^=rSbLy*lUz=~GhHEnfcaDlsNg}PLA}pt;W_ym zI-_{&U#jDW zIzr%wY7Y{6a##DOQH!|W$Q_MEV>H*GFP87JdqI0BgxKg(hw507WqkJ5;M8Kd`3|cw zCpVQP@B4YC0Ajr-h)ml(;Fj#jvPKS&*65muMhQE29GF(Sd~qS*I$!DOr+0miE?pVy z)hJOYB5D&tFFzCKIaM4e*QGM1Jd~1W{GL0g7ss=B*XyJ`JxD%@? zL8IL>q*q)Gk$AsWJ}Xn?Q#q_dX@CXaY-0!yYD-Uk+dFR6zpiEYdhn=M9*^jXx>S19 z96Pe~6Q`y`Q(Nw%TKUjPS*SA(NvXHHcNCG)S4djFo5^&3)tkTLS>a%@(sIS^G zR4gWH>ux_dLVnugEFB0-0U0Y8`ee=P;A`$rjYius1vwVsY|p!ckK!~>zL;yvlSV8~ zQBj(TMfVn+u_U9om6N3rRItyL0kmM$5QW?SPT)7h)3fsNz3rIPyQ(3X*VMAqA_JM% zRqp1(fv4Z=E!BRI7aAzYz55k4}SJg{%fQmyQrgtynuC) zDW9;D2yhTjMj2LiX^u3Hs4Fg}AkT2#6OKeMMM7dFU$mW(gZ|A)jZ-!sX43P2?fra? z>|anQ_OVs&dX||~LhipVK_yX~X7H?4|I`eaDPHF2L5^H1okd%Mv8zpb^;AE94BmW` z2saT-HagfKj3ZAg(WH5L+HpJ%43O>T7I*HQ^Tf6&t;ewP{`AmMXQ7{iysr%4gGC$V zUjlA?E2$@uE_&Gde)%?<|H4GbSUTCaEZ-@b&r<)AMm2_F{ukpkIP{KG8}QUkDmV|3 zo8tGMJON&FcU)esbl*L{c>E!B>BvTk>hR#T1^_m0j|bWrqYmdPY*ZvAzX5c=P37Y%zJN(%-Wc=~qd?FlGa1aD~0OPw31k`ovOW6&V zdyZIFY29=>DDaw^$3a5Q15nq^v~}S&8vCa+T6Ce6hb3-Ot*Swsc-_~!C_~h23o&eW&>9K}l z070csF6vMmM@Bc8sS<4xcZ+bZmnz3!&8bkt{gcJ+nB(f=8EOR{X2)>zgEz1dGoRwo z+F;L^@yYi~#pFlz*K~@RNx87>EBKZgt&F}tMSwPMF*pJCXQ`#Z0g{rZ=_p0k%VMp* z%m-S%qWQJ%f58+$GD~3hdVYJ|rWL1xdgHjjw2c9W6d8dme-GR(wr;e@d70K9SP6V| z;YNA7#fOy+-Km(j>*WhbVw<3wjzU`lc>d3cdA97v6Y7A*S}t$>^=E;SvzOu zvqfTxd$y3RO^=vo=sD|OZf_=|7q3Uyg1^AlKqf1M2UL!Kfo*(V@%Q#XD>lSxI~VB= zz7yNe@p&zxDbarC<%@>O!P5Sv{)s~g`rIi7Y1 zf4Es3Iq1^)#<_XaL1Xc=GRgM-{Q-tVZ}_pqap^g^BIKe$``MRxM)RCT4nbMQx}_;S zEk2rkj>p1bD&cffXTg{}aWZM0=n@&NBG+YTafh%5}YPRRd90*D86Pn;*@b#B0X=1u4n0D&MO2Lyk!gG?#myqjvmDd;u8(Y$X4e zPOp%7<)8lrfI{9Ki01Z>qOY2>!dYyT(Y!JN;${A3a{Afs5HNRa0^oFU2WR)t(?g){

QYV+=8nL&|Y%Beyf+>kI(sGa$&LQ3&C6O&!s2 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

=9Y+My(OG@uY^x~Y&G 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 z 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

*S>QcBb$GxI?U$O`L zPhk#;dlYPghn(b~&YjLrYfHc)RtNlU2uy=~`OQ2hY~#aM|vXLLYZc-&ZrbMX*x6+&+Nj zT0%8NGSwJ+aNr25-W^#Ss!oEh>|5Sozff2AMc41JRGCAJIpdL+wwV3rY;HaL&~Ws1 zSZtq^3_FXql1lClr22{D{Y+5iDYX=52;t)&!@xH7drC`@#j3Le1W1EmwR=JNCH>G*>>=!J;V~L!uCTVA1?1v}rX+50Dik zTF;Tr>plg-nOWI-KxE?7YZ%pFajU!juul7m{`{+)<({-LYO>T+kt&5g5?t*=TXEzw z-@evjq2U^?^OI6jXmcxKp=xDC&l48?mOjKGcTYTnRF!4xy-Rq_(^ZS-5&CH z`=?>JhL_=Mipd$sHA&V#lKD)m*KB(g$?S5JQ!ZO|6o!HYUR$PB#jIR0lI~+4I?Q}< zk@UrEhJy+z2IM*Z>yKHBP$t+a?8Yf#)!rMDegXLrNtQ5m&#is(INo|ACnT5+X)=q+b|~`q>FZ5UMeyz$Ypbz^@YvCo+ORl7cg!56;Z0(YLeSIy zm8+cL(io{8dr0>t_lh%=MVE*ZS8hd%9OC#uURz9U4v)veFub6?4b$Yr^W9ke}2R`P*#ZRe*<4^p{nTTIgn-}~ zx3aCS*x*`Fr{7#fkcaEbdJ(a1rKSBbj;7`1J_^`b2%pj3z|% zJtMukmpFsyn-LEW(2Q~X7VAwL#CZtLz_HS%fH(tqs;b^ybg=*)FF0aK7MIH{ zjyRJ_E5;W#Tm8hZ1llFs1fgLXRwd*bv(O?Qujg3-7R_)IY&_dc2SS(bm>4=pCB}^) zc#ow3u%mD;5D{=_fl^RL%s3s0vHyw(PHapLj~Da}W{h3LsB%A`d`MgY!bswsDCGR&-2dvA-E}eFw%MouuJpqwZF3A@=YRG8*H!;* zw}CVz?f=FRQmI7atqZ(L9*nVE!_i8mNdU1;n)0!<<;dP3B3xAB&BUaW8Wi*`-*< z$oFQu>HDn?qQbh^(C4)FLe?eBP zc+bWd2XWl|xKsN6s<`y|UxP*ZRt(Zw=aZcAzUI)#cuh!A#goJc;OpA~#>T%(y--NL z;{pQIq++GwCnfkxV0NSz+XM>h8+wZgr!`e$fSx|$^ayT{`kv1f+H3Nk^>c2;%P_1M zSVg7<7PiZ@>*hVOR4xk>AZk>~=G`Ec=ky29U8ukKCH6+**_XPd6~XhrMlEv8cV*u& z-qH{&_O?Q`qNr{wrB1btiAQ%zjeRy`X8&gVRNL**Q(5@zR+b$pvOT5ZR_07DOV{`HN9KI3 ze--UCE`MglN6uqeeoe4CYkjwzd~xeVXHCh}E58?4=Zg~m;r{a~>Dr17iYaA5iG!^E z^_76CRNtz3`Pmw$KWClN2%pHEMG`|}>pk(Uc-5!nr{MQBJ!JcCyZNOr2y^g_S^lxn zLa&_Nknfm!y6}&6uF-=G$J-xgzi zHSHBN?EFg|FrWlyz~9)1lYjBYl?E(2F440&C4q3rroEODH=0|th&choYXEwQfo!g` zFn~BcSxz%(0M0S?rPCsp;&KvvZNKZ1`GAg(W^0Re-S)_CHq3rdsyGkGHa&kf*L-oj z@M!m}a_)}-tuLF>rSiw>XJK0yu4BLlyubNmt`4=nQ4v0X<*W```ZOxkmob;gl&NSS z$G4SRkbW4@=VtIW+GV}-^>uPMsAOHiNS~d=db0|&3Bi;h8{kHG_TYOQg`>0TvGiey z=*5ngxo?L5U}hx)@36^u6vk6ju#W_Pm=orJeNfLekCyBns9sz(|3b@SN6PiQ>9kJy zXWpk%U``A>>U8@9_b9RA86Fra;wulJS_4VUSRDD^=C@S^`Dh|6Q}T z`C+)-PKdxCM`$e}hmOV9sB>w>q*YxqN9zO#1^!S0rKahR1^DDi9s+CkfK7G&5$;?c zw}cnivMY5Nxa*IYbL#;sC*)niaG9)$88bvdZ=}FXF8c&!7oCa1CkBFrS=7bjdaRQ) zvq-KIu_-gm96hPMy=E0cWf-BcQ<ry%-uXfK-r&$tC<{<_kHUY42uOyckEEufFeEX7apYThA7zo@(P$snrt0^X7|wrG z%@T%{z7u+s%Ir&@laR@F$KLrMC-MNCgUW)4@N%;XW>I5+f(2B+ru!@Fl?o)ZL~ zd6Y|&h4)K(MuoNd*%lnT`0FoX3$Ig;-1O1nO@PzYyl^RUD%(CuMYtWLO5of$bHqco zR?KA|3$rAScV<5r*v$97kki__sAd$C67(Bn2fRLF2Tk{cEq62jXIeuH>hr zN1lWMCMffS@$1Dp&x`@z>S$Xqd9DE!c-NlRagCa?bR59>dPf}xZh z-wJ-cLjv|Z*4>kYDCx+iZ`#UQm)T(Oy!VN36G{PMt5(q3Td zaNAt9MwZi-d3$n<&gYy{?iM0^j(S*;62Vz&jhdWa?~f{GO~~yTJ0p|~OZyc~UK75h zW?f+I#aE$#vKUOsyI^4E@+@=G_8ABC3o#RWe#-WrnYQYk_CU#)%a{-U9^5BWQiR>dRMpG^Yp0x4~@+CXQ+q zoZAZ~Mf)E4lc|UYwlGqb>A(xWZ+et_mv;xqVG$}i%b0?SGIr}kP`3%5PrHi<{{vDv zt2sg5Ss;Eaa_h^y$up@MDbBAvet{OlQ{B8S4`@0ke)am$t9JxW2Vj=mD9m6q5rrmY z?Og)Qw<9I8!RW=jc}$}87_axGm@HkaZ%m5Z5Fb&u9A#K*IW`%CwhXAcl+qhn=%-8r zBu5hI(}HOexkd5f996AFA6k6;7l+F<-iTilSc(u#$uX1ZF5VhPoIUr68KTC-WX|nv zY%yTPLI+Z;co<;k-=rkOdkjxyU(bf1dI_a&Jlb!L)5iiWya$-JUM|HtGF6~~6t0!V zOWT?zF?|=p0?6r%dl@9Tqnl(2T)12q!`5CNft7W2 zZ%wM^XTp3fyYh?iGL>A$95%PX;bb~D(r@s2_@i2lkj^K=HIq)guu!6}d1J#My732L z6FS%3HhDmOUsD*lTEaIU%c3iPY))xI)(N%>PHpdn4Pl)HBWKUWuXdS5oI0XQeq3nu znR-8STblSe=UDfALmAG`Hfy(D{$c1n8a6K%c$8AnfKn-&+430ZCr!P?dwo=f#k)fv z0>+?_vQmfC{|V(B7v_uir=iwlvSD{^VZlTf_*Z9AeEbgnnq&L;$y7=(PmeToF6P_0re z9boLN$4YaU4WmxghKRwMxOT~8MZ6Js7WH2}oj5V!TrtQ@mjTB|*?2lt z%FVL?ac)XwcW9O)pjAlBPnj^KGBtL2yrriO$*0Ovvx{dCZGy1)fPOJoOl@0N9SVBx zwbrEw-ZYA~k|PElSK+N?Mh8BokS!fqgHr;uG<@5D{jvjhQ2(_6k8s4BLUHldC&UZ{ zB$rf}ZJl%fBpnMhKQ}vF#Pzy_2C3el9k=xIos{sVGo=G|zzPfV5^vcP8rEvdZG6ET-J0HVE_V2LwoY$;-iLC6EBi-vYv@3#ro0sDSkFBu(uzQJz(Mq6`eco_&V-?HnN1XUH@fbe*a%U&9{^MvB@OctfdQEf+H~Unb(x-4)dbN zPkEduX+@#&-dq?kh>!9UB(>%}iKd{LLGK8>tsThGgb|a4@B;p4y62OZlgb%`(6Ah* zCe5xBB=g${YFs=+7Xr*p-p13@fJh4$xoI9&D?0iWO=dfk3apgua+}o(&i^AHs@K|@ zpYY}@;Z5X<-i!<_1d+pM7K+1q7F~cyq)lcR-$g{688kbcSPi{!?va17gK{>!5IpW^&YHh2dT5V3r4G5_u5{`PbrA`sA8 zTyEpdujOo?_)n|@;P}4@X?%P6rqFD9LcAFCi&yYcx)@BUsN_FS2X1Rg*Kb}X4D5c zQv3mu(6w$$5M*BU48@OzK35ETI&di(*Su6;c}Jz(BJEnutAKCx$Q!ouIbO}Ta;5iP z-rHXdqqw&>gnMADRQ6ZH!&d}9m3%$6`FZ}iv23i;r3Efb0oDoogVlg2Qn@yJA?EUC zR=N{6!w%+gwC2aS^x7LcR&^ZNLT#62?=0qANMH0_E?^D)BHLSUDcQT~I~?SAnHyz-shc)kZ=8bQ(E&#`T|8)FEcnIrH{W+?1hss0K88N7LWTa7x zWGafsG-wg$6B2$qpGFu)7oC`o%joV=BV_BS{_jai6`3}c0l2wDG{1`u1IY8HT~-U+ z)KLxhV>1c6F=9$>kb^hNaVSBcotj`6ElsAvBI$X3O-8W??BZPL5?X)+Q{&yM1}C3a zcsHu{&F|MAD1&)0#7v+m?lv#)!A*2Gw7>1!x67eCyMVM#;%rB;hFvHj$+fBcqrl_w z$~AM(BD1IF{0lm&2=(_5qW$IhVpn&v{n zAnaB6*xaZ6w#~^SyyeqyDEZ#t+vcG#Ej!|MeyFo`QtD0hS9fUyT`3@iya1%%&UJu? zsVM7);XCjE9?8-hv%J6GW^*o1Oxpe068D1V<~wR3zw_8CWr)An&23vwP&GJV@#%A{ zSbQH;w4Io8B~>Ⓢ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 literal 0 HcmV?d00001 diff --git a/docs/diagrams/Pengenalan Suara.JPEG b/docs/diagrams/Pengenalan Suara.JPEG new file mode 100644 index 0000000000000000000000000000000000000000..cc5ad460e8519ae0bcfac078a2493e44dddfb9ae GIT binary patch literal 183340 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

)#}72r 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 literal 0 HcmV?d00001 diff --git a/docs/diagrams/Use_Case_cv.png b/docs/diagrams/Use_Case_cv.png new file mode 100644 index 0000000000000000000000000000000000000000..129fa0e0c1d51f59f9a6eccf914355d2e76f49d1 GIT binary patch literal 117648 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]`!eCiYGC+RdFy-1|!L+ZRrI$Sf9vWL+3U-onIh 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 UJ8I1Z{s%=yLQ%Y26c+IR0lV(ZtpET3 literal 0 HcmV?d00001 diff --git a/docs/gambar/Waterfall.drawio.png b/docs/gambar/Waterfall.drawio.png new file mode 100644 index 0000000000000000000000000000000000000000..552fe8073f32e814b56c4d6fb87bce3c7edde69c GIT binary patch literal 40707 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

Eh|jch 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 literal 0 HcmV?d00001 diff --git a/docs/gambar/dfd_confivoice.svg b/docs/gambar/dfd_confivoice.svg new file mode 100644 index 0000000..ab5ac9a --- /dev/null +++ b/docs/gambar/dfd_confivoice.svg @@ -0,0 +1,6 @@ + + +Data Flow Diagram Level 1 Sistem ConfiVoice +Klasifikasi tingkat percaya diri berdasarkan analisis suara +Versi PNG berisi diagram lengkap dengan alur data mobile, API, model SVM, database, dan admin web. + diff --git a/docs/ringkasan_model_metode.md b/docs/ringkasan_model_metode.md new file mode 100644 index 0000000..ad5758f --- /dev/null +++ b/docs/ringkasan_model_metode.md @@ -0,0 +1,21 @@ +# Ringkasan Model dan Metode ConfiVoice + +ConfiVoice menggunakan pendekatan klasifikasi suara untuk membedakan tingkat kepercayaan diri pembicara ke dalam dua kelas, yaitu `PD` atau percaya diri dan `TPD` atau tidak percaya diri. Proses klasifikasi dilakukan dengan mengekstraksi ciri-ciri akustik dari file audio, lalu memasukkan vektor fitur tersebut ke model Support Vector Machine (SVM). Model dilatih menggunakan pipeline `StandardScaler` untuk standardisasi fitur dan `SVC` sebagai classifier. + +Audio diproses terlebih dahulu agar formatnya konsisten. Setiap audio dibaca sebagai sinyal mono dengan sample rate 22050 Hz, bagian silence di awal atau akhir dipotong, lalu volume dinormalisasi. Validasi dasar juga dilakukan untuk mendeteksi audio yang terlalu pendek, terlalu pelan, atau mengalami clipping karena volume terlalu keras. + +Fitur pertama yang digunakan adalah Mel-Frequency Cepstral Coefficients (MFCC). MFCC merepresentasikan karakteristik timbre atau warna suara berdasarkan persepsi pendengaran manusia. Pada sistem ini digunakan 13 koefisien MFCC, kemudian setiap koefisien diringkas menjadi nilai rata-rata dan standar deviasi. Dengan demikian, MFCC menghasilkan 26 fitur numerik. + +Selain MFCC statis, sistem juga menggunakan Delta MFCC. Delta MFCC menggambarkan perubahan MFCC antar frame audio sehingga dapat menangkap dinamika suara saat berbicara. Sama seperti MFCC, 13 nilai Delta MFCC diringkas menjadi rata-rata dan standar deviasi, sehingga menghasilkan 26 fitur tambahan. + +Fitur energi suara dihitung menggunakan RMS atau Root Mean Square Energy. Fitur ini menunjukkan kuat-lemahnya energi suara pembicara. Sistem menghitung RMS pada audio yang sudah dinormalisasi serta RMS pada audio mentah. Selain itu, sistem menghitung `peak_amplitude`, `clipping_ratio`, dan `energy_stability` untuk menilai kestabilan volume serta kemungkinan suara pecah akibat volume terlalu tinggi. + +Fitur pitch atau fundamental frequency digunakan untuk menangkap tinggi-rendah suara. Pitch diekstraksi menggunakan metode `pyin` dari library `librosa`. Dari pitch yang terdeteksi, sistem menghitung rata-rata pitch, standar deviasi pitch, rentang pitch, stabilitas pitch, dan variasi pitch. Fitur ini membantu membaca kestabilan serta variasi intonasi pembicara. + +Zero Crossing Rate (ZCR) digunakan untuk menghitung seberapa sering sinyal suara melewati titik nol. Nilai ini dapat menggambarkan karakter tekstur suara dan perubahan sinyal. Pada sistem ini, ZCR diringkas menjadi rata-rata dan standar deviasi. + +Sistem juga menggunakan fitur spektral, yaitu spectral centroid, spectral bandwidth, dan spectral rolloff. Spectral centroid menggambarkan pusat massa frekuensi dan berhubungan dengan kesan terang atau gelapnya suara. Spectral bandwidth menunjukkan lebar sebaran frekuensi, sedangkan spectral rolloff menunjukkan batas frekuensi tempat sebagian besar energi spektrum berada. Masing-masing fitur spektral diringkas menjadi nilai rata-rata dan standar deviasi. + +Selain fitur akustik, sistem menghitung fitur jeda bicara. Fitur ini meliputi durasi bicara aktif, durasi silence, rasio silence, jumlah jeda, rata-rata durasi jeda, dan rasio aktivitas bicara. Fitur jeda digunakan karena pola bicara yang terlalu banyak diam atau ragu-ragu dapat menjadi salah satu indikator rendahnya kepercayaan diri. + +Secara keseluruhan, sistem menghasilkan 78 fitur numerik dari setiap audio. Vektor fitur tersebut kemudian distandardisasi dan diklasifikasikan menggunakan model SVM. Pendekatan ini dipilih karena SVM cocok digunakan untuk data berukuran relatif kecil hingga menengah dan mampu membentuk batas keputusan yang baik pada ruang fitur berdimensi banyak. diff --git a/ml/.gitignore b/ml/.gitignore new file mode 100644 index 0000000..116f0ca --- /dev/null +++ b/ml/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +__pycache__/ +*.py[cod] +.venv/ +.venv_python312_failed/ diff --git a/ml/.ipynb_checkpoints/Untitled-checkpoint.ipynb b/ml/.ipynb_checkpoints/Untitled-checkpoint.ipynb new file mode 100644 index 0000000..363fcab --- /dev/null +++ b/ml/.ipynb_checkpoints/Untitled-checkpoint.ipynb @@ -0,0 +1,6 @@ +{ + "cells": [], + "metadata": {}, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/ml/.ipynb_checkpoints/app-checkpoint.py b/ml/.ipynb_checkpoints/app-checkpoint.py new file mode 100644 index 0000000..c95ad33 --- /dev/null +++ b/ml/.ipynb_checkpoints/app-checkpoint.py @@ -0,0 +1,312 @@ +from datetime import datetime +import tempfile +from pathlib import Path + +import joblib +import librosa +import numpy as np +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, SAMPLE_RATE, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +DATA_DIR = BASE_DIR / "data" +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" + +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +MIN_RECORDING_DURATION_SECONDS = 2.0 +LOW_RMS_WARNING_THRESHOLD = 0.003 + +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_bytes_to_temp_file(audio_bytes, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(audio_bytes) + return Path(temp_audio.name) + + +def save_uploaded_file(uploaded_file, suffix): + return save_bytes_to_temp_file(uploaded_file.getvalue(), suffix) + + +def validate_audio_quality(audio_path, min_duration=MIN_RECORDING_DURATION_SECONDS): + """ + Validasi dasar sebelum ekstraksi fitur. + Error dipakai untuk kasus file kosong/gagal dibaca/terlalu pendek. + Warning dipakai untuk audio yang masih bisa diproses tetapi kualitasnya lemah. + """ + try: + y, sr = librosa.load(audio_path, sr=SAMPLE_RATE, mono=True) + except Exception as error: + raise ValueError(f"File audio gagal dibaca: {error}") from error + + if y.size == 0: + raise ValueError("File audio kosong atau tidak memiliki sinyal suara.") + + duration = librosa.get_duration(y=y, sr=sr) + if duration < min_duration: + raise ValueError( + f"Durasi audio terlalu pendek ({duration:.2f} detik). " + "Silakan rekam suara 3 sampai 5 detik dengan jelas." + ) + + rms = float(np.sqrt(np.mean(y**2))) + warning = None + if rms < LOW_RMS_WARNING_THRESHOLD: + warning = ( + f"Suara terdeteksi cukup pelan (RMS={rms:.5f}). " + "Jika hasil kurang tepat, rekam ulang dengan suara lebih jelas." + ) + + return { + "duration": duration, + "rms": rms, + "warning": warning, + } + + +def predict_audio_path(audio_path, validate_quality=True): + """ + Fungsi prediksi umum untuk upload dan rekaman. + + Urutan: + audio_path -> convert_to_wav -> validate -> extract_features -> predict_proba. + Label utama diambil dari probabilitas terbesar, bukan model.predict(). + """ + model = load_model(MODEL_PATH.stat().st_mtime) + audio_path = Path(audio_path) + extension = audio_path.suffix.lower() + + if extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(audio_path, temp_wav_path) + quality_info = validate_audio_quality(temp_wav_path) if validate_quality else None + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + predicted_label = max(class_probabilities, key=class_probabilities.get) + confidence = class_probabilities[predicted_label] + + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + "quality_info": quality_info, + } + finally: + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +def render_prediction_result(label, confidence, probabilities, debug_info): + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + quality_info = debug_info.get("quality_info") + if quality_info and quality_info.get("warning"): + st.warning(quality_info["warning"]) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + st.write(f"Margin: {margin * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") + if quality_info: + st.write(f"Durasi audio: {quality_info['duration']:.2f} detik") + st.write(f"RMS audio: {quality_info['rms']:.6f}") + + +def get_audio_recorder_input(): + """ + Menggunakan st.audio_input jika tersedia. + Jika belum tersedia, coba fallback ke streamlit-mic-recorder. + """ + if hasattr(st, "audio_input"): + return st.audio_input("Rekam suara") + + try: + from streamlit_mic_recorder import mic_recorder + except ImportError: + st.error( + "Versi Streamlit ini belum mendukung st.audio_input. " + "Install fallback recorder dengan perintah: pip install streamlit-mic-recorder" + ) + return None + + audio = mic_recorder( + start_prompt="Mulai Rekam", + stop_prompt="Berhenti Rekam", + just_once=False, + use_container_width=True, + key="mic_recorder", + ) + + if audio and audio.get("bytes"): + suffix = ".wav" + return { + "bytes": audio["bytes"], + "suffix": suffix, + "mime_type": "audio/wav", + } + + return None + + +def get_recording_bytes(recording): + if recording is None: + return None, ".wav", "audio/wav" + + if isinstance(recording, dict): + return recording["bytes"], recording.get("suffix", ".wav"), recording.get("mime_type", "audio/wav") + + suffix = Path(recording.name).suffix.lower() or ".wav" + return recording.getvalue(), suffix, recording.type or "audio/wav" + + +def save_recording_to_dataset(source_audio_path, label): + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + target_dir = DATA_DIR / label + target_dir.mkdir(parents=True, exist_ok=True) + target_path = target_dir / f"recorded_{label}_{timestamp}.wav" + + convert_to_wav(source_audio_path, target_path) + return target_path + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Input audio, ekstraksi fitur, prediksi SVM, lalu tampilkan PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +upload_tab, record_tab = st.tabs(["Upload Audio", "Rekam Audio"]) + +with upload_tab: + uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, + ) + + if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi Upload"): + temp_input_path = save_uploaded_file(uploaded_file, Path(uploaded_file.name).suffix.lower()) + try: + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + label, confidence, probabilities, debug_info = predict_audio_path(temp_input_path) + render_prediction_result(label, confidence, probabilities, debug_info) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + finally: + temp_input_path.unlink(missing_ok=True) + +with record_tab: + st.write( + "Silakan rekam suara selama 3-5 detik. Gunakan suara yang jelas, " + "tidak terlalu pelan, dan hindari noise ruangan." + ) + + recording = get_audio_recorder_input() + audio_bytes, suffix, mime_type = get_recording_bytes(recording) + + if audio_bytes: + st.audio(audio_bytes, format=mime_type) + + temp_recording_path = save_bytes_to_temp_file(audio_bytes, suffix) + st.session_state["latest_recording_path"] = str(temp_recording_path) + + if st.button("Prediksi Rekaman"): + try: + with st.spinner("Mengekstraksi fitur dan memprediksi rekaman..."): + label, confidence, probabilities, debug_info = predict_audio_path(temp_recording_path) + render_prediction_result(label, confidence, probabilities, debug_info) + except Exception as error: + st.error(f"Gagal memproses rekaman: {error}") + + st.divider() + st.subheader("Simpan Rekaman ke Dataset") + selected_label = st.selectbox( + "Label manual", + options=[LABEL_PD, LABEL_TPD], + format_func=lambda label: f"{label} - {LABEL_DESCRIPTION[label]}", + ) + + if st.button("Simpan ke Dataset"): + try: + saved_path = save_recording_to_dataset(temp_recording_path, selected_label) + st.success( + "Rekaman berhasil disimpan. Jalankan ulang train_model.py " + "untuk melatih ulang model." + ) + st.write(f"File: {saved_path}") + except Exception as error: + st.error(f"Gagal menyimpan rekaman ke dataset: {error}") diff --git a/ml/.ipynb_checkpoints/train_model-checkpoint.py b/ml/.ipynb_checkpoints/train_model-checkpoint.py new file mode 100644 index 0000000..68ebdfa --- /dev/null +++ b/ml/.ipynb_checkpoints/train_model-checkpoint.py @@ -0,0 +1,168 @@ +from collections import Counter +from pathlib import Path + +import joblib +from sklearn.metrics import ( + accuracy_score, + balanced_accuracy_score, + classification_report, + confusion_matrix, + f1_score, + precision_score, + recall_score, +) +from sklearn.model_selection import GridSearchCV, StratifiedKFold, cross_val_predict +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import StandardScaler +from sklearn.svm import SVC + +from features import LABEL_PD, LABEL_TPD, check_dataset_quality, load_dataset + + +BASE_DIR = Path(__file__).resolve().parent +DATA_DIR = BASE_DIR / "data" +MODEL_DIR = BASE_DIR / "models" +MODEL_PATH = MODEL_DIR / "svm_voice_confidence_model.joblib" + + +def build_pipeline(): + """ + Pipeline wajib: + 1. StandardScaler + 2. SVM classifier + """ + return Pipeline( + [ + ("scaler", StandardScaler()), + ( + "svm", + SVC( + probability=True, + class_weight="balanced", + random_state=42, + ), + ), + ] + ) + + +def build_cv(y): + label_counts = Counter(y) + min_class_count = min(label_counts.values()) + n_splits = min(5, min_class_count) + + if n_splits < 2: + raise ValueError("Minimal perlu 2 data pada setiap kelas untuk Stratified K-Fold.") + + return StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=42) + + +def build_grid_search(cv): + param_grid = { + "svm__C": [0.1, 1, 10, 100], + "svm__gamma": ["scale", 0.01, 0.001, 0.0001], + "svm__kernel": ["rbf"], + } + + return GridSearchCV( + estimator=build_pipeline(), + param_grid=param_grid, + scoring="f1_macro", + cv=cv, + n_jobs=1, + refit=True, + verbose=1, + ) + + +def print_wrong_predictions(files, y_true, y_pred): + print("\n=== File yang Salah Prediksi ===") + has_wrong_prediction = False + + for file_path, true_label, predicted_label in zip(files, y_true, y_pred): + if true_label != predicted_label: + has_wrong_prediction = True + print(f"{Path(file_path).name} | {true_label} | {predicted_label}") + + if not has_wrong_prediction: + print("Tidak ada file yang salah prediksi pada cross-validation.") + + +def evaluate_model(model, X, y, files, cv): + """ + Evaluasi memakai prediksi out-of-fold agar lebih realistis untuk dataset kecil. + """ + y_pred = cross_val_predict(model, X, y, cv=cv, n_jobs=1) + labels = [LABEL_PD, LABEL_TPD] + + print("\n=== Evaluasi Cross-Validation ===") + print(f"Accuracy : {accuracy_score(y, y_pred):.4f}") + print(f"Balanced Accuracy : {balanced_accuracy_score(y, y_pred):.4f}") + print(f"Precision Macro : {precision_score(y, y_pred, average='macro', zero_division=0):.4f}") + print(f"Recall Macro : {recall_score(y, y_pred, average='macro', zero_division=0):.4f}") + print(f"F1 Macro : {f1_score(y, y_pred, average='macro', zero_division=0):.4f}") + + print("\n=== Classification Report ===") + print( + classification_report( + y, + y_pred, + labels=labels, + target_names=["PD - Percaya Diri", "TPD - Tidak Percaya Diri"], + zero_division=0, + ) + ) + + print("=== Confusion Matrix ===") + matrix = confusion_matrix(y, y_pred, labels=labels) + print("Urutan label:", labels) + print(matrix) + + print("\n=== Ringkasan Benar/Salah per Kelas ===") + for index, label in enumerate(labels): + total = int(matrix[index].sum()) + correct = int(matrix[index, index]) + wrong = total - correct + print(f"{label}: benar={correct}, salah={wrong}, total={total}") + + print_wrong_predictions(files, y, y_pred) + + +def main(): + print("Mengecek kualitas dataset...") + check_dataset_quality(DATA_DIR) + + print("\nMembaca dataset dan mengekstraksi fitur...") + X, y, files = load_dataset(DATA_DIR) + + print(f"\nTotal data valid: {len(files)}") + print(f"Jumlah fitur per audio: {X.shape[1]}") + print("Distribusi label:", dict(Counter(y))) + + cv = build_cv(y) + grid_search = build_grid_search(cv) + + print("\nMelakukan GridSearchCV SVM dengan scoring f1_macro...") + grid_search.fit(X, y) + + best_model = grid_search.best_estimator_ + + print("\n=== Hasil GridSearchCV ===") + print("Best params:", grid_search.best_params_) + print(f"Best CV f1_macro: {grid_search.best_score_:.4f}") + print("Urutan kelas model:", list(best_model.classes_)) + + evaluate_model(best_model, X, y, files, cv) + + print("\nMelatih ulang model terbaik dengan seluruh dataset...") + best_model.fit(X, y) + print("Urutan kelas model final:", list(best_model.classes_)) + + MODEL_DIR.mkdir(parents=True, exist_ok=True) + joblib.dump(best_model, MODEL_PATH) + + print(f"Model terbaik berhasil disimpan ke: {MODEL_PATH}") + + +if __name__ == "__main__": + main() diff --git a/ml/.python-version b/ml/.python-version new file mode 100644 index 0000000..c8cfe39 --- /dev/null +++ b/ml/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/ml/.streamlit/config.toml b/ml/.streamlit/config.toml new file mode 100644 index 0000000..e7f9632 --- /dev/null +++ b/ml/.streamlit/config.toml @@ -0,0 +1,5 @@ +[browser] +gatherUsageStats = false + +[server] +headless = true diff --git a/ml/README.md b/ml/README.md new file mode 100644 index 0000000..df3737e --- /dev/null +++ b/ml/README.md @@ -0,0 +1,86 @@ +# Project SVM Suara + +Project ini mengklasifikasikan tingkat percaya diri dari file audio `.wav` +menggunakan ekstraksi fitur suara dengan `librosa` dan model SVM. + +Folder `ml/` sekarang juga menjadi lokasi API prediksi untuk Flutter. + +Gunakan Python 3.10 agar dependency audio seperti `librosa`, `numba`, dan +`llvmlite` lebih mudah terpasang. + +## Struktur + +```text +project_svm_suara/ +├── data/ +├── models/ +├── features.py +├── audio_utils.py +├── train_model.py +├── predict.py +├── app.py +└── requirements.txt +``` + +## Format Dataset + +Letakkan file `.wav` di folder `data/`. + +- Nama file mengandung `_pd` untuk kelas `PD` atau Percaya Diri. +- Nama file mengandung `_tpd` untuk kelas `TPD` atau Tidak Percaya Diri. + +Contoh: + +```text +a_pd.wav +a_tpd.wav +``` + +## Cara Menjalankan + +```bash +python3.10 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +python train_model.py +python predict.py data/a_pd.wav +streamlit run app.py +``` + +## Menjalankan API untuk Flutter + +```bash +cd ml +../cv_app/.venv/bin/python -m uvicorn api_app:app --host 0.0.0.0 --port 8000 +``` + +Endpoint: + +```text +POST /predict +GET /predictions +GET /model +``` + +Hasil prediksi disimpan ke MySQL/MariaDB agar bisa dilihat lewat phpMyAdmin: + +```text +database: confivoice +table: prediction_results +``` + +Jika MySQL memakai port/user/password berbeda, atur environment sebelum +menjalankan API: + +```bash +export CONFIVOICE_DB_DRIVER=mysql +export CONFIVOICE_MYSQL_HOST=127.0.0.1 +export CONFIVOICE_MYSQL_PORT=3306 +export CONFIVOICE_MYSQL_USER=root +export CONFIVOICE_MYSQL_PASSWORD= +export CONFIVOICE_MYSQL_DATABASE=confivoice +``` + +Aplikasi Streamlit menerima upload audio `WAV`, `MP3`, `M4A`, `OGG`, `FLAC`, +`WEBM`, dan `AAC`. File akan disiapkan menjadi WAV mono 22050 Hz sebelum fitur +suara diekstraksi. diff --git a/ml/Untitled.ipynb b/ml/Untitled.ipynb new file mode 100644 index 0000000..b2054f5 --- /dev/null +++ b/ml/Untitled.ipynb @@ -0,0 +1,866 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 5, + "id": "8354ba29-0c6f-41d8-86ab-76f296355a6a", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/user/TA/confivoice3/ml/features.py:70: UserWarning: PySoundFile failed. Trying audioread instead.\n", + " y, sr = librosa.load(file_path, sr=sample_rate, mono=True)\n", + "/Users/user/TA/confivoice3/cv_app/.venv/lib/python3.10/site-packages/librosa/core/audio.py:184: FutureWarning: librosa.core.audio.__audioread_load\n", + "\tDeprecated as of librosa version 0.10.0.\n", + "\tIt will be removed in librosa version 1.0.\n", + " y, sr_native = __audioread_load(path, offset, duration, dtype)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "=== Distribusi Label Dataset ===\n", + "PD : 43\n", + "TPD: 43\n", + "Total data: 86\n", + "Distribusi label: {'PD': 43, 'TPD': 43}\n", + "Jumlah fitur: 78\n", + "Fitting 5 folds for each of 16 candidates, totalling 80 fits\n", + "Best params: {'svm__C': 10, 'svm__gamma': 'scale', 'svm__kernel': 'rbf'}\n", + "Best CV f1_macro: 0.6554293822792274\n", + "Urutan kelas: ['PD', 'TPD']\n", + "Urutan label: ['PD', 'TPD']\n", + "[[29 14]\n", + " [15 28]]\n" + ] + }, + { + "data": { + "image/png": "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o0cPWb16tS3biNDghvd8yueY3qTvbc8XC8+8fPnySY0aNeT111/3rkfX77k/V65cUqRIEWnUqJHZX9qMCvtGP1k2/fNXfHy81KlTR6ZOnXrF5RYtWiSbNm3K0hmWCWqQrdq1aydHjhyRn3/+WR5//HEZPXq0vPrqq+kue/78edset2jRouZyFHbQUQSFCxfOdLnY2Fg5fPiwvP/++/LDDz+YbyiXLl3y3q8f1rovdLlZs2aZdd52223y0ksv2bKdCA1Of89rwKPPzzOpGTNmeG9/88033mUfeOABM+/HH380QfyAAQNk7ty53vt1FI3ef+jQIdmwYYM8+OCD5m+jbt265m8JOVv79u3lxRdflC5dumS4zK+//iqDBg2S9957T/LkyeP3YxDUINvPiVGqVCmpUKGCPPLII+YgvnTp0lTpcz2oa4RetWpVM1+vy6QfgPqhqh/UnTp1kn379nnXqYHCsGHDzP3FihWTESNGXHa127Tlp7TefPNN8/uff/65ub1w4UKpXbu2yaLoOnU79VtGyu3MTIkSJaR06dLSvHlzefbZZ80H+e7du7336wFH90X58uXNMm+88YaMGjXKLKuBDpzB6e95DXj0+Xkmpev13L7mmmu8yxYoUMDMu/baa01wV6VKFe++UJql0fv176Z69erSv39/E9ycOXPGPEfYM/rJsmmyW1JSktxzzz0yfPhwqVmzZpbWQVCDoNIP0JTfTvUDVg/oq1atkmXLlsmFCxekbdu2JgBYt26dfPnll1KwYEHz7dfzexMmTDCp67ffflvWr18vJ06cMOlLX40bN05GjhwpK1eulNatW5tvij179pR+/frJzp07TTmpa9eulx00/H2evnwTf+yxx8zjLFmyJMuPhdDmlvd8VvZFRl8OevfubYKflJlOZO3cMnE2TumtMzExMcsvzT/+8Q/JnTu3DB48OMvr4Dw1CAr9sNQP808//dSkGj0iIiLMN0jtP1CzZ8820bvO81zJXFPb+k1QP3jbtGljGsmeeuop8yGspk+fbtbriyeffFLeffddWbt2rfebgX7Aa8+Drk+/XSv9BptVur7x48dL2bJlvd/EM6LfyvVDPOW3cjiDm97zmdHgRMtO27dvNyWmzFSrVs1cxfr48ePm7wP+yZs3r8mAVakUbeuu02A7Ojr1OvUM7ZqF89eWLVtMKfPbb7/1vu+zgqAG2Uq/ieofgn4b1Q/uXr16pfoD0A9Sz4e7+u6770zJJm1vQEJCguzZs8c0EOoHsjYUemikf+ONN2b6LVO/7Wp6ffPmzSYd7qGNbPrtVbdFvzHrQaR79+6mcdEfegV23YazZ8+adX7wwQepnltG9Heu5o8aocVN7/nMaGOwBmuandFm4KFDh5qSXGY8z4u/i6zJly+f7N2719aeLc/rkvY1yeolZzQreezYMVOOTxn8ah+aBvG+ftEjqEG2atWqlUybNs18iGsPgX4Yp6TfWlPSWnqDBg1M01haKWv1WdGsWTNZvny5LFiwwKTiPfTDVksBWsvX9PzkyZPl6aeflq+++koqVark1x+pNj7qN0tfGzb1m+hvv/3m1+MgtLnpPZ8ZLSPperXspH0zYWG+dUBoSUz/lrTXB1kPbPLlyxeyu097abSPKyUNsHX+fffd5/N6CGqQrfQDXIe1+qp+/foyf/58ExhkdH0R/XDUD19ttlWaRtdUpv7uldx0000ycOBA06ugB5onnnjCe59++2jSpImZtHFXU/Las6DNmb7Sg4Evo6RS0vSrftBzHhzncNN7PjPaVOzPvlD67X3OnDnmb8LXIAihSQP2lIMlNHu0bds2U3bXDE3aoFVHP2nZLLOyfUoENQhp+s1Oh7/q6A8dAq0lnf3795tzcOhoCL2tzbWvvPKKGUmhtffXXntNTp486dP6GzduLB9//LEZaqgf8jpaRA8W2vugKXg9sOhtzZ7oaAw7aY/A0aNHTVlC/7i1l0JT82PHjvX7gx/O4eT3vC/lDP2b0J/6fDZu3Cgvv/yyCYb0+SJn27x5s8lcengC5j59+lzx3F/+IKhBSNMhoHoWSm1u1CZGDQS04Vbr/55vsVpz1R4D/cPQb3I6gkPPg+DrCbuaNm1qUvIdOnQwaXhNgepjah1Xu/n1G6v2IuhBwE76bVgnTxPfzTffbA4sKf/o4T5Ofs9nRh9bs1CaNdLnqt/Q9TlqEBdKV4JG1uhpBvwZUZeVARNWcqDH7AEAAGQDCpQAAMARCGoAAIAjENQAAABHIKgBAACOQFADAAAcgaAGAAA4AkENAABwBIIaAEHXt2/fVJeG0JN06ZluPSpWrGhODGfHugE4F2cUBnDFgOCdd97xXodFr89y7733yt///vfLLsxoJ70kgD6eHfR6WpxjFHAHghoAV6QXP5wxY4YkJiaaawYNGDDABBxPPfVUquXOnz9vLvlgB73AnV30ukEA3IHyE4ArCg8PN9em0usBPfLII+Y6QUuXLvWWdV566SUpU6aM90q6Bw8elB49epgrlGtwohdmTHkNl0uXLpkL2en9elVevUhj2kxK2vJTWnrhT/19vVaWWrhwodSuXVvy589v1qnbGB8fb+6j/AS4B0ENAL9o4KBZGaVBRWxsrKxatUqWLVtmrjjetm1bKVSokKxbt06+/PJLKViwoMn2eH5HL5SoV+R9++23Zf369XLixAlZtGiRz48/btw4GTlypKxcudJc5FEv7NizZ09zUcedO3fKmjVrzIUgKTkB7kP5CYBPNEjQIObTTz+VQYMGyW+//SYREREma+IpO82ePVuSkpLMPL3SstLSlWZVNNho06aNafjV0pUGHmr69Olmnb7QK1e/++67snbtWqlZs6aZp0HNxYsXzfo0m6Q0awPAfQhqAFyRZmA026JZGA1YevXqJaNHjza9NRo8pOyj+e6772T37t0mU5NSQkKC7NmzR06dOmWCkEaNGv3vQyh3brnxxhszzaxohkdLSps3b5Zrr73WO79OnTomY6PbolkiDZy6d+8uRYoU4ZUFXIbyE4AratWqlWzbtk1+/vlnOXfunBkNpRka5fnpcebMGWnQoIFZPuX0008/mWDoajRr1sz04yxYsCDV/Fy5cpny1yeffCI1atSQyZMnm/6evXv38soCLkNQA+CKNHCpXLmyGc6d2TDu+vXrm+CnRIkS5ndSTjoKSafSpUvLV1995f0dLR1t2bIl01fhpptuMoHLyy+/LOPHj091n5a6mjRpImPGjJGtW7ea7JE/fToAnIGgBoBtevfuLcWLFzcjnrRRWLMl2kszePBgOXTokFnmsccek1deeUUWL14su3btkkcffVROnjzp0/obN25shpVr8OI5GZ8GSBroaFnqwIED5hw32u9TvXp1XlnAZeipAWCbAgUKSExMjGno1cbd06dPS9myZU3PS2RkpFnm8ccfN301ffr0kbCwMDNqqUuXLqbfxhdNmzaV5cuXS4cOHUzpSYdv62NqkBMXF2eahbX/pn379ryygMtYyYx7BAAADkD5CQAAOAJBDQAAcASCGgAA4AgENQAAwBEIagAAgCMQ1AAAAEcgqAEAAI5AUAMAAByBoAYAADgCQQ0AAHAEghoAAOAIBDUAAECc4P8BgnQY+rzykeAAAAAASUVORK5CYII=", + "text/plain": [ + "

" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from collections import Counter\n", + "\n", + "from train_model import DATA_DIR, build_cv, build_grid_search\n", + "from features import load_dataset\n", + "from sklearn.model_selection import cross_val_predict\n", + "from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# 1. Load dataset\n", + "X, y, files = load_dataset(DATA_DIR)\n", + "\n", + "print(\"Total data:\", len(files))\n", + "print(\"Distribusi label:\", dict(Counter(y)))\n", + "print(\"Jumlah fitur:\", X.shape[1])\n", + "\n", + "# 2. Buat cross-validation dan model\n", + "cv = build_cv(y)\n", + "grid_search = build_grid_search(cv)\n", + "\n", + "# 3. Training GridSearch\n", + "grid_search.fit(X, y)\n", + "\n", + "best_model = grid_search.best_estimator_\n", + "\n", + "print(\"Best params:\", grid_search.best_params_)\n", + "print(\"Best CV f1_macro:\", grid_search.best_score_)\n", + "print(\"Urutan kelas:\", list(best_model.classes_))\n", + "\n", + "# 4. Prediksi cross-validation\n", + "y_pred = cross_val_predict(best_model, X, y, cv=cv, n_jobs=1)\n", + "\n", + "# 5. Confusion matrix\n", + "labels = [\"PD\", \"TPD\"]\n", + "cm = confusion_matrix(y, y_pred, labels=labels)\n", + "\n", + "print(\"Urutan label:\", labels)\n", + "print(cm)\n", + "\n", + "# 6. Tampilkan gambar confusion matrix\n", + "disp = ConfusionMatrixDisplay(\n", + " confusion_matrix=cm,\n", + " display_labels=[\"PD\", \"TPD\"]\n", + ")\n", + "\n", + "fig, ax = plt.subplots(figsize=(6, 5))\n", + "disp.plot(cmap=\"Blues\", values_format=\"d\", ax=ax, colorbar=True)\n", + "\n", + "ax.set_title(\"Confusion Matrix Model SVM\")\n", + "ax.set_xlabel(\"Prediksi\")\n", + "ax.set_ylabel(\"Label Asli\")\n", + "ax.set_xticklabels([\"Prediksi PD\", \"Prediksi TPD\"])\n", + "ax.set_yticklabels([\"Asli PD\", \"Asli TPD\"])\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d509eed5-2719-4c11-98cd-67c157034f07", + "metadata": {}, + "outputs": [], + "source": [ + "from sklearn.metrics import accuracy_score, balanced_accuracy_score, precision_score, recall_score, f1_score, classification_report\n", + "\n", + "print(\"Accuracy :\", accuracy_score(y, y_pred))\n", + "print(\"Balanced Accuracy :\", balanced_accuracy_score(y, y_pred))\n", + "print(\"Precision Macro :\", precision_score(y, y_pred, average=\"macro\", zero_division=0))\n", + "print(\"Recall Macro :\", recall_score(y, y_pred, average=\"macro\", zero_division=0))\n", + "print(\"F1 Macro :\", f1_score(y, y_pred, average=\"macro\", zero_division=0))\n", + "\n", + "print(classification_report(\n", + " y,\n", + " y_pred,\n", + " labels=labels,\n", + " target_names=[\"PD - Percaya Diri\", \"TPD - Tidak Percaya Diri\"],\n", + " zero_division=0\n", + "))" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "6d7215c2-6d9b-4233-921b-d79e262912a1", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Fitting 5 folds for each of 16 candidates, totalling 80 fits\n" + ] + }, + { + "data": { + "text/html": [ + "
GridSearchCV(cv=StratifiedKFold(n_splits=5, random_state=42, shuffle=True),\n",
+       "             estimator=Pipeline(steps=[('scaler', StandardScaler()),\n",
+       "                                       ('svm',\n",
+       "                                        SVC(class_weight='balanced',\n",
+       "                                            probability=True,\n",
+       "                                            random_state=42))]),\n",
+       "             n_jobs=1,\n",
+       "             param_grid={'svm__C': [0.1, 1, 10, 100],\n",
+       "                         'svm__gamma': ['scale', 0.01, 0.001, 0.0001],\n",
+       "                         'svm__kernel': ['rbf']},\n",
+       "             scoring='f1_macro', verbose=1)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
" + ], + "text/plain": [ + "GridSearchCV(cv=StratifiedKFold(n_splits=5, random_state=42, shuffle=True),\n", + " estimator=Pipeline(steps=[('scaler', StandardScaler()),\n", + " ('svm',\n", + " SVC(class_weight='balanced',\n", + " probability=True,\n", + " random_state=42))]),\n", + " n_jobs=1,\n", + " param_grid={'svm__C': [0.1, 1, 10, 100],\n", + " 'svm__gamma': ['scale', 0.01, 0.001, 0.0001],\n", + " 'svm__kernel': ['rbf']},\n", + " scoring='f1_macro', verbose=1)" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "cv = build_cv(y)\n", + "grid_search = build_grid_search(cv)\n", + "\n", + "grid_search.fit(X, y)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "83e533a4-bb17-477f-9566-d765fbbf7cc7", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Best params: {'svm__C': 10, 'svm__gamma': 'scale', 'svm__kernel': 'rbf'}\n", + "Best CV f1_macro: 0.6554293822792274\n", + "Urutan kelas: ['PD', 'TPD']\n" + ] + } + ], + "source": [ + "best_model = grid_search.best_estimator_\n", + "\n", + "print(\"Best params:\", grid_search.best_params_)\n", + "print(\"Best CV f1_macro:\", grid_search.best_score_)\n", + "print(\"Urutan kelas:\", list(best_model.classes_))" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "e2279016-05d5-4a05-b199-d99c3ad2cf21", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "=== Evaluasi Cross-Validation ===\n", + "Accuracy : 0.6628\n", + "Balanced Accuracy : 0.6628\n", + "Precision Macro : 0.6629\n", + "Recall Macro : 0.6628\n", + "F1 Macro : 0.6627\n", + "\n", + "=== Classification Report ===\n", + " precision recall f1-score support\n", + "\n", + " PD - Percaya Diri 0.66 0.67 0.67 43\n", + "TPD - Tidak Percaya Diri 0.67 0.65 0.66 43\n", + "\n", + " accuracy 0.66 86\n", + " macro avg 0.66 0.66 0.66 86\n", + " weighted avg 0.66 0.66 0.66 86\n", + "\n", + "=== Confusion Matrix ===\n", + "Urutan label: ['PD', 'TPD']\n", + "[[29 14]\n", + " [15 28]]\n", + "\n", + "=== Ringkasan Benar/Salah per Kelas ===\n", + "PD: benar=29, salah=14, total=43\n", + "TPD: benar=28, salah=15, total=43\n", + "\n", + "=== File yang Salah Prediksi ===\n", + "c_pd.wav | PD | TPD\n", + "c_tpd.wav | TPD | PD\n", + "f_tpd.wav | TPD | PD\n", + "h_tpd.wav | TPD | PD\n", + "j_pd.wav | PD | TPD\n", + "j_tpd.wav | TPD | PD\n", + "k_tpd.wav | TPD | PD\n", + "m_pd.wav | PD | TPD\n", + "p10_pd.wav | PD | TPD\n", + "p10_tpd.wav | TPD | PD\n", + "p2_pd.wav | PD | TPD\n", + "p3_pd.wav | PD | TPD\n", + "p4_pd.wav | PD | TPD\n", + "p5_tpd.wav | TPD | PD\n", + "p6_tpd.wav | TPD | PD\n", + "p8_pd.wav | PD | TPD\n", + "p_pd.wav | PD | TPD\n", + "q_pd.wav | PD | TPD\n", + "s_tpd.wav | TPD | PD\n", + "w3_pd.wav | PD | TPD\n", + "w3_tpd.wav | TPD | PD\n", + "w4_pd.wav | PD | TPD\n", + "w5_pd.wav | PD | TPD\n", + "w5_tpd.wav | TPD | PD\n", + "w6_tpd.wav | TPD | PD\n", + "w7_tpd.wav | TPD | PD\n", + "w8_pd.wav | PD | TPD\n", + "w8_tpd.wav | TPD | PD\n", + "w_tpd.wav | TPD | PD\n" + ] + } + ], + "source": [ + "evaluate_model(best_model, X, y, files, cv)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "03747dfc-acbf-42aa-9814-efa02e1d1d5f", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import seaborn as sns\n", + "\n", + "from sklearn.metrics import confusion_matrix\n", + "from sklearn.model_selection import cross_val_predict\n", + "from features import LABEL_PD, LABEL_TPD" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "a4e120f6-e45e-4a57-ab29-3cf80ee91cf5", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/user/TA/confivoice3/ml/features.py:70: UserWarning: PySoundFile failed. Trying audioread instead.\n", + " y, sr = librosa.load(file_path, sr=sample_rate, mono=True)\n", + "/Users/user/TA/confivoice3/cv_app/.venv/lib/python3.10/site-packages/librosa/core/audio.py:184: FutureWarning: librosa.core.audio.__audioread_load\n", + "\tDeprecated as of librosa version 0.10.0.\n", + "\tIt will be removed in librosa version 1.0.\n", + " y, sr_native = __audioread_load(path, offset, duration, dtype)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "=== Distribusi Label Dataset ===\n", + "PD : 43\n", + "TPD: 43\n", + "Total data: 86\n", + "Distribusi label: {'PD': 43, 'TPD': 43}\n", + "Jumlah fitur: 78\n", + "Fitting 5 folds for each of 16 candidates, totalling 80 fits\n", + "Best params: {'svm__C': 10, 'svm__gamma': 'scale', 'svm__kernel': 'rbf'}\n", + "Best CV f1_macro: 0.6554293822792274\n", + "Urutan kelas: ['PD', 'TPD']\n" + ] + } + ], + "source": [ + "from collections import Counter\n", + "\n", + "from train_model import DATA_DIR, build_cv, build_grid_search\n", + "from features import load_dataset\n", + "\n", + "X, y, files = load_dataset(DATA_DIR)\n", + "\n", + "print(\"Total data:\", len(files))\n", + "print(\"Distribusi label:\", dict(Counter(y)))\n", + "print(\"Jumlah fitur:\", X.shape[1])\n", + "\n", + "cv = build_cv(y)\n", + "grid_search = build_grid_search(cv)\n", + "\n", + "grid_search.fit(X, y)\n", + "\n", + "best_model = grid_search.best_estimator_\n", + "\n", + "print(\"Best params:\", grid_search.best_params_)\n", + "print(\"Best CV f1_macro:\", grid_search.best_score_)\n", + "print(\"Urutan kelas:\", list(best_model.classes_))" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "6b93e357-b6b5-474a-a020-fc7c925d172a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Urutan label: ['PD', 'TPD']\n", + "[[29 14]\n", + " [15 28]]\n" + ] + }, + { + "data": { + "image/png": "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o0cPWb16tS3biNDghvd8yueY3qTvbc8XC8+8fPnySY0aNeT111/3rkfX77k/V65cUqRIEWnUqJHZX9qMCvtGP1k2/fNXfHy81KlTR6ZOnXrF5RYtWiSbNm3K0hmWCWqQrdq1aydHjhyRn3/+WR5//HEZPXq0vPrqq+kue/78edset2jRouZyFHbQUQSFCxfOdLnY2Fg5fPiwvP/++/LDDz+YbyiXLl3y3q8f1rovdLlZs2aZdd52223y0ksv2bKdCA1Of89rwKPPzzOpGTNmeG9/88033mUfeOABM+/HH380QfyAAQNk7ty53vt1FI3ef+jQIdmwYYM8+OCD5m+jbt265m8JOVv79u3lxRdflC5dumS4zK+//iqDBg2S9957T/LkyeP3YxDUINvPiVGqVCmpUKGCPPLII+YgvnTp0lTpcz2oa4RetWpVM1+vy6QfgPqhqh/UnTp1kn379nnXqYHCsGHDzP3FihWTESNGXHa127Tlp7TefPNN8/uff/65ub1w4UKpXbu2yaLoOnU79VtGyu3MTIkSJaR06dLSvHlzefbZZ80H+e7du7336wFH90X58uXNMm+88YaMGjXKLKuBDpzB6e95DXj0+Xkmpev13L7mmmu8yxYoUMDMu/baa01wV6VKFe++UJql0fv176Z69erSv39/E9ycOXPGPEfYM/rJsmmyW1JSktxzzz0yfPhwqVmzZpbWQVCDoNIP0JTfTvUDVg/oq1atkmXLlsmFCxekbdu2JgBYt26dfPnll1KwYEHz7dfzexMmTDCp67ffflvWr18vJ06cMOlLX40bN05GjhwpK1eulNatW5tvij179pR+/frJzp07TTmpa9eulx00/H2evnwTf+yxx8zjLFmyJMuPhdDmlvd8VvZFRl8OevfubYKflJlOZO3cMnE2TumtMzExMcsvzT/+8Q/JnTu3DB48OMvr4Dw1CAr9sNQP808//dSkGj0iIiLMN0jtP1CzZ8820bvO81zJXFPb+k1QP3jbtGljGsmeeuop8yGspk+fbtbriyeffFLeffddWbt2rfebgX7Aa8+Drk+/XSv9BptVur7x48dL2bJlvd/EM6LfyvVDPOW3cjiDm97zmdHgRMtO27dvNyWmzFSrVs1cxfr48ePm7wP+yZs3r8mAVakUbeuu02A7Ojr1OvUM7ZqF89eWLVtMKfPbb7/1vu+zgqAG2Uq/ieofgn4b1Q/uXr16pfoD0A9Sz4e7+u6770zJJm1vQEJCguzZs8c0EOoHsjYUemikf+ONN2b6LVO/7Wp6ffPmzSYd7qGNbPrtVbdFvzHrQaR79+6mcdEfegV23YazZ8+adX7wwQepnltG9Heu5o8aocVN7/nMaGOwBmuandFm4KFDh5qSXGY8z4u/i6zJly+f7N2719aeLc/rkvY1yeolZzQreezYMVOOTxn8ah+aBvG+ftEjqEG2atWqlUybNs18iGsPgX4Yp6TfWlPSWnqDBg1M01haKWv1WdGsWTNZvny5LFiwwKTiPfTDVksBWsvX9PzkyZPl6aeflq+++koqVark1x+pNj7qN0tfGzb1m+hvv/3m1+MgtLnpPZ8ZLSPperXspH0zYWG+dUBoSUz/lrTXB1kPbPLlyxeyu097abSPKyUNsHX+fffd5/N6CGqQrfQDXIe1+qp+/foyf/58ExhkdH0R/XDUD19ttlWaRtdUpv7uldx0000ycOBA06ugB5onnnjCe59++2jSpImZtHFXU/Las6DNmb7Sg4Evo6RS0vSrftBzHhzncNN7PjPaVOzPvlD67X3OnDnmb8LXIAihSQP2lIMlNHu0bds2U3bXDE3aoFVHP2nZLLOyfUoENQhp+s1Oh7/q6A8dAq0lnf3795tzcOhoCL2tzbWvvPKKGUmhtffXXntNTp486dP6GzduLB9//LEZaqgf8jpaRA8W2vugKXg9sOhtzZ7oaAw7aY/A0aNHTVlC/7i1l0JT82PHjvX7gx/O4eT3vC/lDP2b0J/6fDZu3Cgvv/yyCYb0+SJn27x5s8lcengC5j59+lzx3F/+IKhBSNMhoHoWSm1u1CZGDQS04Vbr/55vsVpz1R4D/cPQb3I6gkPPg+DrCbuaNm1qUvIdOnQwaXhNgepjah1Xu/n1G6v2IuhBwE76bVgnTxPfzTffbA4sKf/o4T5Ofs9nRh9bs1CaNdLnqt/Q9TlqEBdKV4JG1uhpBvwZUZeVARNWcqDH7AEAAGQDCpQAAMARCGoAAIAjENQAAABHIKgBAACOQFADAAAcgaAGAAA4AkENAABwBIIaAEHXt2/fVJeG0JN06ZluPSpWrGhODGfHugE4F2cUBnDFgOCdd97xXodFr89y7733yt///vfLLsxoJ70kgD6eHfR6WpxjFHAHghoAV6QXP5wxY4YkJiaaawYNGDDABBxPPfVUquXOnz9vLvlgB73AnV30ukEA3IHyE4ArCg8PN9em0usBPfLII+Y6QUuXLvWWdV566SUpU6aM90q6Bw8elB49epgrlGtwohdmTHkNl0uXLpkL2en9elVevUhj2kxK2vJTWnrhT/19vVaWWrhwodSuXVvy589v1qnbGB8fb+6j/AS4B0ENAL9o4KBZGaVBRWxsrKxatUqWLVtmrjjetm1bKVSokKxbt06+/PJLKViwoMn2eH5HL5SoV+R9++23Zf369XLixAlZtGiRz48/btw4GTlypKxcudJc5FEv7NizZ09zUcedO3fKmjVrzIUgKTkB7kP5CYBPNEjQIObTTz+VQYMGyW+//SYREREma+IpO82ePVuSkpLMPL3SstLSlWZVNNho06aNafjV0pUGHmr69Olmnb7QK1e/++67snbtWqlZs6aZp0HNxYsXzfo0m6Q0awPAfQhqAFyRZmA026JZGA1YevXqJaNHjza9NRo8pOyj+e6772T37t0mU5NSQkKC7NmzR06dOmWCkEaNGv3vQyh3brnxxhszzaxohkdLSps3b5Zrr73WO79OnTomY6PbolkiDZy6d+8uRYoU4ZUFXIbyE4AratWqlWzbtk1+/vlnOXfunBkNpRka5fnpcebMGWnQoIFZPuX0008/mWDoajRr1sz04yxYsCDV/Fy5cpny1yeffCI1atSQyZMnm/6evXv38soCLkNQA+CKNHCpXLmyGc6d2TDu+vXrm+CnRIkS5ndSTjoKSafSpUvLV1995f0dLR1t2bIl01fhpptuMoHLyy+/LOPHj091n5a6mjRpImPGjJGtW7ea7JE/fToAnIGgBoBtevfuLcWLFzcjnrRRWLMl2kszePBgOXTokFnmsccek1deeUUWL14su3btkkcffVROnjzp0/obN25shpVr8OI5GZ8GSBroaFnqwIED5hw32u9TvXp1XlnAZeipAWCbAgUKSExMjGno1cbd06dPS9myZU3PS2RkpFnm8ccfN301ffr0kbCwMDNqqUuXLqbfxhdNmzaV5cuXS4cOHUzpSYdv62NqkBMXF2eahbX/pn379ryygMtYyYx7BAAADkD5CQAAOAJBDQAAcASCGgAA4AgENQAAwBEIagAAgCMQ1AAAAEcgqAEAAI5AUAMAAByBoAYAADgCQQ0AAHAEghoAAOAIBDUAAECc4P8BgnQY+rzykeAAAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from sklearn.model_selection import cross_val_predict\n", + "from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay\n", + "import matplotlib.pyplot as plt\n", + "\n", + "labels = [\"PD\", \"TPD\"]\n", + "\n", + "y_pred = cross_val_predict(best_model, X, y, cv=cv, n_jobs=1)\n", + "\n", + "cm = confusion_matrix(y, y_pred, labels=labels)\n", + "\n", + "print(\"Urutan label:\", labels)\n", + "print(cm)\n", + "\n", + "disp = ConfusionMatrixDisplay(\n", + " confusion_matrix=cm,\n", + " display_labels=[\"PD\", \"TPD\"]\n", + ")\n", + "\n", + "fig, ax = plt.subplots(figsize=(6, 5))\n", + "disp.plot(cmap=\"Blues\", values_format=\"d\", ax=ax, colorbar=True)\n", + "\n", + "ax.set_title(\"Confusion Matrix Model SVM\")\n", + "ax.set_xlabel(\"Prediksi\")\n", + "ax.set_ylabel(\"Label Asli\")\n", + "ax.set_xticklabels([\"Prediksi PD\", \"Prediksi TPD\"])\n", + "ax.set_yticklabels([\"Asli PD\", \"Asli TPD\"])\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "db2c5b18-fb32-426a-b704-d21b0aae65cf", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python (ConfiVoice)", + "language": "python", + "name": "confivoice" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.20" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/ml/api_app.py b/ml/api_app.py new file mode 100644 index 0000000..0763917 --- /dev/null +++ b/ml/api_app.py @@ -0,0 +1,181 @@ +from pathlib import Path +import tempfile + +from fastapi import FastAPI, File, Form, HTTPException, UploadFile +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, Field + +from audio_utils_api import SUPPORTED_AUDIO_EXTENSIONS, cleanup_temp_files +from database import ( + authenticate_user, + create_user, + get_database_label, + init_db, + list_prediction_results, + save_prediction_result, +) +from predict_api import MODEL_NOT_FOUND_MESSAGE, get_model_info, predict_audio + + +app = FastAPI(title="ConfiVoice Prediction API") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +class PredictionSaveRequest(BaseModel): + user_id: int | None = None + student_name: str + student_gender: str | None = None + predicted_label: str | None = None + label: str | None = None + description: str | None = None + confidence: float = 0 + probability_pd: float = 0 + probability_tpd: float = 0 + is_valid_audio: bool = True + error_message: str | None = None + audio_quality: dict = Field(default_factory=dict) + voice_indicators: dict = Field(default_factory=dict) + + +class AuthRequest(BaseModel): + username: str + password: str + + +class RegisterRequest(AuthRequest): + full_name: str + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Prediction API", + "status": "ok", + "predict_endpoint": "/predict", + "predictions_endpoint": "/predictions", + "database": get_database_label(), + } + + +@app.get("/model") +def read_model_info(): + try: + return get_model_info() + except FileNotFoundError as error: + raise HTTPException(status_code=404, detail=MODEL_NOT_FOUND_MESSAGE) from error + except Exception as error: + raise HTTPException(status_code=500, detail=str(error)) from error + + +@app.get("/predictions") +def read_predictions(limit: int = 200, user_id: int | None = None): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit, user_id=user_id), + } + + +@app.post("/register") +def register(payload: RegisterRequest): + full_name = " ".join(payload.full_name.strip().split()) + username = payload.username.strip().lower() + password = payload.password + + if not full_name: + raise HTTPException(status_code=400, detail="Nama lengkap wajib diisi.") + if len(username) < 3: + raise HTTPException(status_code=400, detail="Username minimal 3 karakter.") + if len(password) < 6: + raise HTTPException(status_code=400, detail="Password minimal 6 karakter.") + + try: + user = create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + raise HTTPException(status_code=409, detail="Username sudah terdaftar.") from error + raise HTTPException(status_code=500, detail=str(error)) from error + + return {"status": "registered", "user": user} + + +@app.post("/login") +def login(payload: AuthRequest): + username = payload.username.strip().lower() + user = authenticate_user(username, payload.password) + if not user: + raise HTTPException(status_code=401, detail="Username atau password salah.") + return {"status": "logged_in", "user": user} + + +@app.post("/predictions") +def create_prediction(payload: PredictionSaveRequest): + student_name = payload.student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + result = payload.model_dump() + prediction_id = save_prediction_result(student_name, result) + return { + "status": "saved", + "prediction_id": prediction_id, + "student_name": student_name, + } + + +@app.post("/save") +def save_prediction(payload: PredictionSaveRequest): + return create_prediction(payload) + + +@app.post("/predict") +async def predict( + file: UploadFile = File(...), + student_name: str = Form("Tanpa Nama"), + student_gender: str = Form(""), +): + suffix = Path(file.filename or "").suffix.lower() + if suffix not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(sorted(SUPPORTED_AUDIO_EXTENSIONS)) + raise HTTPException( + status_code=400, + detail=f"Format audio tidak didukung. Format yang didukung: {allowed}", + ) + + temp_input = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) + temp_input_path = Path(temp_input.name) + + try: + content = await file.read() + temp_input.write(content) + temp_input.close() + + if not content: + raise HTTPException(status_code=400, detail="File audio kosong.") + + clean_student_name = student_name.strip() or "Tanpa Nama" + result = predict_audio(temp_input_path) + result["student_name"] = clean_student_name + result["student_gender"] = student_gender.strip() or None + return result + except HTTPException: + raise + except FileNotFoundError as error: + raise HTTPException(status_code=404, detail=MODEL_NOT_FOUND_MESSAGE) from error + except Exception as error: + raise HTTPException(status_code=500, detail=str(error)) from error + finally: + temp_input.close() + cleanup_temp_files(temp_input_path) diff --git a/ml/app.py b/ml/app.py new file mode 100644 index 0000000..c95ad33 --- /dev/null +++ b/ml/app.py @@ -0,0 +1,312 @@ +from datetime import datetime +import tempfile +from pathlib import Path + +import joblib +import librosa +import numpy as np +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, SAMPLE_RATE, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +DATA_DIR = BASE_DIR / "data" +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" + +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +MIN_RECORDING_DURATION_SECONDS = 2.0 +LOW_RMS_WARNING_THRESHOLD = 0.003 + +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_bytes_to_temp_file(audio_bytes, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(audio_bytes) + return Path(temp_audio.name) + + +def save_uploaded_file(uploaded_file, suffix): + return save_bytes_to_temp_file(uploaded_file.getvalue(), suffix) + + +def validate_audio_quality(audio_path, min_duration=MIN_RECORDING_DURATION_SECONDS): + """ + Validasi dasar sebelum ekstraksi fitur. + Error dipakai untuk kasus file kosong/gagal dibaca/terlalu pendek. + Warning dipakai untuk audio yang masih bisa diproses tetapi kualitasnya lemah. + """ + try: + y, sr = librosa.load(audio_path, sr=SAMPLE_RATE, mono=True) + except Exception as error: + raise ValueError(f"File audio gagal dibaca: {error}") from error + + if y.size == 0: + raise ValueError("File audio kosong atau tidak memiliki sinyal suara.") + + duration = librosa.get_duration(y=y, sr=sr) + if duration < min_duration: + raise ValueError( + f"Durasi audio terlalu pendek ({duration:.2f} detik). " + "Silakan rekam suara 3 sampai 5 detik dengan jelas." + ) + + rms = float(np.sqrt(np.mean(y**2))) + warning = None + if rms < LOW_RMS_WARNING_THRESHOLD: + warning = ( + f"Suara terdeteksi cukup pelan (RMS={rms:.5f}). " + "Jika hasil kurang tepat, rekam ulang dengan suara lebih jelas." + ) + + return { + "duration": duration, + "rms": rms, + "warning": warning, + } + + +def predict_audio_path(audio_path, validate_quality=True): + """ + Fungsi prediksi umum untuk upload dan rekaman. + + Urutan: + audio_path -> convert_to_wav -> validate -> extract_features -> predict_proba. + Label utama diambil dari probabilitas terbesar, bukan model.predict(). + """ + model = load_model(MODEL_PATH.stat().st_mtime) + audio_path = Path(audio_path) + extension = audio_path.suffix.lower() + + if extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(audio_path, temp_wav_path) + quality_info = validate_audio_quality(temp_wav_path) if validate_quality else None + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + predicted_label = max(class_probabilities, key=class_probabilities.get) + confidence = class_probabilities[predicted_label] + + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + "quality_info": quality_info, + } + finally: + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +def render_prediction_result(label, confidence, probabilities, debug_info): + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + quality_info = debug_info.get("quality_info") + if quality_info and quality_info.get("warning"): + st.warning(quality_info["warning"]) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + st.write(f"Margin: {margin * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") + if quality_info: + st.write(f"Durasi audio: {quality_info['duration']:.2f} detik") + st.write(f"RMS audio: {quality_info['rms']:.6f}") + + +def get_audio_recorder_input(): + """ + Menggunakan st.audio_input jika tersedia. + Jika belum tersedia, coba fallback ke streamlit-mic-recorder. + """ + if hasattr(st, "audio_input"): + return st.audio_input("Rekam suara") + + try: + from streamlit_mic_recorder import mic_recorder + except ImportError: + st.error( + "Versi Streamlit ini belum mendukung st.audio_input. " + "Install fallback recorder dengan perintah: pip install streamlit-mic-recorder" + ) + return None + + audio = mic_recorder( + start_prompt="Mulai Rekam", + stop_prompt="Berhenti Rekam", + just_once=False, + use_container_width=True, + key="mic_recorder", + ) + + if audio and audio.get("bytes"): + suffix = ".wav" + return { + "bytes": audio["bytes"], + "suffix": suffix, + "mime_type": "audio/wav", + } + + return None + + +def get_recording_bytes(recording): + if recording is None: + return None, ".wav", "audio/wav" + + if isinstance(recording, dict): + return recording["bytes"], recording.get("suffix", ".wav"), recording.get("mime_type", "audio/wav") + + suffix = Path(recording.name).suffix.lower() or ".wav" + return recording.getvalue(), suffix, recording.type or "audio/wav" + + +def save_recording_to_dataset(source_audio_path, label): + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + target_dir = DATA_DIR / label + target_dir.mkdir(parents=True, exist_ok=True) + target_path = target_dir / f"recorded_{label}_{timestamp}.wav" + + convert_to_wav(source_audio_path, target_path) + return target_path + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Input audio, ekstraksi fitur, prediksi SVM, lalu tampilkan PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +upload_tab, record_tab = st.tabs(["Upload Audio", "Rekam Audio"]) + +with upload_tab: + uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, + ) + + if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi Upload"): + temp_input_path = save_uploaded_file(uploaded_file, Path(uploaded_file.name).suffix.lower()) + try: + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + label, confidence, probabilities, debug_info = predict_audio_path(temp_input_path) + render_prediction_result(label, confidence, probabilities, debug_info) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + finally: + temp_input_path.unlink(missing_ok=True) + +with record_tab: + st.write( + "Silakan rekam suara selama 3-5 detik. Gunakan suara yang jelas, " + "tidak terlalu pelan, dan hindari noise ruangan." + ) + + recording = get_audio_recorder_input() + audio_bytes, suffix, mime_type = get_recording_bytes(recording) + + if audio_bytes: + st.audio(audio_bytes, format=mime_type) + + temp_recording_path = save_bytes_to_temp_file(audio_bytes, suffix) + st.session_state["latest_recording_path"] = str(temp_recording_path) + + if st.button("Prediksi Rekaman"): + try: + with st.spinner("Mengekstraksi fitur dan memprediksi rekaman..."): + label, confidence, probabilities, debug_info = predict_audio_path(temp_recording_path) + render_prediction_result(label, confidence, probabilities, debug_info) + except Exception as error: + st.error(f"Gagal memproses rekaman: {error}") + + st.divider() + st.subheader("Simpan Rekaman ke Dataset") + selected_label = st.selectbox( + "Label manual", + options=[LABEL_PD, LABEL_TPD], + format_func=lambda label: f"{label} - {LABEL_DESCRIPTION[label]}", + ) + + if st.button("Simpan ke Dataset"): + try: + saved_path = save_recording_to_dataset(temp_recording_path, selected_label) + st.success( + "Rekaman berhasil disimpan. Jalankan ulang train_model.py " + "untuk melatih ulang model." + ) + st.write(f"File: {saved_path}") + except Exception as error: + st.error(f"Gagal menyimpan rekaman ke dataset: {error}") diff --git a/ml/audio_utils.py b/ml/audio_utils.py new file mode 100644 index 0000000..749a743 --- /dev/null +++ b/ml/audio_utils.py @@ -0,0 +1,83 @@ +from pathlib import Path +import shutil +import subprocess + +import imageio_ffmpeg + + +TARGET_SAMPLE_RATE = 22050 +SUPPORTED_AUDIO_EXTENSIONS = {".wav", ".mp3", ".m4a", ".ogg", ".flac", ".webm", ".aac"} + + +def configure_ffmpeg(): + """ + Mengambil path ffmpeg dari imageio-ffmpeg. + Jika ffmpeg gagal tersedia, error dibuat lebih mudah dipahami. + """ + try: + return imageio_ffmpeg.get_ffmpeg_exe() + except Exception as error: + raise RuntimeError( + "ffmpeg belum tersedia. Jalankan `pip install imageio-ffmpeg` " + "atau install ffmpeg di sistem operasi." + ) from error + + +def convert_to_wav(input_path, output_path): + """ + Mengubah WAV, MP3, M4A, OGG, FLAC, WEBM, atau AAC menjadi WAV. + + Output selalu: + - WAV + - mono + - sample rate 22050 Hz + """ + input_path = Path(input_path) + output_path = Path(output_path) + extension = input_path.suffix.lower() + + if not input_path.exists(): + raise FileNotFoundError(f"File audio tidak ditemukan: {input_path}") + + if extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(sorted(SUPPORTED_AUDIO_EXTENSIONS)) + raise ValueError(f"Format audio tidak didukung: {extension}. Format yang didukung: {allowed}") + + output_path.parent.mkdir(parents=True, exist_ok=True) + + if extension == ".wav": + if input_path.resolve() != output_path.resolve(): + shutil.copyfile(input_path, output_path) + return output_path + + command = [ + configure_ffmpeg(), + "-y", + "-i", + str(input_path), + "-vn", + "-ac", + "1", + "-ar", + str(TARGET_SAMPLE_RATE), + "-f", + "wav", + str(output_path), + ] + + try: + result = subprocess.run(command, capture_output=True, text=True, check=False) + except FileNotFoundError as error: + raise RuntimeError( + "ffmpeg tidak bisa dijalankan. Pastikan dependency `imageio-ffmpeg` " + "sudah terpasang atau install ffmpeg secara manual." + ) from error + + if result.returncode != 0: + error_message = result.stderr.strip() or "Tidak ada detail error dari ffmpeg." + raise RuntimeError(f"Konversi audio gagal: {error_message}") + + if not output_path.exists(): + raise RuntimeError("Konversi audio gagal: file WAV output tidak terbentuk.") + + return output_path diff --git a/ml/audio_utils_api.py b/ml/audio_utils_api.py new file mode 100644 index 0000000..82f1522 --- /dev/null +++ b/ml/audio_utils_api.py @@ -0,0 +1,99 @@ +from pathlib import Path +import shutil +import subprocess +import tempfile + +try: + import imageio_ffmpeg +except ImportError: + imageio_ffmpeg = None + + +TARGET_SAMPLE_RATE = 22050 +SUPPORTED_AUDIO_EXTENSIONS = {".wav", ".mp3", ".m4a", ".ogg", ".flac", ".webm", ".aac"} + + +def validate_audio_extension(file_path): + extension = Path(file_path).suffix.lower() + if extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(sorted(SUPPORTED_AUDIO_EXTENSIONS)).upper().replace(".", "") + raise ValueError(f"Format audio tidak didukung: {extension}. Format yang didukung: {allowed}") + + +def create_temp_wav_path(): + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".wav") + temp_path = Path(temp_file.name) + temp_file.close() + return temp_path + + +def configure_ffmpeg(): + if imageio_ffmpeg is None: + raise RuntimeError( + "ffmpeg belum tersedia. Jalankan `pip install imageio-ffmpeg` " + "di virtual environment backend." + ) + try: + return imageio_ffmpeg.get_ffmpeg_exe() + except Exception as error: + raise RuntimeError( + "ffmpeg belum tersedia. Jalankan `pip install imageio-ffmpeg` " + "atau install ffmpeg di sistem operasi." + ) from error + + +def convert_audio_to_wav(input_path, output_path=None): + input_path = Path(input_path) + validate_audio_extension(input_path) + + if not input_path.exists(): + raise FileNotFoundError(f"File audio tidak ditemukan: {input_path}") + + if input_path.suffix.lower() == ".wav" and output_path is None: + return input_path + + output_path = Path(output_path) if output_path else create_temp_wav_path() + output_path.parent.mkdir(parents=True, exist_ok=True) + + if input_path.suffix.lower() == ".wav": + if input_path.resolve() != output_path.resolve(): + shutil.copyfile(input_path, output_path) + return output_path + + command = [ + configure_ffmpeg(), + "-y", + "-i", + str(input_path), + "-vn", + "-ac", + "1", + "-ar", + str(TARGET_SAMPLE_RATE), + "-f", + "wav", + str(output_path), + ] + + try: + result = subprocess.run(command, capture_output=True, text=True, check=False) + except FileNotFoundError as error: + raise RuntimeError( + "ffmpeg tidak bisa dijalankan. Pastikan dependency `imageio-ffmpeg` " + "sudah terpasang di virtual environment backend." + ) from error + + if result.returncode != 0: + error_message = result.stderr.strip() or "Tidak ada detail error dari ffmpeg." + raise RuntimeError(f"Konversi audio gagal: {error_message}") + + if not output_path.exists(): + raise RuntimeError("Konversi audio gagal: file WAV output tidak terbentuk.") + + return output_path + + +def cleanup_temp_files(*paths): + for path in paths: + if path: + Path(path).unlink(missing_ok=True) diff --git a/ml/data/a_pd.wav b/ml/data/a_pd.wav new file mode 100644 index 0000000000000000000000000000000000000000..d6a31b411a2390657aeb99f6f6d18f3cec0bc1a5 GIT binary patch literal 751848 zcmeF)3A}Gp-9PYi?m>n^sicf4DV63zBBGQA&66pmlm;cGNoXD=o=Tacd7dXkqvlY` zaFroEDtS`jo^$`7_s{u0_J2RWGu+8_S^M*P-L?1HYpuQ3cYW7)TI+Z2lXuvD`wL#X z%E&Vw`Sk7feCfXHuRby|GCDG6C)6d27Zx&Bpo{ZzTi>(}W& z&$e8@p67mD&;7h%<%YF2UEZ*E``_t4&*r}Sy((X6_uXvnt6rt*bnk~fPt|U}UcavQ zRi>Zz+w7L@znkj0+U&j`mRIlV8`G7kvcuBp>P`1ZI`${W+ zLDB`S+b>_wmDaaCyF6Fgz3=8#zC4#_SKU`SU0#)`v|F}+?|Il&b)_z4uD`qf-+rAc zQ{{%e@0RJluXd~MROPyL>b~j@t5ff~W$RvAb^7IomFeeo>-O8~K6mSN@2h@4uiuw? z?v}5-Zr!@?mXlXyyZMzjtWLjNm8rJsxm&LCTG}YkC@?bx`nF&HdAb&=Y^D9Ol`pOP zZkc{w|GCOem0$0r{W9I>{=MgJUbmgPulJR9^SgQd`+AlRyRUY-X}4@QzwWD@dLEWm znM$k7u(tbUs_cTMi`q`ruXI7azc6(cr2PdgdsFDoO`#79O1DaP1=wR#*<_VpsUFsS zKkfG3yLw;w!}7ZC>V7t9zwMdo^uOJ&-+$I#zs^kGSdjO_zPBLt7p3gd{Pv>s`{rJE zw(~MoxvBDJOSeU>KiluLa*YCw0*i+NOVd|bkZ(^_cB=eEq0W+Mb5Z)!>MlG57IrSX z^}6?Z(tn;RuiNWkb-M4n_X|?CTd&i*+MdaxP_NZ(6lfG! z5DLs>GuQiWTKDyx{&VFoiF7vKnyQTaLiKH`vh}{w>B>#_uIhKwDqm^$-E{X0T7J5= zX8Uewe6!!T>VtIHeU-0w^*mkLZA-bTTWRHWpQmf@min&I=axG2%f`5uhCRJFzB!vV zrYkeuyQyYwVd_lP)@<_^^qUJ>ezyH+;YuN*`O@MuA3wMuA3wMuA3wMuA3wMuA3wMuA3wMuA3wMuA3wMuA3wMuA3wMuBCS z0xcFlyV+}WnjM{5sbzx#i|e=0Gqp3*``J=!VcS{Qdiu2}b!R)SR<2RtmPUcLu5M{F z-p1P~Ff|35Z9O$@+M64P0$m>L-q+VFubX!7tK6`($_;y0dDE5gZrJ;HE=?>1?9u-raOY(0bA+&?vC56liwS!cwl)YZPb{XcTA^XcYLr z6j+vR=ViI?|92)@&XS-&;H7`7M^0)*Ib-!4{K{! z-lF!o)o&DN6lfG^6lfG^6lfG!+!WyByJp*NmF=g~)mPS4WxDs3H!PiPxnXsu%Db`J z9QK`Ic~kYX^3`$G^Ma;}Qg=bW)yg*tGz#22DX_4y~NvjRK2<0?l4o98_#=Gzv5dGzv5dGzv5d zGzv5dGzu*H6qt<JN{J=W=7RR2U;d`pr+y#XbE80`!0af{;vTc3Q!CXd&?wL- z&?wL-&?wL-&?wL-aAQ%R+2}VGrQ3HJ1sVk!1sVk!1sVk!1sVk!1sVk!1sVkwg#yd6 z|5|;obV2E`p!H@`zRFbE?bEQl?)$0kr>i%ccf-mI%bV)^Q{^v?Hl}L3Mzc^yWi>Ht#o|xojh86u2o- zKs?4ZRiFCr`p;Ews{DH2(nY7hO_jz=t6xj&yNf=Pt%XK`MuA3wMuA3w#Y2H5;Uo6j z>^^tzji8?U>9BW|r)-s%S6Ag%TKSb$oqp*ilKc<0%>dBJic%MIRnu6otR;6?kVQD6oNG=7pMefO^Vj5*IUD~hLf1a*xd)Fw?D9|X-D9|WyOQygu->B=abnm-#s(RhD|GxV?)qS^pF*xPM z0x`IX!^!i`RXV;~Z>oFshF#tA!``>NMuA3wMuA3wMuA3wMuA3wn-2xbe!TgNsEwpi zVA-U=O>HgC)SsE&FPpQ_dfh0{D6lM1U~$E-7FRn{(QZk%Kh<|zexpF6K%+pTK%+pT zK%+pTz>Q0RWzkPw7W;hT&T9Kwqd=oTqd=oTqd=p;vPglOOFZu8GLB`DhgzQ-1sVk! z1sVk!1(p;AntoqWlx^)c3N#8d3fw#>usCA&3){xR)|-y<)3rI>yQ$h-m^xFnwY2kZ zPGecxv~1sR6u6~Op!pIvubEzu`CO2)OM*&^^}MkCT-bU`Yn<)7jRK7VjRK7V zjRK7VjRK7Vv!}ppeW=-%>wbA*>U8V3`$mCAfkuHwfkuHwfkuHwfkuHwfkuHwfkuHw zfg32$_G@mSMZ0ShSQH8@YcZ>Cf4ldKLW5SfQJ_(vQJ_(vQJ_(vQ2+&I<73RG%%W0r z*f)mdP4%5BTj^BgT7IKIqd=oTqd=oTqd=p;Eu8|lwAj)uZM?U1{%+%M6lfG^6lfG^ z6lfG^6lfG^6lfG^6lfG^6j&GvwEd-pp<1icD9|X-D9|WyOQ1lDvELGAxs9<=pi!Vv zpi!Vvpi!Vvpi!Vvpi!Vvpi!Vvpi$taOo0}kxhc=i&7*J2+CJIR?yv7J?LI7PoY$vU z|9N4?vpCvVnD5?{>MRKwXmfG5yExlh(6$z|y!mZ<_ol?Up~QQ@vAHJN>K5%dh*H(rQDw z1*tczU9_Bvp8fK(E!VHteeSkV_tLuWmYwc?s`B0Z?tQg2EU!z$?tRtir?V;3ud_Iw zhxNVRt}=!GJI`~1x|ACW{=_fnU20d-Y=zx`p)>I}j8tFx{pr51GX1=2TYg=`+F{R^P2at*?@BAb%5~p$@9TY~ z-FJ1bFV&~YlUAJ?Pra+M^`7_DhlGRnNKcw|8@V{mor^`Glq(R`}+ zRZgmmtDD!&_paKjGW};|T=l+tUwKodgNl=NyZ6dhJC*j^m@2hhsy*KF!8 zsWLP1SNDD0OY2_0y3Zv~s=rmP%1o8^%h$8C+UVwW?~8_&cHa%V@0auJ8di3uynelz z`daPHwp^9(rd=9$+v(nS%g=P*ZLeEad&AlsmRI$rdsk(o*SCEAzuo-0ulL<&_kDWT z`+i#esI>pybN^lcxnF0d_cN8BP5ohIA!pJx!l*7x@G$Bs!XNB)`)lNRhg+$Wn5Fe zmp@he)74qncip}WIyTw28>9DK5GwfZKj#W*mISyw7y$)>balR z`)afQex_&NbInxF``MJ!UX`tT&-I;Koick=>ip~_0V>poXK->Q4{ zy7%67+v@h&JD2yBR@r(UyvRS5UuEUjcq^}4rt*|=b>FG$S$?(S-c`>t$@6~u*Vn$` z-)jGcoT=}8)734}f9}3>KU4qu<)?bCcBFkfT3(^PSvI%)%IlV?yh^KFJxiy%rYhh4 zhWqMkm3H&H_x1g5x$1v6ukNMYZ+F}3=2y9T z-+$L_uY2DuUv(;Px;B-mepkIJFXf-zDp)>nzg!@#I?5C+q~6v0YP;UobCs#*ZtA{! zb)UQU-7@a`-{_WgU+*hbM?b1uH{ZLeQ}>ltdCxBOx|jF;GTrwJb6@SvBtK@`cD1Lx z{?{1fyEn&`Uo@&RRj1oV)vND&RcInVWOm626OZX4V_ zxO1>huvXw6?i6?~RDQRNIvWOe4=UYwWaE*02lojc5xhJ&A~-a7W}w{af$y&tsQZ}U zX~9DR`Tv>!pMj3lxBCa$*(!MCB(@yca**#^cMa|spcDOzPR6@N;9KhlzDMW&Gx(q2 zUx8<|@b1{up@wyFeN5E(|UYu9(DS^Dmo! zLEzmlg6{|43r-4t8puCCm>Wp{9Qf{Ef=dF=@_p;9;Fpt7?@xjJUkCc64d1^s_-kM+ z*9O-GR|OXZ=LYKOm+vU+J({5j`nXmcS#iKS#$jywfv?Q{gM$|Z`v-dm&kME>9u%w_ zX!o}P8htzXTA;uG4sJJcy8+M9{p*daH}d!ZPwW=#8SD}~DR^LD4vj;<&Bxz@p9en+ zjBVwSl?QXY(a1(4_YLq#@rAq%0^=~>>Y@#rpB#KQ_*`&8@X6pS!KndS>)UNcZZmR+ zN%#)h;0vif{UrEIpziU(`+~Oy9}0}ud-I~-Xp6@BtRLnPU3~w`f%`86)sIij|J3}C z1z!yC$+v=20(|ECD~+r);8p#>Yx0i?_6*(>Xy3T;zj3S?SR;7A81V|4=o8w0GdLsA zPh+cb;*+93zPl#4+sNIr;;vtF`eFT$A=U<3YTG>Hqgso2;^BdHNOpOTue65;tm`d; zdj{5_F?#op01x3M<$d>FfwuKkS!=|2@xHZKd}plY5xwydSwvPDw?3L@ZOGp`z&B*j zJpwdAZ{zuTpqw`J%QwkIVC^dhl)Q&UmaN zZOS)B%2@Q%nsJX8T*fEQ+#09ybgp*j*u#V4 zf+qy}Prg4RKx6ZU-`5WC7`BhL+#J0N&V@TdU2wT2LF0m}k1KT_vZD%cai`3zYRu zboG3{zz4u{5T++*<&u_K<6h0`iJgls^9wPew%iXcxWkbLk`R@GhBXtmuVy_}nv~E4YkX85h1`S9~=v z24gf1G&X1YYi()^AJHjN{lMG$!2Z`K{2(7~_2t9CCjzuLpXiDQ(f`ST)OXC0@@0qO zdwLf=@R{{yJ%IA$gt}#8qZ!+b-Xo{YAsgqg;Lw20b8=w);%zbm@2g{M^bHwhJ>v)R z%NWTE@5vB$*+&CoqD#yho@axQWAqIjNpD+6c;K{v?LZFT*P<&K;5+mNeo~gbhW_aM ztl$m7J^|T>AJCADMq_kBBm9H@);~It1;+cqfc#<)+j|9HGZ}600|3Dsl2TjczU5>ZW2=AFg>kOaM zb=I1)=*9*vo^?Xv>>BB z>!Wtmw`Qy@YYcx_-^#f+AKI~IlqGM?wLak)^Gj}-Z*xVTS$o!|lpG}s%(;2hNB8tH zzS1xAWS!E9{D30^_LFa;hx+KIO*Al%coSXG3a|Js9?~DQ)IUBj{xCLcf_^s#c)&Nb zfev&ndh08>N`|`h%ecKmQ)>;spbK6>M=}8)nrrI`zwpam9qbzTCOu`&wdLFTtAED@ zUkbEkF4099W8EVlKj<{N0=?+|#|2~-dF1-x{2vZ{sAK0JJD4}$ap|vbxF=J{1@oxS z~D+A*_HYi({ZhUb-_oMaBfpYH2 zCbF5l`9biX;N1b<(I?+mMql_VWFsE2&L0_^7?73bR?G!0*yVJ*xgs0UX7}J}!Lx%m z2IyifurZz;tRIl!?!^E;5_};r&-4NLZ(X39=OY5+#NYIowMovPEtbJ4u-**Ib241p$@tE~u zPHTS8898U1U{^0-j z2J{QLfHv02^8>m@pUQu?X3>BwAydo&+mD@$CTL6+s>8=8pUt81>l3@kH}sc$!ISul z{?dlNf%&p#=q&P^{L_Z|c#9k{E6^aNUE~RyhHv$pfXqY_{iWmiE!GOz zryqEf{v%hlrL4IJ@6iJP7&qGa4qMK+tOM`Nr#Ytg=vlS_UT1^oD}FZze;LQQsOTX!H@<5-- zYrJR<$V{|ATkYxxonRf12k6bN_dPsBMw1&AZL~HEdHtwbQW7qd2@iD$RzYL zx9kG#q79q!=)gD42VSCQ@dUbBXX1B<2j-CNL8h_S@a#bW9bw&D$L3!h>yoarrqDs3 zd_(*C;9K;iZ|Eo9H;3$|HwCW<_&@qWu8aTqrZ$b!nlT4>jXYG>nzwGPdl$Ykc4b{; zHl89^@R0HUAiyu={nml`!dKP--ei~He{w<_>f;l(3;oCDWse x{*S*1LlKuwnF- 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

-6q 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 LMf!)NQSko(t9+}G literal 0 HcmV?d00001 diff --git a/docs/diagrams/Flowchart_app.drawio.png b/docs/diagrams/Flowchart_app.drawio.png new file mode 100644 index 0000000000000000000000000000000000000000..8932042bb3a7e3302512343754d899f818edb80d GIT binary patch literal 161990 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

+ + {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +