diff --git a/.idea/.name b/.idea/.name deleted file mode 100644 index a139aa0..0000000 --- a/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -NAT-AS \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index ed7a4c6..e6bd082 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -4,14 +4,27 @@ diff --git a/.kotlin/errors/errors-1782740918600.log b/.kotlin/errors/errors-1782740918600.log new file mode 100644 index 0000000..d8a5450 --- /dev/null +++ b/.kotlin/errors/errors-1782740918600.log @@ -0,0 +1,71 @@ +kotlin version: 2.0.21 +error message: Daemon compilation failed: null +java.lang.Exception + at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:69) + at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:65) + at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemon(GradleKotlinCompilerWork.kt:240) + at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemonOrFallbackImpl(GradleKotlinCompilerWork.kt:159) + at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.run(GradleKotlinCompilerWork.kt:111) + at org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction.execute(GradleCompilerRunnerWithWorkers.kt:76) + at org.gradle.workers.internal.DefaultWorkerServer.execute(DefaultWorkerServer.java:63) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:66) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:62) + at org.gradle.internal.classloader.ClassLoaderUtils.executeInClassloader(ClassLoaderUtils.java:100) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1.lambda$execute$0(NoIsolationWorkerFactory.java:62) + at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:44) + at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:41) + at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:209) + at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:204) + at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66) + at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59) + at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166) + at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59) + at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:53) + at org.gradle.workers.internal.AbstractWorker.executeWrappedInBuildOperation(AbstractWorker.java:41) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1.execute(NoIsolationWorkerFactory.java:59) + at org.gradle.workers.internal.DefaultWorkerExecutor.lambda$submitWork$0(DefaultWorkerExecutor.java:174) + at java.base/java.util.concurrent.FutureTask.run(Unknown Source) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runExecution(DefaultConditionalExecutionQueue.java:194) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.access$700(DefaultConditionalExecutionQueue.java:127) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner$1.run(DefaultConditionalExecutionQueue.java:169) + at org.gradle.internal.Factories$1.create(Factories.java:31) + at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:263) + at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:127) + at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:132) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runBatch(DefaultConditionalExecutionQueue.java:164) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.run(DefaultConditionalExecutionQueue.java:133) + at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Unknown Source) + at java.base/java.util.concurrent.FutureTask.run(Unknown Source) + at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64) + at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:48) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) +Caused by: java.nio.file.NoSuchFileException: C:\Users\tomhe\AppData\Local\Temp\kotlin-backups853413239455496257\8.backup -> C:\Promotion-Software\Barometer\app\build\tmp\kapt3\incrementalData\debug\com\tomhempel\rbarometer\ui\ReviewScoresHandler$Companion.class + at java.base/sun.nio.fs.WindowsException.translateToIOException(Unknown Source) + at java.base/sun.nio.fs.WindowsException.rethrowAsIOException(Unknown Source) + at java.base/sun.nio.fs.WindowsFileCopy.move(Unknown Source) + at java.base/sun.nio.fs.WindowsFileSystemProvider.move(Unknown Source) + at java.base/java.nio.file.Files.move(Unknown Source) + at org.jetbrains.kotlin.incremental.RecoverableCompilationTransaction.revertChanges(CompilationTransaction.kt:231) + at org.jetbrains.kotlin.incremental.RecoverableCompilationTransaction.close(CompilationTransaction.kt:256) + at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.tryCompileIncrementally(IncrementalCompilerRunner.kt:747) + at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:120) + at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:675) + at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:92) + at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1660) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source) + at java.base/java.lang.reflect.Method.invoke(Unknown Source) + at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source) + at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source) + at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source) + at java.base/java.security.AccessController.doPrivileged(Unknown Source) + at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source) + at java.base/java.security.AccessController.doPrivileged(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source) + ... 3 more + + diff --git a/app/src/main/assets/bundled_german_defaults.json b/app/src/main/assets/bundled_german_defaults.json new file mode 100644 index 0000000..ee6d05e --- /dev/null +++ b/app/src/main/assets/bundled_german_defaults.json @@ -0,0 +1,316 @@ +{ + "all_clients": "Alle Klienten", + "answer": "Antwort", + "april": "April", + "august": "August", + "auth_footer_accounts": "Konten werden von Ihrer Organisation vergeben.", + "auth_subtitle_change_password": "Bitte legen Sie jetzt ein eigenes Passwort fest.", + "auth_subtitle_login": "Melden Sie sich mit Ihrem Berater-Konto an.", + "auth_title": "NAT-AS Ressourcenbarometer", + "cancel": "Abbrechen", + "category_green": "Grün", + "category_red": "Rot", + "category_yellow": "Gelb", + "choose_answer": "Antwort wählen", + "choose_more_elements": "Es müssen mehr Elemenete ausgewählt werden.", + "client": "Klient", + "client_code": "Klientencode", + "client_code_exists": "Dieser Client-Code existiert bereits.", + "coach_code": "Berater-Code", + "confirm_password_hint": "Passwort bestätigen", + "date_after": "Das Datum muss nach", + "date_before": "Das Datum muss vor", + "day": "Tag", + "december": "Dezember", + "done": "Erledigt", + "download": "Herunterladen", + "download_failed_use_offline": "Download fehlgeschlagen – arbeite offline mit vorhandener Datenbank", + "enter_coach_code": "Bitte geben Sie Ihren Berater-Code ein, um fortzufahren!", + "error": "Fehler", + "error_invalid_data": "Ungültige Daten", + "error_not_found": "Nicht gefunden", + "questionnaire_title": "Fragebögen", + "exit_btn": "Beenden", + "extreme_glass": "extrem", + "february": "Februar", + "fill_all_fields": "Bitte alle Felder ausfüllen!", + "fill_both_fields": "Bitte Klienten-Code und Berater-Code prüfen.", + "hours_short": "h", + "january": "Januar", + "july": "Juli", + "june": "Juni", + "lay": "liegen.", + "little_glass": "ein wenig", + "consultation_unlock_requires_rhs": "Freischaltung nach Abschluss von Demografie und RHS", + "enter_ages_ranges_hint": "Bitte Alter als Bereich (z. B. 8–12) oder einzelne Jahre (z. B. 6, 9, 13) eingeben", + "questionnaire_unlock_2_rhs": "Verfügbar nach Abschluss von Demografie", + "questionnaire_unlock_3_integration_index": "Verfügbar nach Abschluss von Demografie und RHS", + "questionnaire_unlock_4_consultation_results": "Verfügbar nach Abschluss von Demografie (Einwilligung unterschrieben), RHS und IPL", + "locked": "Gesperrt", + "questionnaire_chip_edit": "Bearbeiten", + "questionnaire_chip_edit_pending": "Bearbeiten · Upload ausstehend", + "login_btn": "Login", + "login_failed_with_reason": "Login fehlgeschlagen: {reason}", + "login_required": "Bitte zuerst einloggen", + "logout": "Abmelden", + "logout_confirm_action": "Abmelden", + "logout_confirm_message": "Alle Daten auf diesem Gerät werden gelöscht. Sie müssen sich danach erneut anmelden.", + "logout_confirm_title": "Wirklich abmelden?", + "logout_offline_message": "Sie sind offline. Stellen Sie eine Internetverbindung her, laden Sie ausstehende Daten hoch und versuchen Sie es dann erneut.", + "logout_offline_title": "Abmelden nicht möglich", + "logout_pending_detail_in_progress": "{count} begonnene(r) Fragebogen mit lokalen Antworten", + "logout_pending_detail_questionnaires": "{count} abgeschlossene(r) Fragebogen warten auf Upload", + "logout_pending_detail_scoring": "{count} Punkteprüfung(en) warten auf Upload", + "logout_pending_message": "Auf diesem Gerät liegen noch nicht hochgeladene Daten vor. Bitte tippen Sie auf „Hochladen“ und warten Sie, bis der Upload abgeschlossen ist. Schließen Sie begonnene Fragebögen zuerst ab.", + "logout_pending_title": "Abmelden nicht möglich", + "march": "März", + "may": "Mai", + "never_glass": "überhaupt nicht", + "minutes_short": "m", + "moderate_glass": "moderat", + "month": "Monat", + "much_glass": "ziemlich", + "new_password_hint": "Neues Passwort", + "next_step_completed_body": "Für diesen Klienten sind alle verfügbaren Schritte abgeschlossen.", + "next_step_completed_title": "Alles ist abgeschlossen", + "next_step_loading_body": "Der nächste sinnvolle Schritt wird vorbereitet.", + "next_step_loading_title": "Nächster Schritt", + "next_step_label": "Nächster Schritt", + "next_step_questionnaire_body": "Wählen Sie unten den nächsten verfügbaren Fragebogen.", + "next_step_questionnaire_title": "Nächsten Fragebogen ausfüllen", + "next_step_review_body": "Prüfen Sie die berechneten Kategorien vor dem Upload.", + "next_step_review_title": "Punkteprüfung abschließen", + "next_step_show_questionnaire": "Fragebogen anzeigen", + "next_step_upload_body": "Bitte prüfen und hochladen, sobald Sie bereit sind.", + "next_step_upload_title": "Daten warten auf Upload", + "no_clients_assigned": "Ihr Supervisor hat Ihnen noch keine Klienten zugewiesen.", + "no_profile": "Dieser Klient ist noch nicht Teil der Datenbank", + "no_questionnaires": "Keine Fragebögen vorhanden.", + "no_questions_available": "Keine Fragen vorhanden.", + "questionnaire_missing_options": "Dieser Fragebogen ist unvollständig (fehlende Antwortoptionen). Bitte wenden Sie sich an Ihren Administrator.", + "none": "Keine", + "not_done": "Nicht erledigt", + "november": "November", + "october": "Oktober", + "offline": "Offline", + "other_country": "anderes Land", + "other_option": "Sonstiges", + "ok": "OK", + "online": "Online", + "password_hint": "Passwort", + "password_too_short": "Mindestens 6 Zeichen", + "passwords_dont_match": "Passwörter stimmen nicht überein", + "please_client_code": "Bitte Klienten Code eingeben", + "please_username_password": "Bitte Benutzername und Passwort eingeben.", + "points": "Punkte", + "previous": "Zurück", + "question": "Frage", + "questions_filled": "Antworten", + "refresh": "Aktualisieren", + "review_scores": "Punkte prüfen", + "review_scores_agree": "Berechnete Kategorie bestätigen", + "review_scores_calculated_category": "Berechnete Kategorie", + "review_scores_coach_category": "Berater-Kategorie", + "review_scores_empty": "Noch keine vollständigen Profilwerte zum Prüfen.", + "review_scores_pending_upload": "Ausstehend — wird mit dem Upload gesendet", + "review_scores_complete_body": "Alle Kategorien sind ausgewählt. Die Prüfung wird beim nächsten Upload gesendet.", + "review_scores_complete_title": "Punkteprüfung abgeschlossen", + "review_scores_save_failed": "Kategorie konnte nicht gespeichert werden.", + "review_scores_saved": "Kategorie gespeichert.", + "review_scores_saved_pending": "Kategorie gespeichert — wird beim Upload gesendet.", + "review_scores_set_category": "Kategorie setzen", + "review_scores_subtitle": "Prüfen Sie die berechneten Werte und bestätigen oder setzen Sie die Kategorie.", + "review_scores_total": "Gewichtete Summe", + "save": "Speichern", + "save_password_btn": "Passwort speichern", + "select_one_answer": "Bitte wählen Sie eine Antwort aus!", + "select_one_answer_per_row": "Bitte wählen Sie eine Antwort pro Reihe aus!", + "september": "September", + "session_dash": "Sitzung: —", + "session_label": "Sitzung", + "start": "Start", + "start_upload": "Upload starten?", + "upload": "Hochladen", + "upload_failed_title": "Upload fehlgeschlagen", + "upload_item_scoring_review": "Punkteprüfung: {profile}", + "upload_nothing_pending": "Es wurde nichts hochgeladen. Es liegen keine abgeschlossenen Fragebögen zum Upload vor. Bitte zuerst einen Fragebogen abschließen.", + "upload_prepare_failed": "Es wurde nichts hochgeladen. Die Daten konnten nicht für den Upload vorbereitet werden. Bitte erneut synchronisieren oder den Support kontaktieren.", + "upload_scoring_review_summary": "Berater: {band} · Summe {total}", + "upload_summary": "{qn} Fragebögen · {scores} Punkteprüfungen · {clients} Klienten", + "upload_success_message": "Hochladen: {count} erledigt.", + "upload_success_questionnaires": "{count} Fragebogen/Fragebögen", + "upload_success_scoring_reviews": "{count} Punkteprüfung(en)", + "upload_success_sync_next": "Nach diesem Upload startet die Synchronisierung automatisch.", + "upload_success_title": "Upload abgeschlossen", + "username_hint": "Benutzername", + "year": "Jahr", + "questionnaire_group_barometer_measures": "Barometer-Maßnahmen", + "brs_instruction": "Geben Sie bitte an, wie sehr Sie den folgenden Aussagen jeweils zustimmen.", + "brs_item_1_note_before": "Geben Sie bitte an, wie sehr Sie den folgenden Aussagen jeweils zustimmen.", + "finish_data_entry": "Speichern", + "brs_item_1": "Ich neige dazu, mich nach schwierigen Zeiten schnell zu erholen.", + "strongly_disagree": "Stimme überhaupt nicht zu", + "disagree": "Stimme nicht zu", + "neutral": "Neutral", + "agree": "Stimme zu", + "strongly_agree": "Stimme voll und ganz zu", + "brs_item_2": "Es fällt mir, schwer stressige Situationen durchzustehen.", + "brs_item_3": "Ich brauche nicht viel Zeit, um mich von einem stressigen Ereignis zu erholen.", + "brs_item_4": "Es fällt mir schwer zur Normalität zurückzukehren, wenn etwas Schlimmes passiert ist.", + "brs_item_5": "Normalerweise überstehe ich schwierige Zeiten ohne größere Probleme.", + "brs_item_6": "Ich brauche tendenziell lange, um über Rückschläge in meinem Leben hinwegzukommen.", + "data_final_warning": "Bitte prüfen Sie Ihre Angaben. Nach dem Speichern kehren Sie zur Sitzungsübersicht zurück.", + "fies_store_access_question": "Ist es schwierig, Geschäfte zu finden, die die von Ihnen bevorzugten Lebensmittel anbieten?", + "very_difficult": "sehr schwer", + "somewhat_difficult": "schwierig", + "neither": "Weder noch", + "somewhat_easy": "wenig", + "very_easy": "sehr leicht", + "fies_item_2": "Gab es in den letzten 4 Wochen Zeiten, in denen Sie besorgt waren, nicht genug zu essen zu haben, weil Sie nicht genug Geld oder sonstige Ressourcen hatten.", + "yes": "Ja", + "no": "Nein", + "fies_item_3": "Gab es in den letzten 4 Wochen Zeiten, in denen Sie sich nicht gesund und ausgewogen ernähren konnten, weil Sie nicht genug Geld oder sonstige Ressourcen hatten.", + "fies_item_4": "Gab es in den letzten 4 Wochen Zeiten, in denen Sie nur eine limitierte Auswahl an Lebensmitteln gegessen haben, weil Sie nicht genug Geld oder sonstige Ressourcen hatten.", + "fies_item_5": "Gab es in den letzten 4 Wochen Zeiten, in denen Sie eine Mahlzeit auslassen mussten, weil Sie nicht genug Geld oder sonstige Ressourcen hatten, um Essen zu kaufen.", + "fies_item_6": "Gab es in den letzten 4 Wochen Zeiten, in denen Sie weniger aßen als Sie Ihrer Ansicht nach essen sollten, weil Sie nicht genug Geld oder sonstige Ressourcen hatten.", + "fies_item_7": "Gab es in den letzten 4 Wochen Zeiten, in denen Ihrem Haushalt die Lebensmittel ausgingen, weil Sie nicht genug Geld oder sonstige Ressourcen hatten.", + "fies_item_8": "Gab es in den letzten 4 Wochen Zeiten, in denen Sie hungrig waren, aber nicht aßen, weil Sie nicht genug Geld oder sonstige Ressourcen für Essen hatten.", + "fies_item_9": "Gab es in den letzten 4 Wochen Zeiten, in denen Sie einen ganzen Tag lang nichts gegessen haben, weil Sie nicht genug Geld oder sonstige Ressourcen hatten.", + "ges3_instruction": "Die folgenden Aussagen können mehr oder weniger auf Sie zutreffen. Bitte geben Sie bei jeder Aussage an, inwieweit diese auf Sie persönlich zutrifft.", + "ges3_item_1_note_before": "Die folgenden Aussagen können mehr oder weniger auf Sie zutreffen. Bitte geben Sie bei jeder Aussage an, inwieweit diese auf Sie persönlich zutrifft.", + "ges3_item_1": "In schwierigen Situationen kann ich mich auf meine Fähigkeiten verlassen.", + "not_at_all": "gar nicht", + "a_little": "wenig", + "somewhat": "mittel", + "mostly": "trifft ziemlich zu", + "completely": "trifft voll und ganz zu", + "ges3_item_2": "Die meisten Probleme kann ich aus eigener Kraft gut meistern.", + "ges3_item_3": "Auch anstrengende und komplizierte Aufgaben kann ich in der Regel gut lösen.", + "intro_life_in_germany": "Im Folgenden geht es um Ihr Leben in Deutschland.", + "feeling_connected_to_germany_question": "Wie stark fühlen Sie sich Deutschland verbunden?", + "very_connected": "sehr eng", + "quite_connected": "ziemlich eng", + "moderately_connected": "mäßig eng", + "somewhat_loose": "eher locker", + "not_connected_at_all": "überhaupt nicht eng", + "understanding_political_issues": "Wie gut verstehen Sie die wichtigsten politischen Fragen in Deutschland?", + "very_good": "sehr gut", + "good": "gut", + "fairly_good": "ziemlich gut", + "unexpected_expense_question": "Stellen Sie sich vor, dass Sie eine unerwartete, aber notwendige Zahlung tätigen müssen. Könnten Sie sich die folgenden Euro-Zahlungen leisten? (Sie können mehrere Antworten ankreuzen)", + "expense_50": "50 €", + "expense_100": "100 €", + "expense_300": "300 €", + "expense_500": "500 €", + "expense_800": "800 €", + "dining_with_germans_question": "Wie oft haben Sie in den letzten 12 Monaten mit Deutschen eine Mahlzeit gegessen oder haben gemeinsam etwas unternommen?", + "never": "nie", + "once_a_year": "2 mal im Jahr", + "once_a_month": "1 mal im Monat", + "once_a_week": "1 mal pro Woche", + "almost_every_day": "fast jeden Tag", + "reading_german_articles_question": "Wie gut können Sie auf Deutsch einfache Zeitungsartikel über Ihnen vertraute Themen lesen und die wichtigsten Punkte verstehen?", + "moderately_good": "mäßig gut", + "not_good": "nicht gut", + "visiting_doctor_question": "Wie leicht oder schwer würde es Ihnen hier fallen einen Arzt aufzusuchen?", + "rather_difficult": "eher schwer", + "neither_nor": "weder schwer noch leicht", + "rather_easy": "eher leicht", + "feeling_as_outsider_question": "Wie oft fühlen Sie sich in Deutschland als Außenseiter?", + "rarely": "selten", + "sometimes": "manchmal", + "often": "oft", + "always": "immer", + "recent_occupation_question": "Waren Sie in den letzten vier Wochen beschäftigt? (Bitte nur eine Antwort)", + "employed": "in bezahlter Beschäftigung, auch wenn Sie nur kurzfristig nicht gearbeitet haben (angestellt, selbständig, kleinere Jobs)", + "education": "in Ausbildung (Schule, Berufsausbildung, Hochschule), auch wenn Ferien waren", + "internship": "Praktikum", + "unemployed_searching": "arbeitslos und aktiv auf Arbeitssuche /arbeitslos und nicht aktiv auf Arbeitssuche", + "unemployed_not_searching": "krank oder arbeitsunfähig", + "sick_or_disabled": "in unbezahlter Hausarbeit, Betreuung von Kindern / anderer Personen", + "unpaid_housework": "Sonstiges (z.B. Sprachkurs)", + "discussing_politics_question": "Wie oft haben Sie, seit Sie in Deutschland sind, mit anderen Leuten über wichtige politische Themen in Deutschland diskutiert?", + "contact_with_germans_question": "Mit wie vielen Deutschen haben Sie sich in den letzten 4 Wochen entweder telefonisch, per Messenger-Chat (Whatsapp, Facebook...) oder per SMS ausgetauscht?", + "zero": "mit keinem", + "one_to_three": "mit 1 bis 3", + "three_to_six": "mit 3 bis 6", + "seven_to_fourteen": "mit 7 bis14", + "fifteen_or_more": "mit 15 oder mehr", + "speaking_german_opinion_question": "Wie gut können Sie in einer Unterhaltung auf Deutsch über vertraute Themen sprechen und Ihre Meinung ausdrücken?", + "job_search_question": "Wie leicht oder schwer würde es Ihnen in diesem Land fallen, einen Job zu suchen (richtige Angebote finden)?", + "lsns_item_1": "Wie viele Familienangehörige sehen Sie mindestens einmal im Monat oder hören von ihnen (z.B. telefonisch)?", + "one": "1", + "two": "2", + "three_or_four": "3 oder 4", + "five_to_eight": "5 bis 8", + "nine_or_more": "9 oder mehr", + "lsns_item_2": "Mit wie vielen Verwandten fühlen Sie sich so wohl, dass Sie über private Dinge sprechen können?", + "lsns_item_3": "Wie vielen Familienangehörigen fühlen Sie sich so nahe, dass Sie sie um Hilfe bitten könnten?", + "lsns_item_4": "Wie viele Ihrer Freunde sehen Sie mindestens einmal im Monat oder hören von ihnen (z.B. telefonisch)?", + "lsns_item_5": "Mit wie vielen Freunden fühlen Sie sich so wohl, dass Sie über private Dinge sprechen können?", + "lsns_item_6": "Wie vielen Nachbarn fühlen Sie sich so nahe, dass Sie sie um Hilfe bitten könnten?", + "mhls_instruction": "Bitte geben Sie an, inwieweit Sie den folgenden Aussagen zustimmen.", + "mhls_item_1_note_before": "Bitte geben Sie an, inwieweit Sie den folgenden Aussagen zustimmen.", + "mhls_item_1": "Ich bin zuversichtlich, dass ich wüsste, wo ich Informationen über psychische Erkrankungen finden kann.", + "mhls_item_2": "Ich bin sicher im Umgang mit dem Computer oder dem Telefon, um Informationen über psychische Erkrankungen zu finden.", + "mhls_item_3": "Ich traue mir zu, persönliche Termine wahrzunehmen, um Informationen über psychische Erkrankungen zu erhalten (z. B. beim Hausarzt/Allgemeinarzt).", + "mhls_item_4": "Ich bin mir sicher, dass ich Zugang zu Ressourcen habe (z. B. Hausarzt, Internet, Freunde), die ich nutzen kann, um mich über psychische Erkrankungen zu informieren.", + "mhls_item_5": "Menschen mit einer psychischen Erkrankung können diese überwinden, wenn sie es wollen.", + "mhls_item_6": "Eine psychische Krankheit ist ein Zeichen von persönlicher Schwäche.", + "mhls_item_7": "Eine psychische Krankheit ist keine echte medizinische Krankheit.", + "mhls_item_8": "Menschen mit einer psychischen Erkrankung sind gefährlich.", + "mhls_item_9": "Es ist am besten, Menschen mit psychischen Erkrankungen zu meiden, damit man dieses Problem nicht selbst bekommt.", + "mhls_item_10": "Wenn ich eine psychische Krankheit hätte, würde ich es niemandem erzählen.", + "mhls_item_11": "Wenn man sich an eine psychologische Beratungsstelle / Psychotherapeut wendet, heißt das, dass man nicht stark genug ist, seine eigenen Schwierigkeiten zu bewältigen.", + "mhls_item_12": "Wenn ich eine psychische Erkrankung hätte, würde ich keine Hilfe bei einer psychologische Beratungsstelle/Psychotherapeut suchen.", + "mhls_item_13": "Ich glaube, dass die Behandlung einer psychischen Erkrankung, die von einer psychologische Beratungsstelle/Psychotherapeut durchgeführt wird, nicht wirksam wäre.", + "rhs_symptom_1": "Muskel-, Knochen-, Gelenk- schmerzen", + "rhs_symptom_2": "Sich die meiste Zeit unglücklich, traurig, oder deprimiert fühlen", + "rhs_symptom_3": "Zu viel Nachdenken oder sich zu viele Gedanken machen", + "rhs_symptom_4": "Sich hilflos fühlen", + "rhs_symptom_5": "Sich plötzlich ohne Grund erschrecken", + "rhs_symptom_6": "Mattigkeit, Schwindel, oder Schwächegefühl", + "rhs_symptom_7": "Nervosität oder innere Unsicherheit", + "rhs_symptom_8": "Sich ruhelos fühlen, nicht still sitzen können", + "rhs_symptom_9": "Leicht weinen müssen", + "q10_reexperience_trauma": "Wie stark haben Sie im vergangenen Monat die Erfahrung gemacht, das Trauma wieder zu erleben; sich zu verhalten oder zu fühlen, als geschähe es wieder?", + "q11_physical_reaction": "Wie stark haben Sie im vergangenen Monat körperliche Reaktionen gehabt (z.B. Schweißausbruchausbruch, schneller Herzschlag), wenn Sie an das Trauma erinnert wurden?", + "q12_emotional_numbness": "Wie stark haben Sie im vergangenen Monat sich emotional taub gefühlt (z.B. sich traurig fühlen, aber nicht weinen können, keine liebevollen Gefühle empfinden können)?", + "q13_easily_startled": "Wie stark haben Sie im vergangenen Monat sich schreckhafter, leichter erschreckbar gefühlt (z.B. wenn jemand von hinten näher kommt)?", + "resilience_reflection_prompt": "Welche der folgenden Aussagen trifft am besten für Sie zu: Wenn Sie an Ihr Leben in den letzten vier Wochen denken, wie stark haben Sie das Gefühl, dass Sie:", + "glass_explanation": "Bitte geben Sie an, in welchem Ausmaß das Problem während des letzten Monats/ der letzten 4 Wochen störend war. Die Antwortmöglichkeiten sind in Worten und am Bild eines Glasbehälters dargestellt: wenn etwas überhaupt nicht für Sie zutrifft, ist das Glas leer, wenn etwas ein wenig oder ziemlich zutrifft, wird das Glas zunehmend voller, wenn Sie etwas extrem erlebt haben, ist das Glas voll.", + "how_strong_past_month": "Trauma-bezogene Beschwerden im vergangenen Monat", + "q14_intro": "Welche der folgenden Aussagen trifft am besten für Sie zu: Wenn Sie an Ihr Leben in den letzten vier Wochen denken, wie stark haben Sie das Gefühl, dass Sie:", + "resilience_fully_capable": "Dazu in der Lage sind, alles zu schaffen (zu bewältigen), was Ihnen begegnet", + "resilience_mostly_capable": "Dazu in der Lage sind, die meisten Dinge zu schaffen (zu bewältigen), die Ihnen begegnen", + "resilience_some_capable_some_not": "Dazu in der Lage sind, manche Dinge zu schaffen (zu bewältigen), aber andere Dinge nicht zu schaffen/zu bewältigen", + "resilience_mostly_not_capable": "Nicht dazu in der Lage sind, die meisten Dinge zu schaffen (zu bewältigen)", + "resilience_not_capable": "Nicht dazu in der Lage sind, irgendetwas zu schaffen", + "pain_rating_instruction": "Wählen Sie die Nummer (0-10), die am besten beschreibt, wie viel Leid Sie in der vergangenen Woche einschließlich heute fühlen müssen.", + "swls_instruction": "Es folgen fünf Aussagen, denen Sie zustimmen bzw. die Sie ablehnen können. Bitte benutzen Sie die folgende Skala, um Ihre Zustimmung bzw. Ablehnung zu jeder Aussage zum Ausdruck zu bringen.", + "swls_item_1_note_before": "Es folgen fünf Aussagen, denen Sie zustimmen bzw. die Sie ablehnen können. Bitte benutzen Sie die folgende Skala, um Ihre Zustimmung bzw. Ablehnung zu jeder Aussage zum Ausdruck zu bringen.", + "swls_item_1": "In den meisten Bereichen entspricht mein Leben meinen Idealvorstellungen..", + "swls_1": "trifft überhaupt nicht zu", + "swls_2": "trifft nicht zu", + "swls_3": "trifft eher nicht zu", + "swls_4": "teils/teils", + "swls_5": "trifft eher zu", + "swls_6": "trifft zu", + "swls_7": "trifft vollständig zu", + "swls_item_2": "Meine Lebensbedingungen sind ausgezeichnet.", + "swls_item_3": "Ich bin mit meinem Leben zufrieden.", + "swls_item_4": "Bisher habe ich die wesentlichen Dinge erreicht, die ich mir für mein Leben wünsche.", + "swls_item_5": "Wenn ich mein Leben noch einmal leben könnte, würde ich kaum etwas ändern.", + "sss8_instruction": "Wie stark fühlten Sie sich im Verlauf der letzten 7 Tage durch die folgenden Beschwerden beeinträchtigt?", + "sss8_item_1_note_before": "Wie stark fühlten Sie sich im Verlauf der letzten 7 Tage durch die folgenden Beschwerden beeinträchtigt?", + "sss8_item_1": "Bauchschmerzen oder Verdauungsbeschwerden", + "quite_a_bit": "stark", + "very_much": "sehr stark", + "sss8_item_2": "Rückenschmerzen", + "sss8_item_3": "Schmerzen in Armen, Beinen oder Gelenken", + "sss8_item_4": "Kopfschmerzen", + "sss8_item_5": "Schmerzen im Brustbereich oder Kurzatmigkeit", + "sss8_item_6": "Schwindel", + "sss8_item_7": "Müdigkeit oder Gefühl, keine Energie zu haben", + "sss8_item_8": "Schlafstörungen" +} \ No newline at end of file diff --git a/app/src/main/java/com/tomhempel/rbarometer/MainActivity.kt b/app/src/main/java/com/tomhempel/rbarometer/MainActivity.kt index 648362c..ce381cb 100644 --- a/app/src/main/java/com/tomhempel/rbarometer/MainActivity.kt +++ b/app/src/main/java/com/tomhempel/rbarometer/MainActivity.kt @@ -37,6 +37,8 @@ import com.tomhempel.rbarometer.network.SyncProgress import com.tomhempel.rbarometer.network.SyncResult import com.tomhempel.rbarometer.network.TokenStore import com.tomhempel.rbarometer.questionnaire.QuestionnaireBase +import com.tomhempel.rbarometer.questionnaire.QuestionnaireLanguagePicker +import com.tomhempel.rbarometer.session.SessionFlowController import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -54,6 +56,10 @@ class MainActivity : AppCompatActivity() { private lateinit var openingScreenHandler: HandlerOpeningScreen var isInQuestionnaire: Boolean = false + private var sessionFlowController: SessionFlowController? = null + private var activeQuestionnaire: QuestionnaireBase<*>? = null + + val isInSessionFlow: Boolean get() = sessionFlowController != null var isFirstQuestionnairePage: Boolean = false /** Set before opening DevSettings so onResume runs a full opening-screen rebuild. */ private var needsOpeningReinit: Boolean = false @@ -104,6 +110,7 @@ class MainActivity : AppCompatActivity() { override fun handleOnBackPressed() { when { isInQuestionnaire -> confirmExitQuestionnaire() + isInSessionFlow -> sessionFlowController?.pauseFromBackPress() backPressInterceptor?.invoke() == true -> Unit ::openingScreenHandler.isInitialized && openingScreenHandler.onBackPressed() -> Unit else -> { @@ -168,7 +175,7 @@ class MainActivity : AppCompatActivity() { private suspend fun routeCachedStartup(token: String) { val hasPendingUploads = withContext(Dispatchers.IO) { - PendingUploadRepository.hasAny() + PendingUploadRepository.hasUploadReady(this@MainActivity) } if (hasPendingUploads || !NetworkUtils.isOnline(this)) { showCachedOpeningScreen(refreshAfterOpen = false, showUploadPrompt = hasPendingUploads) @@ -487,20 +494,46 @@ class MainActivity : AppCompatActivity() { super.onConfigurationChanged(newConfig) } + fun startSessionFlow(clientCode: String, languageID: String) { + sessionFlowController = SessionFlowController(this, languageID, clientCode) { completion -> + sessionFlowController = null + isInQuestionnaire = false + if (::openingScreenHandler.isInitialized) { + openingScreenHandler.refreshOpeningUi() + if (completion != null) { + openingScreenHandler.showSessionCompletionDialog(completion) + } + } + } + sessionFlowController?.start() + } + fun startQuestionnaire(questionnaire: QuestionnaireBase<*>, languageID: String) { + activeQuestionnaire = questionnaire isInQuestionnaire = true isFirstQuestionnairePage = true questionnaire.attach(this, languageID) questionnaire.startQuestionnaire() } - fun finishQuestionnaire() { + fun finishQuestionnaire(wasCompleted: Boolean = false) { + val draftSnapshot = activeQuestionnaire?.draftSnapshot() + activeQuestionnaire = null + QuestionnaireLanguagePicker.detach(this) + if (sessionFlowController?.handleQuestionnaireFinished(wasCompleted, draftSnapshot) == true) { + isInQuestionnaire = false + isFirstQuestionnairePage = false + ViewUtils.setTextSizeScale(1f) + LanguageManager.clearQuestionnaireTranslations() + return + } isInQuestionnaire = false isFirstQuestionnairePage = false ViewUtils.setTextSizeScale(1f) LanguageManager.clearQuestionnaireTranslations() + sessionFlowController = null if (::openingScreenHandler.isInitialized) { - openingScreenHandler.init() + openingScreenHandler.refreshOpeningUi() } } } diff --git a/app/src/main/java/com/tomhempel/rbarometer/MyApp.kt b/app/src/main/java/com/tomhempel/rbarometer/MyApp.kt index da106ac..8036ca9 100644 --- a/app/src/main/java/com/tomhempel/rbarometer/MyApp.kt +++ b/app/src/main/java/com/tomhempel/rbarometer/MyApp.kt @@ -32,6 +32,7 @@ class MyApp : Application() { super.onCreate() AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) SensitiveDataCrypto.init(this) + LanguageManager.warmBundledDefaults(this) // Room-Datenbank bauen: nur die Hauptdatei, ohne WAL und Journal database = Room.databaseBuilder( diff --git a/app/src/main/java/com/tomhempel/rbarometer/data/PendingUploadRepository.kt b/app/src/main/java/com/tomhempel/rbarometer/data/PendingUploadRepository.kt index a191d9b..240425b 100644 --- a/app/src/main/java/com/tomhempel/rbarometer/data/PendingUploadRepository.kt +++ b/app/src/main/java/com/tomhempel/rbarometer/data/PendingUploadRepository.kt @@ -1,7 +1,29 @@ package com.tomhempel.rbarometer.data +import android.content.Context +import com.tomhempel.rbarometer.session.SessionProgramHelper + object PendingUploadRepository { + suspend fun countUploadReady(context: Context): Int = + getUploadReadyQuestionnaires(context).size + + getUploadReadyScoringReviews(context).size + + suspend fun hasUploadReady(context: Context): Boolean = countUploadReady(context) > 0 + + suspend fun getUploadReadyQuestionnaires(context: Context): List = + SessionProgramHelper.filterUploadReadyQuestionnaires( + context, + CompletedQuestionnaireRepository.getAllPendingUploads(), + ) + + suspend fun getUploadReadyScoringReviews(context: Context): List = + SessionProgramHelper.filterUploadReadyScoringReviews( + context, + CoachScoringReviewRepository.getAllPendingUploads(), + ) + + /** Raw pending count (ignores session completion). */ suspend fun countAll(): Int = CompletedQuestionnaireRepository.countPendingUploads() + CoachScoringReviewRepository.countPendingUploads() diff --git a/app/src/main/java/com/tomhempel/rbarometer/network/ApiModels.kt b/app/src/main/java/com/tomhempel/rbarometer/network/ApiModels.kt index 2e6f8cf..4962360 100644 --- a/app/src/main/java/com/tomhempel/rbarometer/network/ApiModels.kt +++ b/app/src/main/java/com/tomhempel/rbarometer/network/ApiModels.kt @@ -67,6 +67,44 @@ data class SubmitQuestionnaireResponse( val sumPoints: Int = 0, val structureRevision: Int? = null, val legacySubmit: Boolean = false, + val cycleAdvanced: Boolean = false, + val cycleNumber: Int? = null, +) + +data class ProgramSessionQuestionnaire( + val questionnaireID: String, + val orderIndex: Int = 0, +) + +data class ProgramSessionDefinition( + val sessionID: String, + val name: String, + val description: String = "", + val sessionNumber: Int = 0, + val orderIndex: Int = 0, + val questionnaires: List = emptyList(), +) + +data class ProgramSessionSettings( + val autoRepeatSessions: Boolean = false, +) + +data class ProgramSessionsPayload( + val sessions: List = emptyList(), + val settings: ProgramSessionSettings = ProgramSessionSettings(), +) + +data class ProgramProgress( + val cycleNumber: Int = 1, + val cycleStartedAt: Long = 0L, + val totalSessions: Int = 0, + val sessionsCompletedInCycle: Int = 0, + val currentSessionID: String? = null, + val currentSessionNumber: Int? = null, + val currentSessionName: String? = null, + val cycleComplete: Boolean = false, + val programComplete: Boolean = false, + val autoRepeatSessions: Boolean = false, ) /** One questionnaire already completed on the server side. */ @@ -80,7 +118,8 @@ data class ServerCompletedQuestionnaire( /** A client with its server-side completion records. */ data class AssignedClientInfo( val clientCode: String, - val completedQuestionnaires: List = emptyList() + val completedQuestionnaires: List = emptyList(), + val programProgress: ProgramProgress? = null, ) data class ScoringProfileMember( diff --git a/app/src/main/java/com/tomhempel/rbarometer/network/DatabaseUploader.kt b/app/src/main/java/com/tomhempel/rbarometer/network/DatabaseUploader.kt index f9f9bb4..5946465 100644 --- a/app/src/main/java/com/tomhempel/rbarometer/network/DatabaseUploader.kt +++ b/app/src/main/java/com/tomhempel/rbarometer/network/DatabaseUploader.kt @@ -3,6 +3,7 @@ package com.tomhempel.rbarometer.network import android.content.Context import com.tomhempel.rbarometer.LanguageManager import com.tomhempel.rbarometer.notification.UploadReminderScheduler +import com.tomhempel.rbarometer.session.SessionProgramHelper import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -48,10 +49,14 @@ object DatabaseUploader { onResult: (UploadResult) -> Unit, ) { CoroutineScope(Dispatchers.IO).launch { - val allPending = com.tomhempel.rbarometer.data.CompletedQuestionnaireRepository - .getAllPendingUploads() - val pendingScoring = com.tomhempel.rbarometer.data.CoachScoringReviewRepository - .getAllPendingUploads() + val allPending = SessionProgramHelper.filterUploadReadyQuestionnaires( + context, + com.tomhempel.rbarometer.data.CompletedQuestionnaireRepository.getAllPendingUploads(), + ) + val pendingScoring = SessionProgramHelper.filterUploadReadyScoringReviews( + context, + com.tomhempel.rbarometer.data.CoachScoringReviewRepository.getAllPendingUploads(), + ) if (allPending.isEmpty() && pendingScoring.isEmpty()) { val assigned = QuestionnaireCache.getAssignedClients(context) if (assigned.isEmpty()) { @@ -189,22 +194,12 @@ object DatabaseUploader { } if (!errors.any() && (submittedQuestionnaires + submittedScoringReviews) > 0) { - val stillPending = UploadReminderScheduler.hasPendingUploads() + val stillPending = UploadReminderScheduler.hasPendingUploads(context) if (!stillPending) { UploadReminderScheduler.cancel(context) } } - // Refresh server cache after upload so local interview rows are not wiped first. - runCatching { - SyncCoordinator.refreshManual(context, token) - }.onFailure { e -> - SyncLog.w("UPLOAD", "Post-upload sync failed: ${e.message}") - if (TokenStore.isAuthError(e)) { - errors += UploadError(clientCode = "", message = "Session expired", isAuthError = true) - } - } - withContext(Dispatchers.Main) { onResult( UploadResult( @@ -216,6 +211,13 @@ object DatabaseUploader { ), ) } + + // Refresh cache in the background; opening screen runs a visible sync after the user closes upload. + runCatching { + SyncCoordinator.refreshManual(context, token) + }.onFailure { e -> + SyncLog.w("UPLOAD", "Post-upload sync failed: ${e.message}") + } } } } diff --git a/app/src/main/java/com/tomhempel/rbarometer/network/HttpModule.kt b/app/src/main/java/com/tomhempel/rbarometer/network/HttpModule.kt index 1f26d0d..ad893e4 100644 --- a/app/src/main/java/com/tomhempel/rbarometer/network/HttpModule.kt +++ b/app/src/main/java/com/tomhempel/rbarometer/network/HttpModule.kt @@ -5,7 +5,7 @@ import okhttp3.OkHttpClient import java.util.concurrent.TimeUnit object HttpModule { - const val BASE_URL = "http://49.13.157.44/nat-as-server/api" + const val BASE_URL = "http://49.13.157.44/barometer-server/api" val client: OkHttpClient by lazy { val dispatcher = Dispatcher().apply { diff --git a/app/src/main/java/com/tomhempel/rbarometer/network/QuestionnaireApiClient.kt b/app/src/main/java/com/tomhempel/rbarometer/network/QuestionnaireApiClient.kt index a0e9ae3..94b8ec6 100644 --- a/app/src/main/java/com/tomhempel/rbarometer/network/QuestionnaireApiClient.kt +++ b/app/src/main/java/com/tomhempel/rbarometer/network/QuestionnaireApiClient.kt @@ -202,7 +202,11 @@ object QuestionnaireApiClient { completedAt = cObj.get("completedAt")?.asLong ?: 0L ) } ?: emptyList() - AssignedClientInfo(clientCode = code, completedQuestionnaires = completions) + AssignedClientInfo( + clientCode = code, + completedQuestionnaires = completions, + programProgress = parseProgramProgress(obj.get("programProgress")), + ) } else -> null } @@ -300,6 +304,67 @@ object QuestionnaireApiClient { } } + suspend fun getProgramSessions(token: String): ProgramSessionsPayload = withContext(Dispatchers.IO) { + val request = authorizedRequest(token) + .url("${HttpModule.BASE_URL}/app_questionnaires?sessions=1") + .get() + .build() + + client.newCall(request).execute().use { response -> + val bodyText = response.body?.string() + SyncLog.apiResponse("API_PROGRAM_SESSIONS", "getProgramSessions", response.code, bodyText) + val root = parseEnvelope(response.code, bodyText) + val data = root.getAsJsonObject("data") ?: JsonObject() + val sessionsArr = data.getAsJsonArray("sessions") ?: JsonArray() + val sessions = sessionsArr.mapNotNull { item -> + if (!item.isJsonObject) return@mapNotNull null + val obj = item.asJsonObject + val sessionID = obj.get("sessionID")?.asString?.takeIf { it.isNotBlank() } + ?: return@mapNotNull null + val members = obj.getAsJsonArray("questionnaires")?.mapNotNull { mEl -> + if (!mEl.isJsonObject) return@mapNotNull null + val m = mEl.asJsonObject + val qId = m.get("questionnaireID")?.asString?.takeIf { it.isNotBlank() } + ?: return@mapNotNull null + ProgramSessionQuestionnaire( + questionnaireID = qId, + orderIndex = m.get("orderIndex")?.asInt ?: 0, + ) + }?.sortedBy { it.orderIndex } ?: emptyList() + ProgramSessionDefinition( + sessionID = sessionID, + name = obj.get("name")?.asString ?: sessionID, + description = obj.get("description")?.asString.orEmpty(), + sessionNumber = obj.get("sessionNumber")?.asInt ?: 0, + orderIndex = obj.get("orderIndex")?.asInt ?: 0, + questionnaires = members, + ) + }.sortedBy { it.orderIndex } + val settingsObj = data.getAsJsonObject("settings") + val settings = ProgramSessionSettings( + autoRepeatSessions = settingsObj?.get("autoRepeatSessions")?.asBoolean ?: false, + ) + ProgramSessionsPayload(sessions = sessions, settings = settings) + } + } + + private fun parseProgramProgress(element: JsonElement?): ProgramProgress? { + if (element == null || element.isJsonNull || !element.isJsonObject) return null + val obj = element.asJsonObject + return ProgramProgress( + cycleNumber = obj.get("cycleNumber")?.asInt ?: 1, + cycleStartedAt = obj.get("cycleStartedAt")?.asLong ?: 0L, + totalSessions = obj.get("totalSessions")?.asInt ?: 0, + sessionsCompletedInCycle = obj.get("sessionsCompletedInCycle")?.asInt ?: 0, + currentSessionID = obj.get("currentSessionID")?.takeIf { !it.isJsonNull }?.asString, + currentSessionNumber = obj.get("currentSessionNumber")?.takeIf { !it.isJsonNull }?.asInt, + currentSessionName = obj.get("currentSessionName")?.takeIf { !it.isJsonNull }?.asString, + cycleComplete = obj.get("cycleComplete")?.asBoolean ?: false, + programComplete = obj.get("programComplete")?.asBoolean ?: false, + autoRepeatSessions = obj.get("autoRepeatSessions")?.asBoolean ?: false, + ) + } + suspend fun getQuestionnaire(token: String, questionnaireId: String): JsonObject = withContext(Dispatchers.IO) { val request = authorizedRequest(token) .url("${HttpModule.BASE_URL}/app_questionnaires?id=$questionnaireId") diff --git a/app/src/main/java/com/tomhempel/rbarometer/network/QuestionnaireCache.kt b/app/src/main/java/com/tomhempel/rbarometer/network/QuestionnaireCache.kt index a5f2608..6f3c20b 100644 --- a/app/src/main/java/com/tomhempel/rbarometer/network/QuestionnaireCache.kt +++ b/app/src/main/java/com/tomhempel/rbarometer/network/QuestionnaireCache.kt @@ -17,6 +17,8 @@ object QuestionnaireCache { private const val TRANSLATIONS_FILE = "translations.json" private const val QUESTIONNAIRES_FILE = "questionnaires.json" private const val CLIENTS_FILE = "assigned_clients.json" + private const val CLIENT_INFOS_FILE = "assigned_client_infos.json" + private const val PROGRAM_SESSIONS_FILE = "program_sessions.json" private const val SCORING_PROFILES_FILE = "scoring_profiles.json" private val gson = Gson() @@ -140,6 +142,44 @@ object QuestionnaireCache { }.getOrDefault(emptyList()) } + fun saveAssignedClientInfos(context: Context, infos: List) { + writeCacheText(clientInfosFile(context), gson.toJson(infos)) + } + + fun getAssignedClientInfos(context: Context): List { + val file = clientInfosFile(context) + if (!file.exists()) return emptyList() + return runCatching { + gson.fromJson(readCacheText(file), Array::class.java).toList() + }.getOrDefault(emptyList()) + } + + fun getProgramProgress(context: Context, clientCode: String): ProgramProgress? = + getAssignedClientInfos(context) + .firstOrNull { it.clientCode.equals(clientCode, ignoreCase = true) } + ?.programProgress + + fun saveProgramSessions(context: Context, payload: ProgramSessionsPayload) { + writeCacheText(programSessionsFile(context), gson.toJson(payload)) + } + + fun getProgramSessionsPayload(context: Context): ProgramSessionsPayload { + val file = programSessionsFile(context) + if (!file.exists()) return ProgramSessionsPayload() + return runCatching { + gson.fromJson(readCacheText(file), ProgramSessionsPayload::class.java) + }.getOrDefault(ProgramSessionsPayload()) + } + + fun getProgramSessions(context: Context): List = + getProgramSessionsPayload(context).sessions + + fun getProgramSessionSettings(context: Context): ProgramSessionSettings = + getProgramSessionsPayload(context).settings + + fun findProgramSession(context: Context, sessionID: String): ProgramSessionDefinition? = + getProgramSessions(context).firstOrNull { it.sessionID == sessionID } + private fun parseTranslations(root: JsonObject): Map> { return root.entrySet().mapNotNull { (language, values) -> if (!values.isJsonObject) return@mapNotNull null @@ -165,6 +205,12 @@ object QuestionnaireCache { private fun clientsFile(context: Context): File = File(cacheDir(context), CLIENTS_FILE) + private fun clientInfosFile(context: Context): File = + File(cacheDir(context), CLIENT_INFOS_FILE) + + private fun programSessionsFile(context: Context): File = + File(cacheDir(context), PROGRAM_SESSIONS_FILE) + private fun writeCacheText(file: File, plain: String) { file.writeText(SensitiveDataCrypto.encrypt(plain)) } diff --git a/app/src/main/java/com/tomhempel/rbarometer/network/QuestionnaireSyncService.kt b/app/src/main/java/com/tomhempel/rbarometer/network/QuestionnaireSyncService.kt index c2b2281..38d9222 100644 --- a/app/src/main/java/com/tomhempel/rbarometer/network/QuestionnaireSyncService.kt +++ b/app/src/main/java/com/tomhempel/rbarometer/network/QuestionnaireSyncService.kt @@ -47,6 +47,9 @@ object QuestionnaireSyncService { val scoringProfilesDeferred = async(Dispatchers.IO) { runCatching { QuestionnaireApiClient.getScoringProfiles(token) } } + val programSessionsDeferred = async(Dispatchers.IO) { + runCatching { QuestionnaireApiClient.getProgramSessions(token) } + } val detailsDeferred = async { fetchAndCacheQuestionnaireDetails(context, token, questionnaires) } @@ -58,6 +61,12 @@ object QuestionnaireSyncService { }.onFailure { e -> SyncLog.w(TAG, "getScoringProfiles failed: ${e.message}") } + programSessionsDeferred.await().onSuccess { payload -> + QuestionnaireCache.saveProgramSessions(context, payload) + SyncLog.d(TAG, "program sessions cached: ${payload.sessions.size}") + }.onFailure { e -> + SyncLog.w(TAG, "getProgramSessions failed: ${e.message}") + } pacer.emit(SyncPhase.DOWNLOADING_CLIENT_INFO) val clientInfosFromApi = clientResult.getOrElse { e -> SyncLog.w(TAG, "getAssignedClients failed: ${e.message}") @@ -71,7 +80,7 @@ object QuestionnaireSyncService { else -> emptyList() } - persistAssignedClients(context, apiSucceeded, assignedClientCodes, clientCodesToSave) + persistAssignedClients(context, apiSucceeded, assignedClientCodes, clientCodesToSave, clientInfosFromApi) if (!forceAnswerPull || !pullAnswers) { seedServerCompletions(clientInfosFromApi) @@ -155,7 +164,7 @@ object QuestionnaireSyncService { else -> emptyList() } - persistAssignedClients(context, apiSucceeded, fallbackCodes, clientCodesToSave) + persistAssignedClients(context, apiSucceeded, fallbackCodes, clientCodesToSave, clientInfosFromApi) if (!forceAnswerPull || !pullAnswers) { seedServerCompletions(clientInfosFromApi) @@ -186,10 +195,12 @@ object QuestionnaireSyncService { apiSucceeded: Boolean, fallbackCodes: List, clientCodesToSave: List, + clientInfosFromApi: List = emptyList(), ) { when { apiSucceeded -> { QuestionnaireCache.replaceAssignedClients(context, clientCodesToSave) + QuestionnaireCache.saveAssignedClientInfos(context, clientInfosFromApi) SyncLog.d(TAG, "assigned clients from API: ${clientCodesToSave.size}") } fallbackCodes.isNotEmpty() -> { diff --git a/app/src/main/java/com/tomhempel/rbarometer/notification/UploadReminderReceiver.kt b/app/src/main/java/com/tomhempel/rbarometer/notification/UploadReminderReceiver.kt index 4b45bd7..4565af0 100644 --- a/app/src/main/java/com/tomhempel/rbarometer/notification/UploadReminderReceiver.kt +++ b/app/src/main/java/com/tomhempel/rbarometer/notification/UploadReminderReceiver.kt @@ -24,7 +24,7 @@ class UploadReminderReceiver : BroadcastReceiver() { CoroutineScope(Dispatchers.IO).launch { try { // Only fire the notification if there are actually pending uploads - val hasPending = UploadReminderScheduler.hasPendingUploads() + val hasPending = UploadReminderScheduler.hasPendingUploads(context) if (hasPending) { withContext(Dispatchers.Main) { showNotification(context) diff --git a/app/src/main/java/com/tomhempel/rbarometer/notification/UploadReminderScheduler.kt b/app/src/main/java/com/tomhempel/rbarometer/notification/UploadReminderScheduler.kt index 085c9e6..e37f130 100644 --- a/app/src/main/java/com/tomhempel/rbarometer/notification/UploadReminderScheduler.kt +++ b/app/src/main/java/com/tomhempel/rbarometer/notification/UploadReminderScheduler.kt @@ -46,12 +46,12 @@ object UploadReminderScheduler { } /** - * Returns true if any assigned client has a pending (completed but not uploaded) questionnaire. - * Runs on the calling thread — call from a background coroutine. + * Returns true if any assigned client has upload-ready pending work + * (current session fully complete when applicable). */ - suspend fun hasPendingUploads(): Boolean { + suspend fun hasPendingUploads(context: Context): Boolean { return try { - com.tomhempel.rbarometer.data.PendingUploadRepository.hasAny() + com.tomhempel.rbarometer.data.PendingUploadRepository.hasUploadReady(context) } catch (e: Exception) { Log.w(TAG, "hasPendingUploads check failed: ${e.message}") false diff --git a/app/src/main/java/com/tomhempel/rbarometer/questionnaire/QuestionnaireAllInOne.kt b/app/src/main/java/com/tomhempel/rbarometer/questionnaire/QuestionnaireAllInOne.kt index 006e258..d680d94 100644 --- a/app/src/main/java/com/tomhempel/rbarometer/questionnaire/QuestionnaireAllInOne.kt +++ b/app/src/main/java/com/tomhempel/rbarometer/questionnaire/QuestionnaireAllInOne.kt @@ -105,6 +105,8 @@ class QuestionnaireAllInOne(private val questionnaireFileName: String) : Questio ViewUtils.setTextSizeScale(ViewUtils.EMBEDDED_TEXT_SCALE) context.setContentView(R.layout.questionnaire_all_in_one) + val contentRoot = context.findViewById(android.R.id.content)?.getChildAt(0) + com.tomhempel.rbarometer.ui.SessionUi.applyQuestionScreen(context, contentRoot) ViewUtils.suppressAutofillForSubtree(context.findViewById(android.R.id.content)) container = context.findViewById(R.id.questionContainer) @@ -274,22 +276,12 @@ class QuestionnaireAllInOne(private val questionnaireFileName: String) : Questio sections.filter { it.card.visibility == View.VISIBLE }.map { it.index }.toSet() private fun setupLanguageSpinner(spinner: Spinner) { - val labels = AppLanguages.spinnerLabels(languageID) - val selected = labels.getOrNull(AppLanguages.indexOf(languageID)) - ViewUtils.setupLanguagePickerSpinner(context, spinner, labels, selected) - spinner.setSelection(AppLanguages.indexOf(languageID)) - - spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>?, v: View?, position: Int, id: Long) { - val newLang = AppLanguages.ids[position] - if (newLang == languageID) return - spinner.post { onLanguageChanged(newLang) } - } - override fun onNothingSelected(parent: AdapterView<*>?) {} + QuestionnaireLanguagePicker.wireSpinner(context, spinner, languageID) { newLang -> + onLanguageChanged(newLang) } } - private fun onLanguageChanged(newLang: String) { + override fun onLanguageChanged(newLang: String) { for (section in sections) { if (section.card.visibility == View.VISIBLE) { try { section.handler?.saveAnswer() } catch (_: Exception) {} @@ -1121,7 +1113,7 @@ class QuestionnaireAllInOne(private val questionnaireFileName: String) : Questio val validToken = token?.takeIf { it.isNotBlank() } if (activity == null || clientCode.isBlank() || validToken == null || !TokenStore.isSessionValid(activity)) { showLoading(progressBar, saveOverlay, scrollContent, false) - activity?.finishQuestionnaire() + activity?.finishQuestionnaire(wasCompleted = true) return } @@ -1131,7 +1123,7 @@ class QuestionnaireAllInOne(private val questionnaireFileName: String) : Questio } showLoading(progressBar, saveOverlay, scrollContent, false) if (!hasScores) { - activity.finishQuestionnaire() + activity.finishQuestionnaire(wasCompleted = true) return@launch } @@ -1144,7 +1136,7 @@ class QuestionnaireAllInOne(private val questionnaireFileName: String) : Questio languageID = newLang AppLanguageStore.set(activity, newLang) }, - onClose = { activity.finishQuestionnaire() }, + onClose = { activity.finishQuestionnaire(wasCompleted = true) }, ).show() } } diff --git a/app/src/main/java/com/tomhempel/rbarometer/questionnaire/QuestionnaireBase.kt b/app/src/main/java/com/tomhempel/rbarometer/questionnaire/QuestionnaireBase.kt index b74b6ed..9ef1bc0 100644 --- a/app/src/main/java/com/tomhempel/rbarometer/questionnaire/QuestionnaireBase.kt +++ b/app/src/main/java/com/tomhempel/rbarometer/questionnaire/QuestionnaireBase.kt @@ -7,6 +7,7 @@ import android.view.View import android.widget.* import com.tomhempel.rbarometer.LanguageManager import com.tomhempel.rbarometer.MainActivity +import com.tomhempel.rbarometer.session.QuestionnaireDraftSnapshot import kotlinx.coroutines.* import java.io.File import java.util.Calendar @@ -66,9 +67,21 @@ abstract class QuestionnaireBase { protected fun navigateTo(layoutResId: Int, setup: (View) -> Unit) { context.setContentView(layoutResId) - val contentHost = context.findViewById(android.R.id.content) - val rootView = contentHost?.getChildAt(0) ?: contentHost ?: context.window.decorView - setup(rootView) + val questionRoot = QuestionnaireLanguagePicker.ensureToolbarLayout( + activity = context, + languageId = languageID, + onLanguageChanged = { newLang -> + languageID = newLang + com.tomhempel.rbarometer.ui.AppLanguageStore.set(context, newLang) + onLanguageChanged(newLang) + }, + ) + com.tomhempel.rbarometer.ui.SessionUi.applyQuestionScreen(context, questionRoot) + setup(questionRoot) + } + + protected open fun onLanguageChanged(newLanguageId: String) { + showCurrentQuestion() } protected fun setupPrevButton(buttonId: Int, action: () -> Unit) { @@ -137,7 +150,17 @@ abstract class QuestionnaireBase { QuestionnairePersistence.saveAnswers(context, answers, questionnaireId, languageID, questions) } + fun draftSnapshot(): QuestionnaireDraftSnapshot? { + if (!::questionnaireMeta.isInitialized) return null + return QuestionnaireDraftSnapshot( + questionnaireId = questionnaireMeta.id, + answers = answers.toMap(), + questions = if (::questions.isInitialized) questions else emptyList(), + languageId = languageID, + ) + } + fun endQuestionnaire() { - (context as? MainActivity)?.finishQuestionnaire() + (context as? MainActivity)?.finishQuestionnaire(wasCompleted = false) } } diff --git a/app/src/main/java/com/tomhempel/rbarometer/questionnaire/QuestionnaireCompletionService.kt b/app/src/main/java/com/tomhempel/rbarometer/questionnaire/QuestionnaireCompletionService.kt index 3bd4df5..dc605eb 100644 --- a/app/src/main/java/com/tomhempel/rbarometer/questionnaire/QuestionnaireCompletionService.kt +++ b/app/src/main/java/com/tomhempel/rbarometer/questionnaire/QuestionnaireCompletionService.kt @@ -29,7 +29,7 @@ object QuestionnaireCompletionService { if (elapsed < minLoadingMs) delay(minLoadingMs - elapsed) withContext(Dispatchers.Main) { - onCompleted?.invoke() ?: (context as? MainActivity)?.finishQuestionnaire() + onCompleted?.invoke() ?: (context as? MainActivity)?.finishQuestionnaire(wasCompleted = true) } } } diff --git a/app/src/main/java/com/tomhempel/rbarometer/questionnaire/QuestionnaireGeneric.kt b/app/src/main/java/com/tomhempel/rbarometer/questionnaire/QuestionnaireGeneric.kt index cacb76f..fd2148f 100644 --- a/app/src/main/java/com/tomhempel/rbarometer/questionnaire/QuestionnaireGeneric.kt +++ b/app/src/main/java/com/tomhempel/rbarometer/questionnaire/QuestionnaireGeneric.kt @@ -1,13 +1,11 @@ package com.tomhempel.rbarometer.questionnaire -import android.view.Gravity -import android.view.View -import android.widget.AdapterView -import android.widget.Button -import android.widget.FrameLayout -import android.widget.Spinner import com.tomhempel.rbarometer.R -import com.tomhempel.rbarometer.utils.ViewUtils +import com.tomhempel.rbarometer.network.TokenStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext open class QuestionnaireGeneric(private val questionnaireFileName: String) : QuestionnaireBase() { @@ -16,64 +14,47 @@ open class QuestionnaireGeneric(private val questionnaireFileName: String) : Que questionnaireMeta = meta questions = questionsList currentIndex = 0 - showCurrentQuestion() + + val clientCode = GlobalValues.LOADED_CLIENT_CODE?.trim().orEmpty() + .ifEmpty { GlobalValues.LAST_CLIENT_CODE?.trim().orEmpty() } + if (clientCode.isEmpty()) { + showCurrentQuestion() + return + } + + CoroutineScope(Dispatchers.Main).launch { + withContext(Dispatchers.IO) { + SavedAnswerLoader.loadInto( + clientCode = clientCode, + questionnaireId = meta.id, + questions = questionsList, + answers = answers, + ) + } + answers["client_code"] = clientCode + TokenStore.getUsername(context)?.let { answers["coach_code"] = it } + showCurrentQuestion() + } } override fun showCurrentQuestion() { val question = questions[currentIndex] - val layoutResId = getLayoutResId(question.layout ?: "default_layout") - if (layoutResId == 0) { showEmptyScreen() return } navigateTo(layoutResId) { layout -> - layout.findViewById