implementing first functionality

This commit is contained in:
tom.hempel
2026-06-30 08:34:28 +02:00
parent 8004d3531f
commit 600110fd13
68 changed files with 3945 additions and 1216 deletions

1
.idea/.name generated
View File

@ -1 +0,0 @@
NAT-AS

View File

@ -4,14 +4,27 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-27T17:45:31.648786459Z">
<DropdownSelection timestamp="2026-06-29T18:39:40.854601Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=R52T605XE0L" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
<DialogSelection>
<targets>
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=R52T605XE0L" />
</handle>
</Target>
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=b6bfcb67" />
</handle>
</Target>
</targets>
</DialogSelection>
</SelectionState>
</selectionStates>
</component>

View File

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

View File

@ -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. 812) 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"
}

View File

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

View File

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

View File

@ -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<CompletedQuestionnaire> =
SessionProgramHelper.filterUploadReadyQuestionnaires(
context,
CompletedQuestionnaireRepository.getAllPendingUploads(),
)
suspend fun getUploadReadyScoringReviews(context: Context): List<CoachScoringReview> =
SessionProgramHelper.filterUploadReadyScoringReviews(
context,
CoachScoringReviewRepository.getAllPendingUploads(),
)
/** Raw pending count (ignores session completion). */
suspend fun countAll(): Int =
CompletedQuestionnaireRepository.countPendingUploads() +
CoachScoringReviewRepository.countPendingUploads()

View File

@ -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<ProgramSessionQuestionnaire> = emptyList(),
)
data class ProgramSessionSettings(
val autoRepeatSessions: Boolean = false,
)
data class ProgramSessionsPayload(
val sessions: List<ProgramSessionDefinition> = 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<ServerCompletedQuestionnaire> = emptyList()
val completedQuestionnaires: List<ServerCompletedQuestionnaire> = emptyList(),
val programProgress: ProgramProgress? = null,
)
data class ScoringProfileMember(

View File

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

View File

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

View File

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

View File

@ -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<AssignedClientInfo>) {
writeCacheText(clientInfosFile(context), gson.toJson(infos))
}
fun getAssignedClientInfos(context: Context): List<AssignedClientInfo> {
val file = clientInfosFile(context)
if (!file.exists()) return emptyList()
return runCatching {
gson.fromJson(readCacheText(file), Array<AssignedClientInfo>::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<ProgramSessionDefinition> =
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<String, Map<String, String>> {
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))
}

View File

@ -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<String>,
clientCodesToSave: List<String>,
clientInfosFromApi: List<AssignedClientInfo> = emptyList(),
) {
when {
apiSucceeded -> {
QuestionnaireCache.replaceAssignedClients(context, clientCodesToSave)
QuestionnaireCache.saveAssignedClientInfos(context, clientInfosFromApi)
SyncLog.d(TAG, "assigned clients from API: ${clientCodesToSave.size}")
}
fallbackCodes.isNotEmpty() -> {

View File

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

View File

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

View File

@ -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.view.ViewGroup>(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()
}
}

View File

@ -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<T> {
protected fun navigateTo(layoutResId: Int, setup: (View) -> Unit) {
context.setContentView(layoutResId)
val contentHost = context.findViewById<android.view.ViewGroup>(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<T> {
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)
}
}

View File

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

View File

@ -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<Unit>() {
@ -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<Button>(R.id.Qprev)?.setOnClickListener {
layout.findViewById<android.widget.Button>(R.id.Qprev)?.setOnClickListener {
goToPreviousQuestion()
}
val handler = createHandlerForQuestion(question)
if (handler != null) {
handler.bind(layout, question)
} else {
showEmptyScreen()
}
injectLanguageSpinner(layout)
}
}
private fun injectLanguageSpinner(layout: View) {
val container = layout as? FrameLayout ?: return
val spinner = Spinner(context)
val labels = AppLanguages.spinnerLabels(languageID)
val selected = labels.getOrNull(AppLanguages.indexOf(languageID))
ViewUtils.setupLanguagePickerSpinner(context, spinner, labels, selected)
spinner.setSelection(AppLanguages.indexOf(languageID))
val margin = ViewUtils.dp(context, 10)
container.addView(
spinner,
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
Gravity.TOP or Gravity.END
).apply { setMargins(0, margin, margin, 0) }
)
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
languageID = newLang
com.tomhempel.rbarometer.ui.AppLanguageStore.set(context, newLang)
spinner.post { showCurrentQuestion() }
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
}
}

View File

@ -0,0 +1,166 @@
package com.tomhempel.rbarometer.questionnaire
import android.app.Activity
import android.content.Context
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.Spinner
import com.tomhempel.rbarometer.R
import com.tomhempel.rbarometer.ui.SystemBarInsets
import com.tomhempel.rbarometer.utils.ViewUtils
/** Reusable language dropdown — toolbar row above content so question text sits below. */
object QuestionnaireLanguagePicker {
/**
* Ensures a language toolbar sits above the screen content.
* @return the inner content root (question layout) for handlers to bind against.
*/
fun ensureToolbarLayout(
activity: Activity,
languageId: String,
onLanguageChanged: (String) -> Unit,
): View {
val content = activity.findViewById<ViewGroup>(android.R.id.content)
?: return activity.window.decorView
val currentRoot = content.getChildAt(0) ?: return activity.window.decorView
val questionRoot = when {
currentRoot.findViewById<Spinner>(R.id.langSpinner) != null -> {
wireSpinner(
activity,
currentRoot.findViewById(R.id.langSpinner)!!,
languageId,
onLanguageChanged,
)
contentRoot(currentRoot)
}
currentRoot.id == R.id.questionnaire_screen_wrapper -> {
currentRoot.findViewById<Spinner>(R.id.langSpinner)?.let {
wireSpinner(activity, it, languageId, onLanguageChanged)
}
contentRoot(currentRoot)
}
else -> wrapWithLanguageToolbar(activity, content, currentRoot, languageId, onLanguageChanged)
}
// setContentView runs SystemBarInsets before this wrapper exists; re-apply on the live root.
SystemBarInsets.applyToContentRoot(activity, ensureMinContentPadding = true)
return questionRoot
}
private fun wrapWithLanguageToolbar(
activity: Activity,
content: ViewGroup,
currentRoot: View,
languageId: String,
onLanguageChanged: (String) -> Unit,
): View {
content.removeView(currentRoot)
currentRoot.id = R.id.questionnaire_content_root
val margin = ViewUtils.dp(activity, 12)
val pickerHeight = activity.resources.getDimensionPixelSize(R.dimen.language_picker_height)
val toolbar = LinearLayout(activity).apply {
id = R.id.questionnaire_lang_toolbar
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.END or Gravity.CENTER_VERTICAL
setPadding(margin, margin, margin, margin)
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
)
}
val spinner = Spinner(activity).apply { id = R.id.langSpinner }
toolbar.addView(
spinner,
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
pickerHeight,
),
)
wireSpinner(activity, spinner, languageId, onLanguageChanged)
val wrapper = LinearLayout(activity).apply {
id = R.id.questionnaire_screen_wrapper
orientation = LinearLayout.VERTICAL
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
)
}
wrapper.addView(toolbar)
wrapper.addView(
currentRoot,
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
0,
1f,
),
)
content.addView(wrapper)
return currentRoot
}
/** @deprecated Use [ensureToolbarLayout]; kept for session interstitial refresh. */
fun attach(
root: View,
context: Context,
languageId: String,
onLanguageChanged: (String) -> Unit,
) {
val activity = context as? Activity
if (activity != null) {
ensureToolbarLayout(activity, languageId, onLanguageChanged)
return
}
root.findViewById<Spinner>(R.id.langSpinner)?.let {
wireSpinner(context, it, languageId, onLanguageChanged)
}
}
fun detach(activity: Activity) {
// setContentView replaces the tree; nothing to tear down.
}
fun wireSpinner(
context: Context,
spinner: Spinner,
languageId: String,
onLanguageChanged: (String) -> Unit,
) {
val labels = AppLanguages.spinnerLabels(languageId)
val selected = labels.getOrNull(AppLanguages.indexOf(languageId))
ViewUtils.setupLanguagePickerSpinner(context, spinner, labels, selected)
spinner.onItemSelectedListener = null
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<*>?) = Unit
}
}
private fun contentRoot(root: View): View {
if (root.id == R.id.questionnaire_screen_wrapper) {
val wrapper = root as ViewGroup
return wrapper.findViewById(R.id.questionnaire_content_root)
?: wrapper.getChildAt(1)
?: root
}
return root
}
}

View File

@ -73,6 +73,61 @@ object QuestionnairePersistence {
UploadReminderScheduler.schedule(context)
}
/** Persists partial answers without marking the questionnaire complete (for session pause/resume). */
suspend fun saveDraftAnswers(
context: Context,
answers: Map<String, Any>,
questionnaireId: String,
languageId: String,
questions: List<QuestionItem> = emptyList(),
) {
if (answers.isEmpty()) return
val persistedAnswers = canonicalPersistedAnswers(answers, questions)
if (persistedAnswers.isEmpty()) return
SyncLog.d("QUESTIONNAIRE", "Persisting draft ${persistedAnswers.size} answer field(s)")
if (!ClientAssignmentGuard.canPersistAnswers(context, answers)) {
val clientCode = answers["client_code"] as? String
SyncLog.w("QUESTIONNAIRE", "Skipping draft save for unassigned ${SyncLog.clientRef(clientCode)}")
return
}
val clientCode = answers["client_code"] as? String ?: return
val existing = CompletedQuestionnaireRepository.getStatus(clientCode, questionnaireId)
if (existing?.isDone == true) {
SyncLog.d("QUESTIONNAIRE", "Skipping draft save — questionnaire already completed")
return
}
val db = MyApp.database
ClientRepository.ensureClient(clientCode)
db.questionnaireDao().insertQuestionnaire(Questionnaire(id = questionnaireId))
AnswerRepository.deleteAnswersForClientAndQuestionnaire(clientCode, questionnaireId)
val questionEntities = persistedAnswers.keys
.filterNot { it.startsWith("client") }
.map { key ->
Question(
questionId = AnswerKeyUtils.roomQuestionId(questionnaireId, key),
questionnaireId = questionnaireId,
question = LanguageManager.getText(languageId, key),
)
}
db.questionDao().insertQuestions(questionEntities)
val answerEntities = persistedAnswers.entries
.filterNot { it.key.startsWith("client") }
.map { (key, value) ->
Answer(
clientCode = clientCode,
questionId = AnswerKeyUtils.roomQuestionId(questionnaireId, key),
answerValue = value.toString(),
)
}
AnswerRepository.insertAnswers(answerEntities)
ClientAnswersCache.invalidate(clientCode)
}
private fun canonicalPersistedAnswers(
answers: Map<String, Any>,
questions: List<QuestionItem>,

View File

@ -93,6 +93,7 @@ class HandlerLastPage(
val validToken = token?.takeIf { it.isNotBlank() }
val reviewRequest = if (
activity != null &&
!activity.isInSessionFlow &&
clientCode.isNotBlank() &&
validToken != null &&
TokenStore.isSessionValid(activity) &&
@ -120,11 +121,11 @@ class HandlerLastPage(
onLanguageChanged = { newLang ->
AppLanguageStore.set(reviewRequest.first, newLang)
},
onClose = { reviewRequest.first.finishQuestionnaire() },
onClose = { reviewRequest.first.finishQuestionnaire(wasCompleted = true) },
).show()
} else {
// Zurück zum Opening Screen der lädt dann automatisch (siehe Änderung 2)
activity?.finishQuestionnaire() ?: goToNextQuestion()
activity?.finishQuestionnaire(wasCompleted = true) ?: goToNextQuestion()
}
}
}

View File

@ -12,6 +12,7 @@ import com.tomhempel.rbarometer.questionnaire.QuestionFlowResolver
import com.tomhempel.rbarometer.questionnaire.QuestionHandler
import com.tomhempel.rbarometer.questionnaire.QuestionItem
import com.tomhempel.rbarometer.R
import com.tomhempel.rbarometer.ui.SessionUi
import com.tomhempel.rbarometer.utils.ViewUtils
/*
@ -41,7 +42,7 @@ class HandlerRadioQuestion(
val questionTextView = layout.findViewById<TextView>(R.id.textView)
val questionTitle = layout.findViewById<TextView>(R.id.question)
questionTextView.text = question.textKey?.let { LanguageManager.getText(languageID, it) } ?: ""
questionTextView.text = LanguageManager.resolvedText(languageID, question.noteBeforeKey, question.textKey)
questionTitle.text = question.question?.let {
Html.fromHtml(LanguageManager.getText(languageID, it), Html.FROM_HTML_MODE_LEGACY)
} ?: ""
@ -70,6 +71,7 @@ class HandlerRadioQuestion(
val padding = (12 * resources.displayMetrics.density).toInt()
setPadding(padding, padding, padding, padding)
SessionUi.styleAnswerChoice(context, this)
}
radioGroup.addView(radioButton)
}

View File

@ -0,0 +1,10 @@
package com.tomhempel.rbarometer.session
import com.tomhempel.rbarometer.questionnaire.QuestionItem
data class QuestionnaireDraftSnapshot(
val questionnaireId: String?,
val answers: Map<String, Any>,
val questions: List<QuestionItem>,
val languageId: String,
)

View File

@ -0,0 +1,7 @@
package com.tomhempel.rbarometer.session
data class SessionCompletionResult(
val clientCode: String,
val sessionName: String,
val canUpload: Boolean,
)

View File

@ -0,0 +1,353 @@
package com.tomhempel.rbarometer.session
import android.view.View
import android.widget.LinearLayout
import android.widget.TextView
import com.google.android.material.button.MaterialButton
import com.google.android.material.progressindicator.LinearProgressIndicator
import com.tomhempel.rbarometer.MainActivity
import com.tomhempel.rbarometer.R
import com.tomhempel.rbarometer.network.AssignedClientInfo
import com.tomhempel.rbarometer.network.ProgramProgress
import com.tomhempel.rbarometer.network.ProgramSessionDefinition
import com.tomhempel.rbarometer.network.QuestionnaireCache
import com.tomhempel.rbarometer.questionnaire.GlobalValues
import com.tomhempel.rbarometer.questionnaire.QuestionnaireGeneric
import com.tomhempel.rbarometer.questionnaire.QuestionnaireLanguagePicker
import com.tomhempel.rbarometer.questionnaire.QuestionnairePersistence
import com.tomhempel.rbarometer.ui.AppLanguageStore
import com.tomhempel.rbarometer.ui.SessionMeasureUi
import com.tomhempel.rbarometer.utils.ViewUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class SessionFlowController(
private val activity: MainActivity,
private var languageID: String,
private val clientCode: String,
private val onExit: (SessionCompletionResult?) -> Unit,
) {
private var session: ProgramSessionDefinition? = null
private var progress: ProgramProgress? = null
private var clientInfo: AssignedClientInfo? = null
private var measures: List<SessionProgramHelper.SessionMeasure> = emptyList()
private var measureIndex = 0
private var pendingUpload = false
fun start() {
CoroutineScope(Dispatchers.Main).launch {
if (!withContext(Dispatchers.IO) { loadSessionState() }) {
showErrorAndExit(hubFallback("Keine Sitzung konfiguriert", "No session configured"))
return@launch
}
measureIndex = resolveStartingMeasureIndex()
pendingUpload = withContext(Dispatchers.IO) {
SessionProgramHelper.clientHasPendingUpload(activity, clientCode)
}
when {
measures.all { it.completedInCycle } && pendingUpload -> {
measureIndex = measureIndex.coerceIn(0, (measures.size - 1).coerceAtLeast(0))
showMeasureInterstitial(editBeforeUpload = true)
}
measures.all { it.completedInCycle } -> returnToMainWithCompletionDialog()
else -> showMeasureInterstitial()
}
}
}
fun handleQuestionnaireFinished(
wasCompleted: Boolean,
draftSnapshot: QuestionnaireDraftSnapshot? = null,
): Boolean {
CoroutineScope(Dispatchers.Main).launch {
withContext(Dispatchers.IO) {
if (!wasCompleted) {
saveDraftSnapshot(draftSnapshot)
}
reloadMeasures()
refreshPendingUploadFlag()
}
if (wasCompleted) {
if (measures.all { it.completedInCycle }) {
returnToMainWithCompletionDialog()
} else {
measureIndex++
advancePastCompletedMeasures()
}
} else {
persistPauseState()
val resumeEdit = pendingUpload && measures.all { it.completedInCycle }
showMeasureInterstitial(editBeforeUpload = resumeEdit)
}
}
return true
}
private fun resolveStartingMeasureIndex(): Int {
val prog = progress ?: return 0
val sess = session ?: return 0
val paused = SessionPauseStore.load(activity, clientCode)
if (
paused != null &&
paused.sessionId == sess.sessionID &&
paused.cycleNumber == prog.cycleNumber &&
paused.cycleStartedAt == prog.cycleStartedAt
) {
return paused.measureIndex.coerceIn(0, (measures.size - 1).coerceAtLeast(0))
}
return measures.indexOfFirst { !it.completedInCycle }.let { idx ->
if (idx < 0) 0 else idx
}
}
private suspend fun saveDraftSnapshot(snapshot: QuestionnaireDraftSnapshot?) {
val snap = snapshot ?: return
val questionnaireId = snap.questionnaireId ?: return
if (snap.answers.isEmpty()) return
QuestionnairePersistence.saveDraftAnswers(
activity,
snap.answers,
questionnaireId,
snap.languageId,
snap.questions,
)
}
private suspend fun persistPauseState() {
val sess = session ?: return
val prog = progress ?: return
SessionPauseStore.save(
activity,
SessionPauseStore.PauseState(
clientCode = clientCode,
sessionId = sess.sessionID,
cycleNumber = prog.cycleNumber,
cycleStartedAt = prog.cycleStartedAt,
measureIndex = measureIndex.coerceIn(0, (measures.size - 1).coerceAtLeast(0)),
),
)
}
private fun advancePastCompletedMeasures() {
while (measureIndex < measures.size && measures[measureIndex].completedInCycle) {
measureIndex++
}
CoroutineScope(Dispatchers.Main).launch {
withContext(Dispatchers.IO) { persistPauseState() }
}
if (measures.isEmpty() || measures.all { it.completedInCycle } || measureIndex >= measures.size) {
returnToMainWithCompletionDialog()
} else {
showMeasureInterstitial()
}
}
private suspend fun loadSessionState(): Boolean {
clientInfo = QuestionnaireCache.getAssignedClientInfos(activity)
.firstOrNull { it.clientCode.equals(clientCode, ignoreCase = true) }
?: AssignedClientInfo(
clientCode = clientCode,
programProgress = QuestionnaireCache.getProgramProgress(activity, clientCode),
)
progress = clientInfo?.programProgress ?: return false
val currentSessionId = progress?.currentSessionID ?: return false
session = QuestionnaireCache.findProgramSession(activity, currentSessionId) ?: return false
reloadMeasures()
return session != null && measures.isNotEmpty()
}
private suspend fun refreshPendingUploadFlag() {
pendingUpload = SessionProgramHelper.clientHasPendingUpload(activity, clientCode)
}
private suspend fun reloadMeasures() {
val info = clientInfo ?: return
val sess = session ?: return
val prog = progress ?: return
measures = SessionProgramHelper.measuresForSession(activity, info, sess, prog.cycleStartedAt)
}
private fun showMeasureInterstitial(editBeforeUpload: Boolean = false) {
if (measures.isEmpty()) return
if (!editBeforeUpload) {
val firstIncomplete = measures.indexOfFirst { !it.completedInCycle }
if (firstIncomplete >= 0) {
measureIndex = firstIncomplete
}
}
if (measureIndex >= measures.size) {
returnToMainWithCompletionDialog()
return
}
val sess = session ?: return
val prog = progress ?: return
val measure = measures[measureIndex]
val completedCount = measures.count { it.completedInCycle }
val sessionName = prog.currentSessionName ?: sess.name
val allowMeasureSelection = editBeforeUpload
activity.setContentView(R.layout.session_measure_interstitial)
if (editBeforeUpload) {
activity.findViewById<TextView>(R.id.interstitialEyebrow).text =
hubFallback("Sitzung bearbeiten", "Edit session")
activity.findViewById<TextView>(R.id.interstitialTitle).text = sessionName
activity.findViewById<TextView>(R.id.interstitialSessionLabel).text =
hubFallback("Messpunkt zum Bearbeiten wählen", "Select a measure to edit")
} else {
activity.findViewById<TextView>(R.id.interstitialEyebrow).text =
hubFallback("Sitzungsmesspunkt", "Session measure")
activity.findViewById<TextView>(R.id.interstitialTitle).text = sessionName
activity.findViewById<TextView>(R.id.interstitialSessionLabel).text = measure.displayName
}
activity.findViewById<TextView>(R.id.interstitialProgress).text =
hubFallback(
"Messpunkt ${measureIndex + 1} von ${measures.size} · Zyklus ${prog.cycleNumber} · Sitzung ${prog.currentSessionNumber ?: sess.sessionNumber} von ${prog.totalSessions}",
"Measure ${measureIndex + 1} of ${measures.size} · Cycle ${prog.cycleNumber} · Session ${prog.currentSessionNumber ?: sess.sessionNumber} of ${prog.totalSessions}",
)
activity.findViewById<TextView>(R.id.interstitialClient).text = clientCode
val progressBar = activity.findViewById<LinearProgressIndicator>(R.id.interstitialProgressBar)
progressBar.visibility = View.VISIBLE
progressBar.max = measures.size
progressBar.progress = completedCount
activity.findViewById<TextView>(R.id.interstitialMeasureHeading).text =
if (editBeforeUpload) {
hubFallback("Messpunkte — zum Auswählen tippen", "Measures — tap to select")
} else {
hubFallback("Ihre Messpunkte in dieser Sitzung", "Your measures in this session")
}
val measureList = activity.findViewById<LinearLayout>(R.id.interstitialMeasureList)
measureList.removeAllViews()
measures.forEachIndexed { index, item ->
measureList.addView(
SessionMeasureUi.measureRow(
context = activity,
label = item.displayName,
checked = item.completedInCycle,
isCurrent = index == measureIndex,
verticalPaddingDp = 0,
highlightCurrentRow = true,
selectable = allowMeasureSelection,
onSelect = if (allowMeasureSelection) {
{
measureIndex = index
showMeasureInterstitial(editBeforeUpload = editBeforeUpload)
}
} else {
null
},
statusText = measureStatusLabel(
completed = item.completedInCycle,
selected = index == measureIndex,
editBeforeUpload = editBeforeUpload,
),
),
)
}
activity.findViewById<MaterialButton>(R.id.interstitialContinue).apply {
text = when {
editBeforeUpload -> hubFallback("Ausgewählten bearbeiten", "Edit selected")
measure.completedInCycle -> hubFallback("Weiter", "Continue")
else -> hubFallback("Messung starten", "Start measure")
}
setOnClickListener {
when {
editBeforeUpload -> startMeasure(measure.questionnaireId)
measure.completedInCycle -> advancePastCompletedMeasures()
else -> startMeasure(measure.questionnaireId)
}
}
}
activity.findViewById<MaterialButton>(R.id.interstitialPause).apply {
visibility = View.VISIBLE
text = hubFallback("Sitzung pausieren", "Pause session")
setOnClickListener { pauseSession() }
}
activity.findViewById<TextView>(R.id.interstitialSummary).text =
hubFallback(
"$completedCount von ${measures.size} Messpunkten in dieser Sitzung erledigt",
"$completedCount of ${measures.size} measures completed in this session",
)
attachLanguagePicker()
}
private fun returnToMainWithCompletionDialog() {
CoroutineScope(Dispatchers.Main).launch {
withContext(Dispatchers.IO) { refreshPendingUploadFlag() }
SessionPauseStore.clear(activity, clientCode)
val sessionName = progress?.currentSessionName ?: session?.name.orEmpty()
QuestionnaireLanguagePicker.detach(activity)
activity.isInQuestionnaire = false
onExit(
SessionCompletionResult(
clientCode = clientCode,
sessionName = sessionName,
canUpload = pendingUpload,
),
)
}
}
private fun startMeasure(questionnaireId: String) {
GlobalValues.LAST_CLIENT_CODE = clientCode
GlobalValues.LOADED_CLIENT_CODE = clientCode
activity.startQuestionnaire(QuestionnaireGeneric(questionnaireId), languageID)
}
private fun attachLanguagePicker() {
QuestionnaireLanguagePicker.ensureToolbarLayout(
activity = activity,
languageId = languageID,
onLanguageChanged = { newLang ->
languageID = newLang
AppLanguageStore.set(activity, newLang)
if (measures.all { it.completedInCycle } && pendingUpload) {
showMeasureInterstitial(editBeforeUpload = true)
} else {
showMeasureInterstitial()
}
},
)
}
private fun pauseSession() {
CoroutineScope(Dispatchers.Main).launch {
withContext(Dispatchers.IO) { persistPauseState() }
QuestionnaireLanguagePicker.detach(activity)
activity.isInQuestionnaire = false
onExit(null)
}
}
fun pauseFromBackPress() {
pauseSession()
}
private fun showErrorAndExit(message: String) {
android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_LONG).show()
pauseSession()
}
private fun measureStatusLabel(
completed: Boolean,
selected: Boolean,
editBeforeUpload: Boolean,
): String = when {
selected && editBeforeUpload -> hubFallback("Ausgewählt zum Bearbeiten", "Selected to edit")
selected && !completed -> hubFallback("Als Nächstes", "Up next")
selected -> hubFallback("Ausgewählt", "Selected")
completed -> hubFallback("Abgeschlossen", "Complete")
else -> hubFallback("Noch offen", "Not started")
}
private fun hubFallback(de: String, en: String): String =
if (languageID == "GERMAN") de else en
private fun dp(v: Int): Int = ViewUtils.dp(activity, v)
}

View File

@ -0,0 +1,69 @@
package com.tomhempel.rbarometer.session
import android.content.Context
import com.google.gson.Gson
import com.google.gson.annotations.SerializedName
import com.tomhempel.rbarometer.security.SensitiveDataCrypto
import java.io.File
/** Encrypted on-device session pause state so coaches can resume a program session. */
object SessionPauseStore {
private const val FILE_NAME = "session_pause_states.json"
private val gson = Gson()
data class PauseState(
@SerializedName("clientCode") val clientCode: String,
@SerializedName("sessionId") val sessionId: String,
@SerializedName("cycleNumber") val cycleNumber: Int,
@SerializedName("cycleStartedAt") val cycleStartedAt: Long,
@SerializedName("measureIndex") val measureIndex: Int,
@SerializedName("updatedAt") val updatedAt: Long = System.currentTimeMillis(),
)
private data class Store(
@SerializedName("byClient") val byClient: Map<String, PauseState> = emptyMap(),
)
fun save(context: Context, state: PauseState) {
val current = loadStore(context)
writeStore(context, current.copy(byClient = current.byClient + (state.clientCode.lowercase() to state)))
}
fun load(context: Context, clientCode: String): PauseState? =
loadStore(context).byClient[clientCode.lowercase()]
fun matchesCurrentProgram(
context: Context,
clientCode: String,
sessionId: String?,
cycleNumber: Int,
cycleStartedAt: Long,
): Boolean {
val paused = load(context, clientCode) ?: return false
if (sessionId.isNullOrBlank()) return false
return paused.sessionId == sessionId &&
paused.cycleNumber == cycleNumber &&
paused.cycleStartedAt == cycleStartedAt
}
fun clear(context: Context, clientCode: String) {
val key = clientCode.lowercase()
val current = loadStore(context).byClient
if (!current.containsKey(key)) return
writeStore(context, Store(byClient = current - key))
}
private fun storeFile(context: Context): File = File(context.filesDir, FILE_NAME)
private fun loadStore(context: Context): Store {
val file = storeFile(context)
if (!file.exists()) return Store()
val raw = file.readText()
val plain = if (raw.startsWith("v1:")) SensitiveDataCrypto.decrypt(raw) else raw
return runCatching { gson.fromJson(plain, Store::class.java) }.getOrDefault(Store())
}
private fun writeStore(context: Context, store: Store) {
storeFile(context).writeText(SensitiveDataCrypto.encrypt(gson.toJson(store)))
}
}

View File

@ -0,0 +1,290 @@
package com.tomhempel.rbarometer.session
import android.content.Context
import com.tomhempel.rbarometer.data.CompletedQuestionnaire
import com.tomhempel.rbarometer.data.CompletedQuestionnaireRepository
import com.tomhempel.rbarometer.data.CoachScoringReviewRepository
import com.tomhempel.rbarometer.network.AssignedClientInfo
import com.tomhempel.rbarometer.network.ProgramProgress
import com.tomhempel.rbarometer.network.ProgramSessionDefinition
import com.tomhempel.rbarometer.network.QuestionnaireCache
object SessionProgramHelper {
data class SessionMeasure(
val questionnaireId: String,
val displayName: String,
val completedInCycle: Boolean,
)
data class ClientSessionView(
val clientCode: String,
val progress: ProgramProgress,
val session: ProgramSessionDefinition?,
val measures: List<SessionMeasure>,
val canResumeSession: Boolean = false,
val isSessionMeasuresComplete: Boolean = false,
val hasPendingUpload: Boolean = false,
) {
val pendingMeasureCount: Int get() = measures.count { !it.completedInCycle }
val canStart: Boolean get() = session != null && !progress.programComplete && pendingMeasureCount > 0
/** Session can be opened for start, resume, or edit-before-upload. */
val canEnterSession: Boolean get() = when {
progress.programComplete -> false
session == null -> false
isSessionMeasuresComplete && !hasPendingUpload -> false
else -> canStart || canResumeSession || measures.any { it.completedInCycle } ||
(isSessionMeasuresComplete && hasPendingUpload)
}
}
enum class SessionDisplayBucket {
READY,
IN_PROGRESS,
WAITING_UPLOAD,
COMPLETE,
}
fun sessionDisplayBucket(view: ClientSessionView): SessionDisplayBucket = when {
view.progress.programComplete -> SessionDisplayBucket.COMPLETE
view.isSessionMeasuresComplete && view.hasPendingUpload -> SessionDisplayBucket.WAITING_UPLOAD
view.isSessionMeasuresComplete -> SessionDisplayBucket.COMPLETE
view.canResumeSession || view.measures.any { it.completedInCycle } -> SessionDisplayBucket.IN_PROGRESS
view.canStart -> SessionDisplayBucket.READY
view.session != null -> SessionDisplayBucket.IN_PROGRESS
else -> SessionDisplayBucket.COMPLETE
}
suspend fun clientHasPendingUpload(context: Context, clientCode: String): Boolean {
val info = clientInfo(context, clientCode)
val progress = info.programProgress ?: return false
val session = progress.currentSessionID?.let { QuestionnaireCache.findProgramSession(context, it) }
?: return false
val measures = measuresForSession(context, info, session, progress.cycleStartedAt)
return sessionHasPendingUpload(clientCode, measures)
}
/** Raw pending rows for a completed session — must not call upload-ready filters (avoids recursion). */
private suspend fun sessionHasPendingUpload(
clientCode: String,
measures: List<SessionMeasure>,
): Boolean {
if (measures.isEmpty() || !measures.all { it.completedInCycle }) return false
val pendingQ = CompletedQuestionnaireRepository.getPendingUploadForClient(clientCode)
if (pendingQ.any { entry ->
measures.any { m -> questionnaireIdsMatch(m.questionnaireId, entry.questionnaireId) }
}
) {
return true
}
return CoachScoringReviewRepository.getAllForClient(clientCode).any { it.isPendingUpload }
}
private fun questionnaireIdsMatch(a: String, b: String): Boolean {
val na = normalizeQuestionnaireId(a)
val nb = normalizeQuestionnaireId(b)
if (na.isBlank() || nb.isBlank()) return false
return na == nb || na.contains(nb) || nb.contains(na)
}
suspend fun buildClientSessionViews(context: Context): List<ClientSessionView> {
val questionnaireNames = QuestionnaireCache.getQuestionnaireList(context)
val clientInfos = QuestionnaireCache.getAssignedClientInfos(context)
if (clientInfos.isNotEmpty()) {
return clientInfos.mapNotNull { info ->
buildClientSessionView(context, info, questionnaireNames)
}.sortedBy { it.clientCode }
}
return QuestionnaireCache.getAssignedClients(context).mapNotNull { code ->
buildClientSessionView(
context,
AssignedClientInfo(clientCode = code, programProgress = QuestionnaireCache.getProgramProgress(context, code)),
questionnaireNames,
)
}.sortedBy { it.clientCode }
}
suspend fun buildClientSessionView(
context: Context,
clientInfo: AssignedClientInfo,
questionnaireNames: List<com.tomhempel.rbarometer.network.QuestionnaireListItem> =
QuestionnaireCache.getQuestionnaireList(context),
): ClientSessionView? {
val progress = clientInfo.programProgress ?: return null
val session = progress.currentSessionID?.let { QuestionnaireCache.findProgramSession(context, it) }
val localCompleted = CompletedQuestionnaireRepository.getAllForClient(clientInfo.clientCode)
.associateBy { it.questionnaireId.lowercase() }
val measures = if (session != null) {
measuresForSession(context, clientInfo, session, progress.cycleStartedAt, localCompleted, questionnaireNames)
} else {
emptyList()
}
val isSessionMeasuresComplete = measures.isNotEmpty() && measures.all { it.completedInCycle }
val hasPendingUpload = if (isSessionMeasuresComplete) {
sessionHasPendingUpload(clientInfo.clientCode, measures)
} else {
false
}
return ClientSessionView(
clientCode = clientInfo.clientCode,
progress = progress,
session = session,
measures = measures,
canResumeSession = SessionPauseStore.matchesCurrentProgram(
context = context,
clientCode = clientInfo.clientCode,
sessionId = session?.sessionID,
cycleNumber = progress.cycleNumber,
cycleStartedAt = progress.cycleStartedAt,
),
isSessionMeasuresComplete = isSessionMeasuresComplete,
hasPendingUpload = hasPendingUpload,
)
}
suspend fun measuresForSession(
context: Context,
clientInfo: AssignedClientInfo,
session: ProgramSessionDefinition,
cycleStartedAtSec: Long,
): List<SessionMeasure> {
val localCompleted = CompletedQuestionnaireRepository.getAllForClient(clientInfo.clientCode)
.associateBy { it.questionnaireId.lowercase() }
return measuresForSession(
context,
clientInfo,
session,
cycleStartedAtSec,
localCompleted,
QuestionnaireCache.getQuestionnaireList(context),
)
}
private fun measuresForSession(
context: Context,
clientInfo: AssignedClientInfo,
session: ProgramSessionDefinition,
cycleStartedAtSec: Long,
localCompleted: Map<String, CompletedQuestionnaire>,
questionnaireNames: List<com.tomhempel.rbarometer.network.QuestionnaireListItem>,
): List<SessionMeasure> =
session.questionnaires.sortedBy { it.orderIndex }.map { member ->
SessionMeasure(
questionnaireId = member.questionnaireID,
displayName = questionnaireDisplayName(context, member.questionnaireID, questionnaireNames),
completedInCycle = isMeasureCompleteInCycle(
clientInfo,
member.questionnaireID,
cycleStartedAtSec,
findLocalCompletion(localCompleted, member.questionnaireID),
),
)
}
suspend fun isMeasureCompleteInCycle(
clientInfo: AssignedClientInfo,
questionnaireId: String,
cycleStartedAtSec: Long,
): Boolean {
val localCompleted = CompletedQuestionnaireRepository.getAllForClient(clientInfo.clientCode)
.associateBy { it.questionnaireId.lowercase() }
val local = findLocalCompletion(localCompleted, questionnaireId)
return isMeasureCompleteInCycle(clientInfo, questionnaireId, cycleStartedAtSec, local)
}
private fun normalizeQuestionnaireId(name: String): String =
name.lowercase().removeSuffix(".json")
private fun findLocalCompletion(
localCompleted: Map<String, CompletedQuestionnaire>,
questionnaireId: String,
): CompletedQuestionnaire? {
localCompleted[questionnaireId.lowercase()]?.let { return it }
val target = normalizeQuestionnaireId(questionnaireId)
if (target.isBlank()) return null
return localCompleted.entries.firstOrNull { (key, _) ->
val candidate = normalizeQuestionnaireId(key)
candidate == target || candidate.contains(target) || target.contains(candidate)
}?.value
}
private fun isMeasureCompleteInCycle(
clientInfo: AssignedClientInfo,
questionnaireId: String,
cycleStartedAtSec: Long,
local: CompletedQuestionnaire?,
): Boolean {
if (local?.isDone == true) {
val tsSec = local.timestamp / 1000L
if (cycleStartedAtSec <= 0L || tsSec > cycleStartedAtSec) {
return true
}
}
clientInfo.completedQuestionnaires
.firstOrNull { it.questionnaireID.equals(questionnaireId, ignoreCase = true) }
?.let { server ->
if (cycleStartedAtSec <= 0L || server.completedAt > cycleStartedAtSec) {
return true
}
}
return false
}
fun questionnaireDisplayName(
context: Context,
questionnaireId: String,
questionnaireNames: List<com.tomhempel.rbarometer.network.QuestionnaireListItem> =
QuestionnaireCache.getQuestionnaireList(context),
): String {
val fromList = questionnaireNames
.firstOrNull { it.id.equals(questionnaireId, ignoreCase = true) }
?.name
?.takeIf { it.isNotBlank() }
return fromList ?: questionnaireId
}
private fun clientInfo(context: Context, clientCode: String): AssignedClientInfo =
QuestionnaireCache.getAssignedClientInfos(context)
.firstOrNull { it.clientCode.equals(clientCode, ignoreCase = true) }
?: AssignedClientInfo(
clientCode = clientCode,
programProgress = QuestionnaireCache.getProgramProgress(context, clientCode),
)
/** True when the client's current program session has all measures completed (or no active session). */
suspend fun isClientSessionReadyForUpload(context: Context, clientCode: String): Boolean {
val info = clientInfo(context, clientCode)
val progress = info.programProgress ?: return true
if (progress.programComplete) return true
val session = progress.currentSessionID?.let { QuestionnaireCache.findProgramSession(context, it) }
?: return true
val measures = measuresForSession(context, info, session, progress.cycleStartedAt)
return measures.isEmpty() || measures.all { it.completedInCycle }
}
suspend fun filterUploadReadyQuestionnaires(
context: Context,
pending: List<CompletedQuestionnaire>,
): List<CompletedQuestionnaire> {
if (pending.isEmpty()) return pending
val readyByClient = mutableMapOf<String, Boolean>()
return pending.filter { entry ->
readyByClient.getOrPut(entry.clientCode.lowercase()) {
isClientSessionReadyForUpload(context, entry.clientCode)
}
}
}
suspend fun filterUploadReadyScoringReviews(
context: Context,
pending: List<com.tomhempel.rbarometer.data.CoachScoringReview>,
): List<com.tomhempel.rbarometer.data.CoachScoringReview> {
if (pending.isEmpty()) return pending
val readyByClient = mutableMapOf<String, Boolean>()
return pending.filter { review ->
readyByClient.getOrPut(review.clientCode.lowercase()) {
isClientSessionReadyForUpload(context, review.clientCode)
}
}
}
}

View File

@ -27,6 +27,7 @@ import com.tomhempel.rbarometer.network.TokenStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import com.tomhempel.rbarometer.questionnaire.ConditionJsonParser
@ -36,6 +37,8 @@ import com.tomhempel.rbarometer.questionnaire.QuestionnaireBase
import com.tomhempel.rbarometer.questionnaire.QuestionnaireAllInOne
import com.tomhempel.rbarometer.questionnaire.QuestionnaireGeneric
import com.tomhempel.rbarometer.questionnaire.QuestionnaireValidation
import com.tomhempel.rbarometer.session.SessionProgramHelper
import com.tomhempel.rbarometer.session.SessionCompletionResult
import com.google.android.material.button.MaterialButton
import com.google.android.material.card.MaterialCardView
import com.google.android.material.bottomsheet.BottomSheetDialog
@ -89,6 +92,7 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
private var loadHandler: LoadButtonHandler? = null
private var refreshJob: Job? = null
private var nextStepJob: Job? = null
private var sessionViewsJob: Job? = null
private var spinnerReady = false
private var suppressLanguageCallback = false
private var firstClientLoadDone = false
@ -113,63 +117,8 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
UPLOAD,
}
private data class TodaySession(
val clientCode: String,
val status: String,
val cycle: Int,
val session: Int,
val totalSessions: Int,
val plannedDate: String,
val lastSession: String,
val nextSuggestedDate: String,
val measures: List<String>,
)
private val dummyTodaySessions = listOf(
TodaySession(
clientCode = "A102",
status = "Today",
cycle = 3,
session = 2,
totalSessions = 6,
plannedDate = "Today 14:00",
lastSession = "Jun 17",
nextSuggestedDate = "Jul 01",
measures = listOf("WHO-5", "Stress thermometer", "Relationship check"),
),
TodaySession(
clientCode = "B447",
status = "Overdue",
cycle = 1,
session = 5,
totalSessions = 6,
plannedDate = "Jun 18",
lastSession = "Jun 10",
nextSuggestedDate = "Jun 26",
measures = listOf("Mood check", "Goal tracking", "Counselor score review"),
),
TodaySession(
clientCode = "C083",
status = "Upcoming",
cycle = 2,
session = 1,
totalSessions = 6,
plannedDate = "Tomorrow",
lastSession = "Jun 21",
nextSuggestedDate = "Jul 08",
measures = listOf("Cycle baseline", "WHO-5", "Stress thermometer"),
),
TodaySession(
clientCode = "D219",
status = "Inactive",
cycle = 4,
session = 6,
totalSessions = 6,
plannedDate = "Not planned",
lastSession = "Jun 03",
nextSuggestedDate = "On hold",
measures = listOf("End-of-cycle review", "Participation status"),
),
private data class ClientSessionCard(
val view: SessionProgramHelper.ClientSessionView,
)
private fun color(@ColorRes res: Int) = ContextCompat.getColor(activity, res)
@ -233,6 +182,7 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
/** Updates client picker after a background assigned-clients sync (single UI reload). */
fun onAssignedClientsSynced() {
refreshClientSpinner()
renderTodaySessions()
if (selectedClientCode.isNotBlank() && isClientAssigned(selectedClientCode)) {
refreshLoadedClientUi()
}
@ -241,6 +191,7 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
fun init() {
refreshJob?.cancel()
nextStepJob?.cancel()
sessionViewsJob?.cancel()
activity.bumpAssignedClientsRefreshGeneration()
firstClientLoadDone = false
afterFirstClientLoadCallbacks.clear()
@ -251,7 +202,14 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
bindViews()
loadQuestionnaireOrder()
renderTodaySessions()
createQuestionnaireButtons()
if (assignedClientsForUi().isEmpty()) {
createQuestionnaireButtons()
} else {
buttonContainer.removeAllViews()
dynamicButtons.clear()
questionnaireFiles.clear()
cardParts.clear()
}
setupCoachDisplay()
setupLanguageSpinner()
setupLoadHandler()
@ -271,6 +229,25 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
}
}
/** Lightweight refresh when returning from a questionnaire or upload — avoids full layout rebuild. */
fun refreshOpeningUi() {
if (!isOpeningScreenVisible()) {
init()
return
}
updateStatusStrip()
updateQuestionnaireSectionVisibility()
renderTodaySessions()
refreshPendingUploadBadge()
updateSessionMetrics()
if (assignedClientsForUi().isEmpty()) {
applyUpdateButtonTexts(force = true)
}
}
private fun isOpeningScreenVisible(): Boolean =
activity.findViewById<View?>(R.id.todaySessionsContainer) != null
private fun bindViews() {
headerWelcome = activity.findViewById(R.id.headerWelcome)
coachNameDisplay = activity.findViewById(R.id.coachNameDisplay)
@ -338,7 +315,7 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
sessionMetricMeasuresValue.text = questionnaireEntries.size.toString()
CoroutineScope(Dispatchers.Main).launch {
val count = withContext(Dispatchers.IO) {
PendingUploadRepository.countAll()
PendingUploadRepository.countUploadReady(activity)
}
if (::sessionMetricSyncValue.isInitialized) {
sessionMetricSyncValue.text = if (count > 0) count.toString() else "OK"
@ -349,24 +326,71 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
private fun renderTodaySessions() {
if (!::todaySessionsContainer.isInitialized) return
todaySessionsTitle.text = hubFallback("Sitzungen", "Sessions")
val dueCount = dummyTodaySessions.count { it.status == "Today" || it.status == "Overdue" }
todaySessionsSummary.text = "$dueCount today"
todaySessionsContainer.removeAllViews()
addSessionGroup(
title = hubFallback("Heute", "Today"),
sessions = dummyTodaySessions.filter { it.status == "Today" || it.status == "Overdue" },
)
addSessionGroup(
title = hubFallback("Demnächst", "Upcoming"),
sessions = dummyTodaySessions.filter { it.status == "Upcoming" },
)
addSessionGroup(
title = hubFallback("Inaktiv", "Inactive"),
sessions = dummyTodaySessions.filter { it.status == "Inactive" },
)
sessionViewsJob?.cancel()
sessionViewsJob = CoroutineScope(Dispatchers.Main).launch {
val views = try {
withContext(Dispatchers.IO) {
SessionProgramHelper.buildClientSessionViews(activity)
}
} catch (e: Exception) {
Log.e(TAG, "renderTodaySessions failed", e)
emptyList()
}
if (!isActive) return@launch
bindTodaySessions(views)
}
}
private fun addSessionGroup(title: String, sessions: List<TodaySession>) {
private fun bindTodaySessions(views: List<SessionProgramHelper.ClientSessionView>) {
val ready = views.filter {
SessionProgramHelper.sessionDisplayBucket(it) == SessionProgramHelper.SessionDisplayBucket.READY
}
val inProgress = views.filter {
SessionProgramHelper.sessionDisplayBucket(it) == SessionProgramHelper.SessionDisplayBucket.IN_PROGRESS
}
val waitingUpload = views.filter {
SessionProgramHelper.sessionDisplayBucket(it) == SessionProgramHelper.SessionDisplayBucket.WAITING_UPLOAD
}
val complete = views.filter {
SessionProgramHelper.sessionDisplayBucket(it) == SessionProgramHelper.SessionDisplayBucket.COMPLETE
}
val dueCount = ready.size
todaySessionsSummary.text = if (dueCount == 1) {
hubFallback("1 Sitzung bereit", "1 session ready")
} else {
hubFallback("$dueCount Sitzungen bereit", "$dueCount sessions ready")
}
todaySessionsContainer.removeAllViews()
addSessionGroup(
title = hubFallback("Bereit", "Ready"),
sessions = ready.map { ClientSessionCard(it) },
)
addSessionGroup(
title = hubFallback("In Bearbeitung", "In progress"),
sessions = inProgress.map { ClientSessionCard(it) },
)
addSessionGroup(
title = hubFallback("Warte auf Upload", "Waiting for upload"),
sessions = waitingUpload.map { ClientSessionCard(it) },
)
addSessionGroup(
title = hubFallback("Abgeschlossen", "Complete"),
sessions = complete.map { ClientSessionCard(it) },
)
if (views.isEmpty()) {
todaySessionsContainer.addView(TextView(activity).apply {
text = hubFallback(
"Keine Sitzungsdaten — bitte synchronisieren.",
"No session data — please sync.",
)
setPadding(dp(2), dp(8), 0, dp(4))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
setTextColor(color(R.color.brand_text_muted))
})
}
}
private fun addSessionGroup(title: String, sessions: List<ClientSessionCard>) {
if (sessions.isEmpty()) return
todaySessionsContainer.addView(TextView(activity).apply {
text = title
@ -380,7 +404,9 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
}
}
private fun createTodaySessionCard(session: TodaySession): View {
private fun createTodaySessionCard(session: ClientSessionCard): View {
val view = session.view
val progress = view.progress
val card = MaterialCardView(activity).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
@ -389,8 +415,8 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
it.setMargins(0, 0, 0, dp(10))
}
radius = dp(14).toFloat()
strokeWidth = dp(if (session.status == "Today") 2 else 1)
setStrokeColor(color(if (session.status == "Overdue") R.color.brand_chip_start_fill else R.color.brand_purple))
strokeWidth = dp(if (view.canEnterSession) 2 else 1)
setStrokeColor(color(if (view.canEnterSession) R.color.brand_purple else R.color.brand_header_stroke))
setCardBackgroundColor(color(R.color.brand_header_surface))
cardElevation = dp(2).toFloat()
}
@ -407,116 +433,173 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
titleBlock.addView(TextView(activity).apply {
text = session.clientCode
text = view.clientCode
setTextSize(TypedValue.COMPLEX_UNIT_SP, 20f)
setTypeface(typeface, android.graphics.Typeface.BOLD)
setTextColor(color(R.color.brand_text_dark))
})
titleBlock.addView(TextView(activity).apply {
text = "Cycle ${session.cycle} - Session ${session.session} of ${session.totalSessions}"
text = hubFallback(
"Zyklus ${progress.cycleNumber} · Sitzung ${progress.currentSessionNumber ?: "—"} von ${progress.totalSessions}",
"Cycle ${progress.cycleNumber} · Session ${progress.currentSessionNumber ?: "—"} of ${progress.totalSessions}",
)
setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
setTextColor(color(R.color.brand_text_muted))
})
header.addView(titleBlock)
header.addView(sessionStatusChip(session.status))
header.addView(sessionStatusChip(view))
content.addView(header)
progress.currentSessionName?.takeIf { it.isNotBlank() }?.let { sessionName ->
content.addView(TextView(activity).apply {
text = sessionName
setPadding(0, dp(10), 0, dp(8))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f)
setTypeface(typeface, android.graphics.Typeface.BOLD)
setTextColor(color(R.color.brand_text_dark))
})
}
content.addView(TextView(activity).apply {
text = "Planned: ${session.plannedDate} Last: ${session.lastSession}"
setPadding(0, dp(10), 0, dp(8))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
setTextColor(color(R.color.brand_text_dark))
})
content.addView(TextView(activity).apply {
text = hubFallback("Startet automatisch", "Starts automatically")
text = hubFallback("Messpunkte in dieser Sitzung", "Measures in this session")
setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
setTypeface(typeface, android.graphics.Typeface.BOLD)
setTextColor(color(R.color.brand_text_muted))
})
session.measures.forEachIndexed { index, measure ->
content.addView(measureRow(measure, checked = index == 0))
view.measures.forEach { measure ->
content.addView(
SessionMeasureUi.measureRow(
context = activity,
label = measure.displayName,
checked = measure.completedInCycle,
),
)
}
content.addView(scheduleNextDateBlock(session.nextSuggestedDate))
val actions = LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
setPadding(0, dp(12), 0, 0)
if (view.canEnterSession) {
val canResume = view.canResumeSession
val editBeforeUpload = view.isSessionMeasuresComplete && view.hasPendingUpload
val actions = LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
setPadding(0, dp(12), 0, 0)
}
actions.addView(
todayAction(
hubFallback(
when {
editBeforeUpload -> "Sitzung bearbeiten"
canResume -> "Sitzung fortsetzen"
else -> "Sitzung starten"
},
when {
editBeforeUpload -> "Edit session"
canResume -> "Continue session"
else -> "Start session"
},
),
filled = true,
weight = 1f,
) {
startSessionForClient(view.clientCode)
},
)
content.addView(actions)
}
actions.addView(todayAction("Manage", filled = false, weight = 1f) {
showManageSessionSheet(session)
})
actions.addView(todayAction("Start session", filled = true, weight = 1.25f))
content.addView(actions)
card.addView(content)
return card
}
private fun sessionStatusChip(status: String): TextView =
TextView(activity).apply {
text = status
private fun sessionStatusChip(view: SessionProgramHelper.ClientSessionView): TextView {
val canResume = view.canResumeSession
val (label, style) = when (SessionProgramHelper.sessionDisplayBucket(view)) {
SessionProgramHelper.SessionDisplayBucket.COMPLETE ->
hubFallback("Abgeschlossen", "Complete") to "complete"
SessionProgramHelper.SessionDisplayBucket.WAITING_UPLOAD ->
hubFallback("Warte auf Upload", "Waiting for upload") to "upload"
SessionProgramHelper.SessionDisplayBucket.IN_PROGRESS ->
hubFallback(
if (canResume) "Pausiert" else "In Bearbeitung",
if (canResume) "Paused" else "In progress",
) to "waiting"
SessionProgramHelper.SessionDisplayBucket.READY ->
hubFallback("Bereit", "Ready") to "ready"
}
return TextView(activity).apply {
text = label
setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
setTypeface(typeface, android.graphics.Typeface.BOLD)
setTextColor(color(if (status == "Upcoming" || status == "Inactive") R.color.brand_chip_locked_text else R.color.brand_text_on_dark))
setTextColor(
color(
when (style) {
"waiting" -> R.color.brand_chip_locked_text
"upload" -> R.color.brand_text_on_dark
else -> R.color.brand_text_on_dark
},
),
)
setBackgroundResource(
when (status) {
"Overdue" -> R.drawable.bg_chip_amber
"Upcoming", "Inactive" -> R.drawable.bg_chip_locked
when (style) {
"complete" -> R.drawable.bg_chip_locked
"waiting" -> R.drawable.bg_chip_locked
"upload" -> R.drawable.bg_chip_amber
else -> R.drawable.bg_chip_green
},
)
setPadding(dp(10), dp(6), dp(10), dp(6))
}
}
private fun measureRow(label: String, checked: Boolean): View =
LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(0, dp(7), 0, 0)
addView(TextView(activity).apply {
text = if (checked) "[x]" else "[ ]"
setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
setTypeface(typeface, android.graphics.Typeface.BOLD)
setTextColor(color(if (checked) R.color.brand_purple else R.color.brand_text_muted))
})
addView(TextView(activity).apply {
text = label
setPadding(dp(8), 0, 0, 0)
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
setTextColor(color(R.color.brand_text_dark))
})
private fun startSessionForClient(clientCode: String) {
if (!isClientAssigned(clientCode)) {
Toast.makeText(activity, t("no_profile"), Toast.LENGTH_LONG).show()
return
}
selectClient(clientCode, userInitiated = false)
GlobalValues.LOADED_CLIENT_CODE = clientCode
activity.startSessionFlow(clientCode, languageID)
}
fun showSessionCompletionDialog(result: SessionCompletionResult) {
selectClient(result.clientCode, userInitiated = false)
GlobalValues.LAST_CLIENT_CODE = result.clientCode
GlobalValues.LOADED_CLIENT_CODE = result.clientCode
val title = hubFallback("Sitzung abgeschlossen", "Session complete")
val message = when {
result.sessionName.isNotBlank() && result.canUpload ->
hubFallback(
"${result.sessionName} ist abgeschlossen. Sie können die Daten jetzt hochladen oder vor dem Upload noch bearbeiten.",
"${result.sessionName} is complete. You can upload now or edit before uploading.",
)
result.sessionName.isNotBlank() ->
hubFallback(
"${result.sessionName} ist abgeschlossen.",
"${result.sessionName} is complete.",
)
result.canUpload ->
hubFallback(
"Alle Messpunkte dieser Sitzung sind erledigt. Sie können die Daten jetzt hochladen oder vor dem Upload noch bearbeiten.",
"All measures in this session are complete. You can upload now or edit before uploading.",
)
else ->
hubFallback(
"Alle Messpunkte dieser Sitzung sind erledigt.",
"All measures in this session are complete.",
)
}
private fun scheduleNextDateBlock(suggestedDate: String): View =
LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
background = ContextCompat.getDrawable(activity, R.drawable.bg_session_metric)
setPadding(dp(12), dp(10), dp(12), dp(10))
val lp = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
)
lp.setMargins(0, dp(12), 0, 0)
layoutParams = lp
addView(TextView(activity).apply {
text = hubFallback("Nächsten Termin planen", "Schedule next date")
setTextSize(TypedValue.COMPLEX_UNIT_SP, 11f)
setTextColor(color(R.color.brand_text_muted))
})
val row = LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(0, dp(4), 0, 0)
}
row.addView(TextView(activity).apply {
text = suggestedDate
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
setTypeface(typeface, android.graphics.Typeface.BOLD)
setTextColor(color(R.color.brand_text_dark))
})
row.addView(todayAction("Edit date", filled = false, weight = 0f, fixedWidth = dp(96)))
addView(row)
}
LogoutDialogs.showThreeAction(
activity = activity,
title = title,
message = message,
tone = LogoutDialogs.Tone.Info,
primaryLabel = hubFallback("Hochladen", "Upload"),
secondaryLabel = hubFallback("Bearbeiten", "Edit"),
tertiaryLabel = hubFallback("Schließen", "Close"),
showPrimary = result.canUpload,
onPrimary = { openUploadOverview(syncAfterClose = true) },
onSecondary = { startSessionForClient(result.clientCode) },
)
}
private fun todayAction(
label: String,
@ -541,158 +624,7 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
setTextColor(color(if (filled) R.color.brand_text_on_dark else R.color.brand_purple))
backgroundTintList = ColorStateList.valueOf(color(if (filled) R.color.brand_purple else R.color.brand_header_surface))
strokeColor = ColorStateList.valueOf(color(R.color.brand_purple))
setOnClickListener {
if (onClick != null) onClick()
else Toast.makeText(activity, "$label is prototype-only", Toast.LENGTH_SHORT).show()
}
}
private fun showManageSessionSheet(session: TodaySession) {
val container = LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(color(R.color.brand_surface))
setPadding(dp(20), dp(18), dp(20), dp(18))
}
val header = LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
}
val titleBlock = LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
titleBlock.addView(TextView(activity).apply {
text = "Manage ${session.clientCode}"
setTextSize(TypedValue.COMPLEX_UNIT_SP, 20f)
setTypeface(typeface, android.graphics.Typeface.BOLD)
setTextColor(color(R.color.brand_text_dark))
})
titleBlock.addView(TextView(activity).apply {
text = "Cycle ${session.cycle} - Session ${session.session} of ${session.totalSessions}"
setPadding(0, dp(3), 0, 0)
setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
setTextColor(color(R.color.brand_text_muted))
})
val closeButton = MaterialButton(activity).apply {
layoutParams = LinearLayout.LayoutParams(dp(78), dp(40))
text = "Close"
isAllCaps = false
cornerRadius = dp(18)
strokeWidth = dp(1)
insetTop = 0
insetBottom = 0
setTextColor(color(R.color.brand_purple))
backgroundTintList = ColorStateList.valueOf(color(R.color.brand_header_surface))
strokeColor = ColorStateList.valueOf(color(R.color.brand_header_stroke))
}
header.addView(titleBlock)
header.addView(closeButton)
val body = LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
setPadding(0, dp(14), 0, 0)
}
body.addView(manageSectionTitle("Client status"))
body.addView(LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
addView(manageStatusButton("Active", selected = session.status != "Inactive"))
addView(manageStatusButton("Inactive", selected = session.status == "Inactive"))
addView(manageStatusButton("Archived", selected = false))
})
body.addView(manageSectionTitle("Scheduling"))
body.addView(manageAction("Edit next planned date", "Current suggestion: ${session.nextSuggestedDate}"))
body.addView(manageAction("Reschedule current session", "Move this session to another date"))
body.addView(manageAction("Skip this occurrence", "Keep the cycle but omit this planned visit"))
body.addView(manageSectionTitle("Session"))
body.addView(manageAction("View session protocol", "${session.measures.size} measures assigned"))
body.addView(manageAction("Add counselor note", "Attach a local note to this client"))
body.addView(manageAction("Sync client data", "Review pending local data for this client"))
body.addView(manageSectionTitle("Lifecycle"))
body.addView(manageAction("Mark no longer participating", "Move client to inactive"))
body.addView(manageAction("Archive client", "Hide from active scheduling"))
body.addView(manageAction("Delete client", "Prototype-only destructive option", destructive = true))
val scroll = ScrollView(activity).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
dp(430),
)
isFillViewport = false
addView(body)
}
container.addView(header)
container.addView(scroll)
val dialog = MaterialAlertDialogBuilder(activity)
.setView(container)
.create()
closeButton.setOnClickListener { dialog.dismiss() }
dialog.show()
}
private fun manageSectionTitle(title: String): View =
TextView(activity).apply {
text = title
setPadding(dp(2), dp(14), 0, dp(7))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
setTypeface(typeface, android.graphics.Typeface.BOLD)
setTextColor(color(R.color.brand_text_muted))
}
private fun manageStatusButton(label: String, selected: Boolean): MaterialButton =
MaterialButton(activity).apply {
layoutParams = LinearLayout.LayoutParams(0, dp(40), 1f).also {
it.setMargins(0, 0, dp(8), 0)
}
text = label
isAllCaps = false
setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
cornerRadius = dp(18)
strokeWidth = dp(1)
insetTop = 0
insetBottom = 0
setTextColor(color(if (selected) R.color.brand_text_on_dark else R.color.brand_text_dark))
backgroundTintList = ColorStateList.valueOf(
color(if (selected) R.color.brand_purple else R.color.brand_header_surface),
)
strokeColor = ColorStateList.valueOf(color(R.color.brand_purple))
setOnClickListener {
Toast.makeText(activity, "Set status to $label is prototype-only", Toast.LENGTH_SHORT).show()
}
}
private fun manageAction(
title: String,
subtitle: String,
destructive: Boolean = false,
): View =
MaterialButton(activity).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
).also { it.setMargins(0, 0, 0, dp(8)) }
text = "$title\n$subtitle"
isAllCaps = false
gravity = Gravity.START or Gravity.CENTER_VERTICAL
textAlignment = View.TEXT_ALIGNMENT_VIEW_START
setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
cornerRadius = dp(16)
strokeWidth = dp(1)
insetTop = 0
insetBottom = 0
setPadding(dp(14), dp(11), dp(14), dp(11))
setTextColor(color(if (destructive) R.color.brand_destructive_text else R.color.brand_text_dark))
backgroundTintList = ColorStateList.valueOf(
color(if (destructive) R.color.brand_destructive_fill else R.color.brand_header_surface),
)
strokeColor = ColorStateList.valueOf(
color(if (destructive) R.color.brand_destructive_fill else R.color.brand_header_stroke),
)
setOnClickListener {
Toast.makeText(activity, "$title is prototype-only", Toast.LENGTH_SHORT).show()
}
setOnClickListener { onClick?.invoke() }
}
private fun loadQuestionnaireOrder() {
@ -1126,6 +1058,13 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
}
pendingUploadDialogShowing = true
CoroutineScope(Dispatchers.Main).launch {
val uploadReady = withContext(Dispatchers.IO) {
PendingUploadRepository.hasUploadReady(activity)
}
if (!uploadReady) {
pendingUploadDialogShowing = false
return@launch
}
val summary = withContext(Dispatchers.IO) { UnuploadedWorkRepository.summarize() }
val details = SessionLogout.formatPendingDetails(languageID, summary)
LogoutDialogs.showAction(
@ -1171,6 +1110,7 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
applyUpdateButtonTexts(force = true)
updateStatusStrip()
updateSessionMetrics()
renderTodaySessions()
updateQuestionnaireSectionVisibility()
refreshLoadedClientUi()
}
@ -1398,11 +1338,13 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
if (!::uploadButton.isInitialized) return
CoroutineScope(Dispatchers.Main).launch {
val count = withContext(Dispatchers.IO) {
PendingUploadRepository.countAll()
PendingUploadRepository.countUploadReady(activity)
}
val base = t("upload")
uploadButton.text = if (count > 0) "$base ($count)" else base
uploadButton.contentDescription = uploadButton.text
uploadButton.isEnabled = true
uploadButton.alpha = if (count > 0) 1f else 0.55f
if (::sessionMetricSyncValue.isInitialized) {
sessionMetricSyncValue.text = if (count > 0) count.toString() else "OK"
}
@ -1646,7 +1588,23 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
return@setOnClickListener
}
openUploadOverview(token = existingToken!!, syncAfterClose = true)
CoroutineScope(Dispatchers.Main).launch {
val readyCount = withContext(Dispatchers.IO) {
PendingUploadRepository.countUploadReady(activity)
}
if (readyCount == 0) {
android.widget.Toast.makeText(
activity,
hubFallback(
"Upload ist erst möglich, wenn alle Messpunkte der Sitzung abgeschlossen sind.",
"Upload is only available after all measures in the session are complete.",
),
android.widget.Toast.LENGTH_LONG,
).show()
return@launch
}
openUploadOverview(token = existingToken!!, syncAfterClose = true)
}
}
}
@ -1671,7 +1629,7 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
AppLanguageStore.set(activity, newLang)
},
onClose = { uploaded ->
init()
refreshOpeningUi()
refreshPendingUploadBadge()
if (uploaded && syncAfterClose && NetworkUtils.isOnline(activity) && TokenStore.isSessionValid(activity)) {
TokenStore.getToken(activity)?.takeIf { it.isNotBlank() }?.let {

View File

@ -39,7 +39,9 @@ object LogoutDialogs {
details = details,
primaryLabel = msg(languageId, "ok", "OK"),
secondaryLabel = null,
tertiaryLabel = null,
onPrimary = null,
onSecondary = null,
)
}
@ -60,7 +62,9 @@ object LogoutDialogs {
details = null,
primaryLabel = msg(languageId, "logout_confirm_action", msg(languageId, "logout", "Log out")),
secondaryLabel = msg(languageId, "cancel", "Cancel"),
tertiaryLabel = null,
onPrimary = onConfirm,
onSecondary = null,
)
}
@ -83,10 +87,53 @@ object LogoutDialogs {
details = details,
primaryLabel = primaryLabel,
secondaryLabel = secondaryLabel,
tertiaryLabel = null,
onPrimary = onPrimary,
onSecondary = null,
)
}
fun showThreeAction(
activity: AppCompatActivity,
title: String,
message: String,
tone: Tone = Tone.Info,
primaryLabel: String,
secondaryLabel: String,
tertiaryLabel: String,
showPrimary: Boolean = true,
onPrimary: () -> Unit,
onSecondary: () -> Unit,
) {
if (showPrimary) {
show(
activity = activity,
tone = tone,
title = title,
message = message,
details = null,
primaryLabel = primaryLabel,
secondaryLabel = secondaryLabel,
tertiaryLabel = tertiaryLabel,
onPrimary = onPrimary,
onSecondary = onSecondary,
)
} else {
show(
activity = activity,
tone = tone,
title = title,
message = message,
details = null,
primaryLabel = secondaryLabel,
secondaryLabel = tertiaryLabel,
tertiaryLabel = null,
onPrimary = onSecondary,
onSecondary = null,
)
}
}
private fun show(
activity: AppCompatActivity,
tone: Tone,
@ -95,7 +142,9 @@ object LogoutDialogs {
details: String?,
primaryLabel: String,
secondaryLabel: String?,
tertiaryLabel: String?,
onPrimary: (() -> Unit)?,
onSecondary: (() -> Unit)?,
) {
val dialog = Dialog(activity)
dialog.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
@ -109,7 +158,10 @@ object LogoutDialogs {
val detailsScroll = root.findViewById<NestedScrollView>(R.id.dialogDetailsScroll)
val detailsView = root.findViewById<TextView>(R.id.dialogDetails)
val btnPrimary = root.findViewById<MaterialButton>(R.id.dialogBtnPrimary)
val btnPrimaryInline = root.findViewById<MaterialButton>(R.id.dialogBtnPrimaryInline)
val btnSecondary = root.findViewById<MaterialButton>(R.id.dialogBtnSecondary)
val btnTertiary = root.findViewById<MaterialButton>(R.id.dialogBtnTertiary)
val buttonRow = root.findViewById<ViewGroup>(R.id.dialogButtonRow)
when (tone) {
Tone.Info -> {
@ -152,37 +204,71 @@ object LogoutDialogs {
detailsScroll.visibility = View.GONE
}
btnPrimary.text = primaryLabel
if (tone == Tone.Logout) {
btnPrimary.backgroundTintList = ColorStateList.valueOf(
ContextCompat.getColor(activity, R.color.brand_destructive_text),
)
btnPrimary.setTextColor(ContextCompat.getColor(activity, R.color.white))
} else {
btnPrimary.backgroundTintList = ColorStateList.valueOf(
ContextCompat.getColor(activity, R.color.brand_purple),
)
btnPrimary.setTextColor(ContextCompat.getColor(activity, R.color.white))
val layoutMode = when {
tertiaryLabel != null && secondaryLabel != null -> DialogButtonLayout.TRIPLE
secondaryLabel != null -> DialogButtonLayout.PAIR
else -> DialogButtonLayout.SINGLE
}
btnPrimary.setOnClickListener {
dialog.dismiss()
onPrimary?.invoke()
}
if (secondaryLabel != null) {
btnSecondary.visibility = View.VISIBLE
btnSecondary.text = secondaryLabel
btnSecondary.setOnClickListener { dialog.dismiss() }
// Safer on small screens: cancel above the destructive / primary action.
(btnPrimary.parent as? ViewGroup)?.let { buttonRow ->
buttonRow.removeView(btnPrimary)
buttonRow.removeView(btnSecondary)
buttonRow.addView(btnSecondary)
buttonRow.addView(btnPrimary)
fun stylePrimaryButton(button: MaterialButton) {
if (tone == Tone.Logout) {
button.backgroundTintList = ColorStateList.valueOf(
ContextCompat.getColor(activity, R.color.brand_destructive_text),
)
button.setTextColor(ContextCompat.getColor(activity, R.color.white))
} else {
button.backgroundTintList = ColorStateList.valueOf(
ContextCompat.getColor(activity, R.color.brand_purple),
)
button.setTextColor(ContextCompat.getColor(activity, R.color.white))
}
}
fun bindPrimaryButton(button: MaterialButton) {
button.text = primaryLabel
stylePrimaryButton(button)
button.setOnClickListener {
dialog.dismiss()
onPrimary?.invoke()
}
}
when (layoutMode) {
DialogButtonLayout.SINGLE -> {
btnPrimary.visibility = View.VISIBLE
buttonRow.visibility = View.GONE
bindPrimaryButton(btnPrimary)
}
DialogButtonLayout.PAIR -> {
btnPrimary.visibility = View.GONE
buttonRow.visibility = View.VISIBLE
btnSecondary.visibility = View.VISIBLE
btnTertiary.visibility = View.GONE
btnPrimaryInline.visibility = View.VISIBLE
btnSecondary.text = secondaryLabel
btnSecondary.setOnClickListener {
dialog.dismiss()
onSecondary?.invoke()
}
bindPrimaryButton(btnPrimaryInline)
}
DialogButtonLayout.TRIPLE -> {
btnPrimary.visibility = View.VISIBLE
buttonRow.visibility = View.VISIBLE
btnSecondary.visibility = View.VISIBLE
btnTertiary.visibility = View.VISIBLE
btnPrimaryInline.visibility = View.GONE
bindPrimaryButton(btnPrimary)
btnSecondary.text = secondaryLabel
btnSecondary.setOnClickListener {
dialog.dismiss()
onSecondary?.invoke()
}
btnTertiary.text = tertiaryLabel
btnTertiary.setOnClickListener { dialog.dismiss() }
}
} else {
btnSecondary.visibility = View.GONE
}
dialog.setContentView(root)
@ -205,6 +291,12 @@ object LogoutDialogs {
})
}
private enum class DialogButtonLayout {
SINGLE,
PAIR,
TRIPLE,
}
private fun msg(languageId: String, key: String, fallback: String): String =
SessionLogout.message(languageId, key, fallback)
}

View File

@ -0,0 +1,179 @@
package com.tomhempel.rbarometer.ui
import android.content.Context
import android.content.res.ColorStateList
import android.util.TypedValue
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.content.ContextCompat
import com.google.android.material.card.MaterialCardView
import com.tomhempel.rbarometer.R
import com.tomhempel.rbarometer.utils.ViewUtils
object SessionMeasureUi {
fun indicatorSymbol(checked: Boolean, isCurrent: Boolean = false): String = when {
checked -> "\u2713"
isCurrent -> "\u2022"
else -> "\u25CB"
}
fun measureRow(
context: Context,
label: String,
checked: Boolean,
isCurrent: Boolean = false,
verticalPaddingDp: Int = 7,
labelTextSizeSp: Float = 14f,
highlightCurrentRow: Boolean = false,
selectable: Boolean = false,
onSelect: (() -> Unit)? = null,
statusText: String? = null,
): View {
if (highlightCurrentRow) {
return cardMeasureRow(
context = context,
label = label,
checked = checked,
isCurrent = isCurrent,
selectable = selectable,
onSelect = onSelect,
statusText = statusText,
)
}
return compactMeasureRow(
context = context,
label = label,
checked = checked,
isCurrent = isCurrent,
verticalPaddingDp = verticalPaddingDp,
labelTextSizeSp = labelTextSizeSp,
)
}
private fun cardMeasureRow(
context: Context,
label: String,
checked: Boolean,
isCurrent: Boolean,
selectable: Boolean,
onSelect: (() -> Unit)?,
statusText: String?,
): View {
val root = LayoutInflater.from(context).inflate(R.layout.item_session_measure_row, null, false)
val card = root.findViewById<MaterialCardView>(R.id.sessionMeasureCard)
val indicatorWrap = root.findViewById<FrameLayout>(R.id.sessionMeasureIndicatorWrap)
val indicator = root.findViewById<TextView>(R.id.sessionMeasureIndicator)
val title = root.findViewById<TextView>(R.id.sessionMeasureTitle)
val status = root.findViewById<TextView>(R.id.sessionMeasureStatus)
title.text = label
indicator.text = indicatorSymbol(checked, isCurrent)
when {
isCurrent && checked -> {
card.setCardBackgroundColor(color(context, R.color.brand_questionnaire_highlight_bg))
card.strokeWidth = ViewUtils.dp(context, 2)
card.setStrokeColor(ColorStateList.valueOf(color(context, R.color.brand_purple)))
indicatorWrap.setBackgroundResource(R.drawable.bg_measure_row_done)
indicator.setTextColor(color(context, R.color.brand_text_on_dark))
}
isCurrent -> {
card.setCardBackgroundColor(color(context, R.color.brand_questionnaire_highlight_bg))
card.strokeWidth = ViewUtils.dp(context, 2)
card.setStrokeColor(ColorStateList.valueOf(color(context, R.color.brand_purple)))
indicatorWrap.setBackgroundResource(R.drawable.bg_measure_row_pending)
indicator.setTextColor(color(context, R.color.brand_purple))
}
checked -> {
card.setCardBackgroundColor(color(context, R.color.brand_header_surface))
card.strokeWidth = ViewUtils.dp(context, 1)
card.setStrokeColor(ColorStateList.valueOf(color(context, R.color.brand_purple)))
indicatorWrap.setBackgroundResource(R.drawable.bg_measure_row_done)
indicator.setTextColor(color(context, R.color.brand_text_on_dark))
}
else -> {
card.setCardBackgroundColor(color(context, R.color.brand_header_surface))
card.strokeWidth = ViewUtils.dp(context, 1)
card.setStrokeColor(ColorStateList.valueOf(color(context, R.color.brand_card_stroke)))
indicatorWrap.setBackgroundResource(R.drawable.bg_measure_row_pending)
indicator.setTextColor(color(context, R.color.brand_text_muted))
}
}
if (statusText.isNullOrBlank()) {
status.visibility = View.GONE
} else {
status.visibility = View.VISIBLE
status.text = statusText
status.setTextColor(
color(
context,
when {
isCurrent -> R.color.brand_purple
checked -> R.color.brand_text_muted
else -> R.color.brand_text_muted
},
),
)
if (isCurrent) status.setTypeface(status.typeface, android.graphics.Typeface.BOLD)
}
if (selectable) {
card.isClickable = true
card.isFocusable = true
val ripple = TypedValue()
if (context.theme.resolveAttribute(android.R.attr.selectableItemBackground, ripple, true)) {
card.foreground = ContextCompat.getDrawable(context, ripple.resourceId)
}
card.setOnClickListener { onSelect?.invoke() }
}
return root
}
private fun compactMeasureRow(
context: Context,
label: String,
checked: Boolean,
isCurrent: Boolean,
verticalPaddingDp: Int,
labelTextSizeSp: Float,
): View = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(0, ViewUtils.dp(context, verticalPaddingDp), 0, 0)
val indicatorSize = ViewUtils.dp(context, 22)
addView(TextView(context).apply {
gravity = Gravity.CENTER
text = indicatorSymbol(checked, isCurrent)
minWidth = indicatorSize
minHeight = indicatorSize
setTextSize(TypedValue.COMPLEX_UNIT_SP, if (checked) 14f else 16f)
setTypeface(typeface, android.graphics.Typeface.BOLD)
setTextColor(
color(
context,
when {
checked -> R.color.brand_purple
isCurrent -> R.color.brand_purple
else -> R.color.brand_text_muted
},
),
)
})
addView(TextView(context).apply {
text = label
setPadding(ViewUtils.dp(context, 8), 0, 0, 0)
setTextSize(TypedValue.COMPLEX_UNIT_SP, labelTextSizeSp)
setLineSpacing(0f, 1.2f)
setTextColor(color(context, R.color.brand_text_dark))
if (isCurrent) setTypeface(typeface, android.graphics.Typeface.BOLD)
})
}
private fun color(context: Context, res: Int) = ContextCompat.getColor(context, res)
}

View File

@ -0,0 +1,48 @@
package com.tomhempel.rbarometer.ui
import android.content.Context
import android.content.res.ColorStateList
import android.util.TypedValue
import android.view.View
import android.widget.RadioButton
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.widget.TextViewCompat
import com.google.android.material.button.MaterialButton
import com.tomhempel.rbarometer.R
object SessionUi {
fun applyQuestionScreen(context: Context, root: View?) {
if (root == null) return
root.setBackgroundResource(R.drawable.bg_question_screen)
root.findViewById<TextView>(R.id.textView)?.let { tv ->
TextViewCompat.setTextAppearance(tv, R.style.TextAppearance_Barometer_QuestionHint)
tv.setLineSpacing(0f, 1.25f)
}
root.findViewById<TextView>(R.id.question)?.let { tv ->
TextViewCompat.setTextAppearance(tv, R.style.TextAppearance_Barometer_QuestionTitle)
tv.setLineSpacing(0f, 1.3f)
}
root.findViewById<MaterialButton>(R.id.Qfinish)?.let { btn ->
btn.setTextAppearance(R.style.TextAppearance_Barometer_PrimaryButton)
btn.elevation = context.resources.getDimension(R.dimen.session_btn_elevation)
}
}
fun styleAnswerChoice(context: Context, radioButton: RadioButton) {
val density = context.resources.displayMetrics.density
val padH = (18 * density).toInt()
val padV = (14 * density).toInt()
radioButton.setPadding(padH, padV, padH, padV)
radioButton.background = ContextCompat.getDrawable(context, R.drawable.bg_answer_choice)
radioButton.buttonTintList = ColorStateList.valueOf(
ContextCompat.getColor(context, R.color.brand_purple),
)
radioButton.setTextColor(ContextCompat.getColor(context, R.color.brand_text_dark))
radioButton.setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
radioButton.letterSpacing = 0.01f
}
}

View File

@ -15,6 +15,7 @@ import com.tomhempel.rbarometer.R
import com.tomhempel.rbarometer.data.AnswerRepository
import com.tomhempel.rbarometer.data.CoachScoringReviewRepository
import com.tomhempel.rbarometer.data.CompletedQuestionnaireRepository
import com.tomhempel.rbarometer.data.PendingUploadRepository
import com.tomhempel.rbarometer.data.Question
import com.tomhempel.rbarometer.network.DatabaseUploader
import com.tomhempel.rbarometer.network.QuestionnaireCache
@ -159,8 +160,9 @@ class UploadOverviewHandler(
uploadButton.setOnClickListener {
uploadButton.isEnabled = false
uploadButton.alpha = 0.5f
setListLoading(listLoading, true, recycler, summaryTv, emptyView)
backButton.isEnabled = false
progressDeterminate.visibility = View.VISIBLE
progressDeterminate.isIndeterminate = false
progressDeterminate.progress = 0
progressDeterminate.max = (qCount + sCount).coerceAtLeast(1)
@ -171,12 +173,15 @@ class UploadOverviewHandler(
onProgress = { done, total ->
if (!overviewActive) return@uploadDatabaseWithToken
progressDeterminate.max = total.coerceAtLeast(1)
progressDeterminate.progress = done
progressDeterminate.progress = done.coerceAtMost(progressDeterminate.max)
},
) { result ->
if (!overviewActive) return@uploadDatabaseWithToken
setListLoading(listLoading, false, recycler, summaryTv, emptyView)
progressDeterminate.progress = progressDeterminate.max
progressDeterminate.visibility = View.GONE
backButton.isEnabled = true
uploadButton.isEnabled = true
uploadButton.alpha = 1f
showUploadResultDialog(result)
}
}
@ -223,8 +228,8 @@ class UploadOverviewHandler(
}
private suspend fun loadPendingListItems(): List<ListItem> = withContext(Dispatchers.IO) {
val pending = CompletedQuestionnaireRepository.getAllPendingUploads()
val pendingScoring = CoachScoringReviewRepository.getAllPendingUploads()
val pending = PendingUploadRepository.getUploadReadyQuestionnaires(activity)
val pendingScoring = PendingUploadRepository.getUploadReadyScoringReviews(activity)
if (pending.isEmpty() && pendingScoring.isEmpty()) return@withContext emptyList()
val qRows = pending.map { entry ->

View File

@ -1,14 +1,21 @@
package com.tomhempel.rbarometer
import android.content.Context
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.util.concurrent.atomic.AtomicReference
/**
* Resolves UI strings from API translations (synced from the website).
* No embedded translation tables — configure strings in the admin Translations UI.
* Falls back to bundled German defaults for app UI strings when the server
* has not yet synced translations for the source language.
*/
object LanguageManager {
/** Global app UI strings from GET ?translations=1 */
private var apiTranslations: Map<String, Map<String, String>> = emptyMap()
/** Questionnaire-specific strings from GET ?id=... */
private var questionnaireTranslations: Map<String, Map<String, String>> = emptyMap()
private val bundledGermanDefaults = AtomicReference<Map<String, String>?>(null)
fun setApiTranslations(translations: Map<String, Map<String, String>>) {
apiTranslations = translations
@ -22,6 +29,11 @@ object LanguageManager {
questionnaireTranslations = emptyMap()
}
fun warmBundledDefaults(context: Context) {
if (bundledGermanDefaults.get() != null) return
bundledGermanDefaults.set(loadBundledGermanDefaults(context))
}
fun getTextFormatted(languageId: String, key: String, vararg args: Any): String {
val raw = getText(languageId, key)
return String.format(raw, *args)
@ -29,13 +41,33 @@ object LanguageManager {
fun getText(languageCode: String, id: String): String {
val lang = apiLanguageCode(languageCode)
val raw = questionnaireTranslations[lang]?.get(id)
?: apiTranslations[lang]?.get(id)
?: apiTranslations["de"]?.get(id)
?: id
val fromQuestionnaire = questionnaireTranslations[lang]?.get(id)
val fromApi = apiTranslations[lang]?.get(id)
val fromQuestionnaireDe = questionnaireTranslations["de"]?.get(id)
val fromApiDe = apiTranslations["de"]?.get(id)
val fromBundledDe = bundledGermanDefaults.get()?.get(id)
val raw = when {
!fromQuestionnaire.isNullOrBlank() -> fromQuestionnaire
!fromApi.isNullOrBlank() -> fromApi
!fromQuestionnaireDe.isNullOrBlank() -> fromQuestionnaireDe
!fromApiDe.isNullOrBlank() -> fromApiDe
!fromBundledDe.isNullOrBlank() -> fromBundledDe
else -> id
}
return injectDynamicValues(raw)
}
/** Returns the first key that resolves to translated text (not equal to the key itself). */
fun resolvedText(languageCode: String, vararg keys: String?): String {
for (key in keys) {
if (key.isNullOrBlank()) continue
val text = getText(languageCode, key)
if (text != key) return text
}
return keys.filterNot { it.isNullOrBlank() }.lastOrNull()?.let { getText(languageCode, it) } ?: ""
}
private fun apiLanguageCode(languageCode: String): String = when (languageCode) {
"GERMAN" -> "de"
"ENGLISH" -> "en"
@ -47,8 +79,19 @@ object LanguageManager {
"UKRAINIAN" -> "uk"
"RUSSIAN" -> "ru"
"SPANISH" -> "es"
"CHINESE" -> "zh"
"FARSI" -> "fa"
else -> languageCode.lowercase()
}
private fun loadBundledGermanDefaults(context: Context): Map<String, String> {
return runCatching {
context.assets.open("bundled_german_defaults.json").bufferedReader().use { reader ->
val type = object : TypeToken<Map<String, String>>() {}.type
Gson().fromJson<Map<String, String>>(reader, type) ?: emptyMap()
}
}.getOrDefault(emptyMap())
}
private fun injectDynamicValues(text: String): String = text
}

View File

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#FFFFFFFF"/>
<item android:color="@color/brand_purple" />
</selector>

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:alpha="0.85" android:color="#334B5A" android:state_pressed="true"/>
<item android:color="#385266"/>
<item android:alpha="0.92" android:color="@color/brand_nav_secondary_pressed" android:state_pressed="true" />
<item android:color="@color/brand_nav_secondary" />
</selector>

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:alpha="0.85" android:color="#2F855A" android:state_pressed="true"/>
<item android:color="#339966"/>
<item android:alpha="0.9" android:color="@color/brand_purple" android:state_pressed="true" />
<item android:color="@color/brand_purple" />
</selector>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/brand_ripple_on_surface">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/brand_surface" />
<corners android:radius="16dp" />
<stroke
android:width="1dp"
android:color="@color/brand_card_stroke" />
</shape>
</item>
</ripple>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/brand_emphasis_bg" />
<corners android:radius="14dp" />
<stroke
android:width="1dp"
android:color="@color/brand_header_stroke" />
</shape>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/brand_purple" />
</shape>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@android:color/transparent" />
<stroke
android:width="2dp"
android:color="@color/brand_card_stroke" />
</shape>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/brand_questionnaire_highlight_bg" />
<corners android:radius="16dp" />
<stroke
android:width="2dp"
android:color="@color/brand_purple" />
</shape>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:angle="270"
android:endColor="@color/brand_session_bg_bottom"
android:startColor="@color/brand_session_bg_top"
android:type="linear" />
</shape>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/brand_primary_container" />
<corners android:radius="24dp" />
</shape>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/brand_purple" />
</shape>

View File

@ -5,12 +5,12 @@
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#0F766E"
android:fillColor="#0D9488"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#0E7490"
android:pathData="M0,78 C26,62 48,65 73,49 C88,39 99,27 108,15 L108,108 L0,108 Z" />
android:pathData="M0,72 C22,58 46,60 68,46 C84,36 96,24 108,12 L108,108 L0,108 Z" />
<path
android:fillColor="#14B8A6"
android:pathData="M0,0h108v26 C86,36 64,39 44,32 C27,26 14,17 0,14 Z" />
android:pathData="M0,0 C18,10 38,12 58,8 C78,4 94,10 108,18 L108,0 Z" />
</vector>

View File

@ -4,19 +4,33 @@
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Gauge arc -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M54,23C36.88,23 23,36.88 23,54C23,71.12 36.88,85 54,85C71.12,85 85,71.12 85,54C85,36.88 71.12,23 54,23ZM54,31C66.7,31 77,41.3 77,54C77,66.7 66.7,77 54,77C41.3,77 31,66.7 31,54C31,41.3 41.3,31 54,31Z" />
android:pathData="M31,63 A23,23 0 1,1 77,63 L72,63 A18,18 0 1,0 36,63 Z" />
<!-- Scale ticks -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M54,37C51.79,37 50,38.79 50,41C50,43.21 51.79,45 54,45C56.21,45 58,43.21 58,41C58,38.79 56.21,37 54,37ZM54,41C61.38,41 67.67,45.6 70,52H63.5C61.76,48.45 58.16,46 54,46C49.84,46 46.24,48.45 44.5,52H38C40.33,45.6 46.62,41 54,41Z" />
android:pathData="M33,61 L37,57 L39,59 L35,63 Z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M52,57 L76,43 L58,64 Z" />
android:pathData="M51,35 L55,35 L55,41 L51,41 Z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M31,70h46v7h-46z" />
android:pathData="M69,59 L73,63 L71,65 L67,61 Z" />
<!-- Needle -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M32,54h6v5h-6zM70,54h6v5h-6zM51,30h6v8h-6z" />
android:pathData="M54,58 L71,42 L58,58 Z" />
<!-- Pivot -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M54,58 m-4.5,0 a4.5,4.5 0 1,1 9,0 a4.5,4.5 0 1,1 -9,0" />
<path
android:fillColor="#0D9488"
android:pathData="M54,58 m-2.5,0 a2.5,2.5 0 1,1 5,0 a2.5,2.5 0 1,1 -5,0" />
<!-- Baseline -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M36,67 L72,67 L72,71 L36,71 Z" />
</vector>

View File

@ -14,6 +14,13 @@
android:orientation="horizontal"
app:layout_constraintGuide_begin="32dp" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/gPromptBottom"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.32" />
<com.google.android.material.button.MaterialButton
android:id="@+id/Qprev"
android:layout_width="@dimen/nav_btn_size"
@ -22,15 +29,15 @@
android:layout_marginBottom="16dp"
android:text=""
android:textAllCaps="false"
app:icon="@drawable/ic_chevron_left"
app:iconTint="@color/btn_nav_left_icon_tint"
app:iconSize="@dimen/nav_icon_size"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_left_tint"
app:rippleColor="@color/btn_nav_left_ripple"
app:cornerRadius="999dp"
app:icon="@drawable/ic_chevron_left"
app:iconPadding="0dp"
app:iconSize="@dimen/nav_icon_size"
app:iconTint="@color/btn_nav_left_icon_tint"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
app:layout_constraintStart_toStartOf="parent"
app:rippleColor="@color/btn_nav_left_ripple" />
<com.google.android.material.button.MaterialButton
android:id="@+id/Qnext"
@ -40,90 +47,108 @@
android:layout_marginBottom="16dp"
android:text=""
android:textAllCaps="false"
app:icon="@drawable/ic_chevron_right"
app:iconTint="@color/btn_nav_right_icon_tint"
app:iconSize="@dimen/nav_icon_size"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_right_tint"
app:rippleColor="@color/btn_nav_right_ripple"
app:cornerRadius="999dp"
app:icon="@drawable/ic_chevron_right"
app:iconPadding="0dp"
app:iconSize="@dimen/nav_icon_size"
app:iconTint="@color/btn_nav_right_icon_tint"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
app:layout_constraintEnd_toEndOf="parent"
app:rippleColor="@color/btn_nav_right_ripple" />
<TextView
android:id="@+id/textView"
<ScrollView
android:id="@+id/questionPromptScroll"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold"
android:layout_height="0dp"
android:clipToPadding="false"
android:fillViewport="true"
android:overScrollMode="ifContentScrolls"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:layout_constraintBottom_toTopOf="@id/gPromptBottom"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/gTop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
app:layout_constraintWidth_percent="0.9">
<TextView
android:id="@+id/question"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
app:layout_constraintTop_toBottomOf="@id/textView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="vertical">
<EditText
android:id="@+id/client_code"
<TextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold" />
<TextView
android:id="@+id/question"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:textStyle="bold" />
</LinearLayout>
</ScrollView>
<FrameLayout
android:id="@+id/answerArea"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintWidth_percent="0.7"
app:layout_constraintHeight_percent="0.08"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toTopOf="@id/Qprev"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/question"
android:layout_marginTop="16dp"
android:background="@drawable/bg_edit_text"
android:ems="10"
android:inputType="text"
android:tag="client_code"
android:textColor="@color/brand_text_dark"
android:textColorHint="@color/brand_text_muted"
android:gravity="center_vertical"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="12sp"
android:autoSizeMaxTextSize="36sp"
android:autoSizeStepGranularity="2sp" />
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/gPromptBottom"
app:layout_constraintWidth_percent="0.7">
<EditText
android:id="@+id/coach_code"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintWidth_percent="0.7"
app:layout_constraintHeight_percent="0.08"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/client_code"
android:layout_marginTop="16dp"
android:background="@drawable/bg_edit_text"
android:ems="10"
android:inputType="text"
android:tag="coach_code"
android:textColor="@color/brand_text_dark"
android:textColorHint="@color/brand_text_muted"
android:gravity="center_vertical"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="12sp"
android:autoSizeMaxTextSize="36sp"
android:autoSizeStepGranularity="2sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical">
<EditText
android:id="@+id/client_code"
android:layout_width="match_parent"
android:layout_height="56dp"
android:autoSizeMaxTextSize="36sp"
android:autoSizeMinTextSize="12sp"
android:autoSizeStepGranularity="2sp"
android:autoSizeTextType="uniform"
android:background="@drawable/bg_edit_text"
android:ems="10"
android:gravity="center_vertical"
android:inputType="text"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:tag="client_code"
android:textColor="@color/brand_text_dark"
android:textColorHint="@color/brand_text_muted" />
<EditText
android:id="@+id/coach_code"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_marginTop="16dp"
android:autoSizeMaxTextSize="36sp"
android:autoSizeMinTextSize="12sp"
android:autoSizeStepGranularity="2sp"
android:autoSizeTextType="uniform"
android:background="@drawable/bg_edit_text"
android:ems="10"
android:gravity="center_vertical"
android:inputType="text"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:tag="coach_code"
android:textColor="@color/brand_text_dark"
android:textColorHint="@color/brand_text_muted" />
</LinearLayout>
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -11,114 +11,12 @@
android:orientation="horizontal"
app:layout_constraintGuide_begin="32dp" />
<TextView
android:id="@+id/textView"
android:layout_width="0dp"
<androidx.constraintlayout.widget.Guideline
android:id="@+id/gPromptBottom"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:layout_constraintTop_toBottomOf="@id/gTop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
<TextView
android:id="@+id/question"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
app:layout_constraintTop_toBottomOf="@id/textView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
<TextView
android:id="@+id/date_spinner_day"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:tag="day"
android:gravity="start|center_vertical"
android:textAlignment="viewStart"
android:paddingStart="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.07"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/question"
app:layout_constraintWidth_percent="0.24" />
<TextView
android:id="@+id/date_spinner_month"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:tag="month"
android:gravity="start|center_vertical"
android:textAlignment="viewStart"
android:paddingStart="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.48"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/question"
app:layout_constraintWidth_percent="0.38" />
<TextView
android:id="@+id/date_spinner_year"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="start|center_vertical"
android:paddingStart="8dp"
android:tag="year"
android:textAlignment="viewStart"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/question"
app:layout_constraintWidth_percent="0.30" />
<Spinner
android:id="@+id/spinner_value_day"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:background="@drawable/bg_field_dropdown"
android:popupBackground="@drawable/bg_spinner_popup"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.07"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/date_spinner_day"
app:layout_constraintWidth_percent="0.24" />
<Spinner
android:id="@+id/spinner_value_month"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:background="@drawable/bg_field_dropdown"
android:popupBackground="@drawable/bg_spinner_popup"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.48"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/date_spinner_month"
app:layout_constraintWidth_percent="0.38" />
<Spinner
android:id="@+id/spinner_value_year"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:background="@drawable/bg_field_dropdown"
android:popupBackground="@drawable/bg_spinner_popup"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/date_spinner_year"
app:layout_constraintWidth_percent="0.30" />
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.32" />
<com.google.android.material.button.MaterialButton
android:id="@+id/Qprev"
@ -128,15 +26,15 @@
android:layout_marginBottom="16dp"
android:text=""
android:textAllCaps="false"
app:icon="@drawable/ic_chevron_left"
app:iconTint="@color/btn_nav_left_icon_tint"
app:iconSize="@dimen/nav_icon_size"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_left_tint"
app:rippleColor="@color/btn_nav_left_ripple"
app:cornerRadius="999dp"
app:icon="@drawable/ic_chevron_left"
app:iconPadding="0dp"
app:iconSize="@dimen/nav_icon_size"
app:iconTint="@color/btn_nav_left_icon_tint"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
app:layout_constraintStart_toStartOf="parent"
app:rippleColor="@color/btn_nav_left_ripple" />
<com.google.android.material.button.MaterialButton
android:id="@+id/Qnext"
@ -146,14 +44,143 @@
android:layout_marginBottom="16dp"
android:text=""
android:textAllCaps="false"
app:icon="@drawable/ic_chevron_right"
app:iconTint="@color/btn_nav_right_icon_tint"
app:iconSize="@dimen/nav_icon_size"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_right_tint"
app:rippleColor="@color/btn_nav_right_ripple"
app:cornerRadius="999dp"
app:icon="@drawable/ic_chevron_right"
app:iconPadding="0dp"
app:iconSize="@dimen/nav_icon_size"
app:iconTint="@color/btn_nav_right_icon_tint"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
app:layout_constraintEnd_toEndOf="parent"
app:rippleColor="@color/btn_nav_right_ripple" />
<ScrollView
android:id="@+id/questionPromptScroll"
android:layout_width="0dp"
android:layout_height="0dp"
android:clipToPadding="false"
android:fillViewport="true"
android:overScrollMode="ifContentScrolls"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:layout_constraintBottom_toTopOf="@id/gPromptBottom"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/gTop"
app:layout_constraintWidth_percent="0.9">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold" />
<TextView
android:id="@+id/question"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:textStyle="bold" />
</LinearLayout>
</ScrollView>
<FrameLayout
android:id="@+id/answerArea"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/Qprev"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/gPromptBottom"
app:layout_constraintWidth_percent="0.92">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center">
<TextView
android:id="@+id/date_spinner_day"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="start|center_vertical"
android:paddingStart="8dp"
android:tag="day"
android:textAlignment="viewStart"
app:layout_constraintEnd_toStartOf="@id/date_spinner_month"
app:layout_constraintHorizontal_chainStyle="spread"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_percent="0.24" />
<TextView
android:id="@+id/date_spinner_month"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="start|center_vertical"
android:paddingStart="8dp"
android:tag="month"
android:textAlignment="viewStart"
app:layout_constraintEnd_toStartOf="@id/date_spinner_year"
app:layout_constraintStart_toEndOf="@id/date_spinner_day"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_percent="0.38" />
<TextView
android:id="@+id/date_spinner_year"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="start|center_vertical"
android:paddingStart="8dp"
android:tag="year"
android:textAlignment="viewStart"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/date_spinner_month"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_percent="0.30" />
<Spinner
android:id="@+id/spinner_value_day"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:background="@drawable/bg_field_dropdown"
android:popupBackground="@drawable/bg_spinner_popup"
app:layout_constraintEnd_toEndOf="@id/date_spinner_day"
app:layout_constraintStart_toStartOf="@id/date_spinner_day"
app:layout_constraintTop_toBottomOf="@id/date_spinner_day" />
<Spinner
android:id="@+id/spinner_value_month"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:background="@drawable/bg_field_dropdown"
app:layout_constraintEnd_toEndOf="@id/date_spinner_month"
app:layout_constraintStart_toStartOf="@id/date_spinner_month"
app:layout_constraintTop_toBottomOf="@id/date_spinner_month"
android:popupBackground="@drawable/bg_spinner_popup" />
<Spinner
android:id="@+id/spinner_value_year"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:background="@drawable/bg_field_dropdown"
app:layout_constraintEnd_toEndOf="@id/date_spinner_year"
app:layout_constraintStart_toStartOf="@id/date_spinner_year"
app:layout_constraintTop_toBottomOf="@id/date_spinner_year"
android:popupBackground="@drawable/bg_spinner_popup" />
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -91,21 +91,58 @@
<com.google.android.material.button.MaterialButton
android:id="@+id/dialogBtnPrimary"
style="@style/Widget.Barometer.SessionPrimaryButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="48dp"
android:textAllCaps="false"
app:cornerRadius="14dp" />
android:ellipsize="end"
android:maxLines="1"
android:textAllCaps="false" />
<com.google.android.material.button.MaterialButton
android:id="@+id/dialogBtnSecondary"
style="@style/Widget.Material3.Button.TextButton"
<LinearLayout
android:id="@+id/dialogButtonRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:minHeight="44dp"
android:textAllCaps="false"
android:textColor="@color/brand_text_muted" />
android:layout_marginTop="10dp"
android:orientation="horizontal"
android:visibility="gone">
<com.google.android.material.button.MaterialButton
android:id="@+id/dialogBtnSecondary"
style="@style/Widget.Barometer.SessionSecondaryButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="6dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:textAllCaps="false" />
<com.google.android.material.button.MaterialButton
android:id="@+id/dialogBtnTertiary"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:minHeight="52dp"
android:textAllCaps="false"
android:textColor="@color/brand_text_muted"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/dialogBtnPrimaryInline"
style="@style/Widget.Barometer.SessionPrimaryButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:textAllCaps="false"
android:visibility="gone" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>

View File

@ -13,6 +13,13 @@
android:orientation="horizontal"
app:layout_constraintGuide_begin="32dp" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/gPromptBottom"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.32" />
<com.google.android.material.button.MaterialButton
android:id="@+id/Qprev"
android:layout_width="@dimen/nav_btn_size"
@ -21,15 +28,15 @@
android:layout_marginBottom="16dp"
android:text=""
android:textAllCaps="false"
app:icon="@drawable/ic_chevron_left"
app:iconTint="@color/btn_nav_left_icon_tint"
app:iconSize="@dimen/nav_icon_size"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_left_tint"
app:rippleColor="@color/btn_nav_left_ripple"
app:cornerRadius="999dp"
app:icon="@drawable/ic_chevron_left"
app:iconPadding="0dp"
app:iconSize="@dimen/nav_icon_size"
app:iconTint="@color/btn_nav_left_icon_tint"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
app:layout_constraintStart_toStartOf="parent"
app:rippleColor="@color/btn_nav_left_ripple" />
<com.google.android.material.button.MaterialButton
android:id="@+id/Qnext"
@ -39,88 +46,94 @@
android:layout_marginBottom="16dp"
android:text=""
android:textAllCaps="false"
app:icon="@drawable/ic_chevron_right"
app:iconTint="@color/btn_nav_right_icon_tint"
app:iconSize="@dimen/nav_icon_size"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_right_tint"
app:rippleColor="@color/btn_nav_right_ripple"
app:cornerRadius="999dp"
app:icon="@drawable/ic_chevron_right"
app:iconPadding="0dp"
app:iconSize="@dimen/nav_icon_size"
app:iconTint="@color/btn_nav_right_icon_tint"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
app:layout_constraintEnd_toEndOf="parent"
app:rippleColor="@color/btn_nav_right_ripple" />
<TextView
android:id="@+id/textView"
<ScrollView
android:id="@+id/questionPromptScroll"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold"
android:layout_height="0dp"
android:clipToPadding="false"
android:fillViewport="true"
android:overScrollMode="ifContentScrolls"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:layout_constraintBottom_toTopOf="@id/gPromptBottom"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/gTop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
app:layout_constraintWidth_percent="0.9">
<TextView
android:id="@+id/note_before"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:visibility="gone"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:layout_constraintTop_toBottomOf="@id/textView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:id="@+id/question"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
app:layout_constraintTop_toBottomOf="@id/note_before"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
<TextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold" />
<TextView
android:id="@+id/note_after"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:visibility="gone"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:layout_constraintTop_toBottomOf="@id/question"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
<TextView
android:id="@+id/note_before"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:visibility="gone" />
<EditText
android:id="@+id/free_text_input"
<TextView
android:id="@+id/question"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:textStyle="bold" />
<TextView
android:id="@+id/note_after"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:visibility="gone" />
</LinearLayout>
</ScrollView>
<FrameLayout
android:id="@+id/answerArea"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="@drawable/bg_edit_text"
android:gravity="center_vertical|start"
android:inputType="textCapSentences"
android:textColor="@color/brand_text_dark"
android:textColorHint="@color/brand_text_muted"
android:maxLines="1"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="10dp"
android:paddingBottom="10dp"
app:layout_constraintWidth_percent="0.85"
app:layout_constraintStart_toStartOf="parent"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/Qprev"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/note_after" />
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/gPromptBottom"
app:layout_constraintWidth_percent="0.85">
<EditText
android:id="@+id/free_text_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="@drawable/bg_edit_text"
android:gravity="center_vertical|start"
android:inputType="textCapSentences"
android:maxLines="1"
android:paddingStart="12dp"
android:paddingTop="10dp"
android:paddingEnd="12dp"
android:paddingBottom="10dp"
android:textColor="@color/brand_text_dark"
android:textColorHint="@color/brand_text_muted" />
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,8 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
tools:context=".MainActivity">
<androidx.constraintlayout.widget.Guideline
android:id="@+id/gTop"
@ -11,69 +14,12 @@
android:orientation="horizontal"
app:layout_constraintGuide_begin="32dp" />
<TextView
android:id="@+id/textView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:layout_constraintTop_toBottomOf="@id/gTop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
<TextView
android:id="@+id/question"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
app:layout_constraintTop_toBottomOf="@id/textView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
<LinearLayout
android:id="@+id/glass_header"
android:layout_width="0dp"
<androidx.constraintlayout.widget.Guideline
android:id="@+id/gPromptBottom"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="8dp"
android:weightSum="9"
app:layout_constraintTop_toBottomOf="@id/question"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="1" />
<ScrollView
android:id="@+id/glassScroll"
android:layout_width="0dp"
android:layout_height="0dp"
android:fillViewport="true"
android:clipToPadding="false"
android:paddingTop="4dp"
android:paddingBottom="8dp"
app:layout_constraintTop_toBottomOf="@id/glass_header"
app:layout_constraintBottom_toTopOf="@+id/Qprev"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="1">
<TableLayout
android:id="@+id/glass_table"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp"
android:stretchColumns="1,2,3,4,5"
android:textAlignment="center"
android:textStyle="bold" />
</ScrollView>
app:layout_constraintGuide_percent="0.28" />
<com.google.android.material.button.MaterialButton
android:id="@+id/Qprev"
@ -81,15 +27,15 @@
android:layout_height="@dimen/nav_btn_size"
android:layout_marginStart="20dp"
android:layout_marginBottom="16dp"
app:icon="@drawable/ic_chevron_left"
app:iconTint="@color/btn_nav_left_icon_tint"
app:iconSize="@dimen/nav_icon_size"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_left_tint"
app:rippleColor="@color/btn_nav_left_ripple"
app:cornerRadius="999dp"
app:icon="@drawable/ic_chevron_left"
app:iconPadding="0dp"
app:iconSize="@dimen/nav_icon_size"
app:iconTint="@color/btn_nav_left_icon_tint"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
app:layout_constraintStart_toStartOf="parent"
app:rippleColor="@color/btn_nav_left_ripple" />
<com.google.android.material.button.MaterialButton
android:id="@+id/Qnext"
@ -97,14 +43,97 @@
android:layout_height="@dimen/nav_btn_size"
android:layout_marginEnd="20dp"
android:layout_marginBottom="16dp"
app:icon="@drawable/ic_chevron_right"
app:iconTint="@color/btn_nav_right_icon_tint"
app:iconSize="@dimen/nav_icon_size"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_right_tint"
app:rippleColor="@color/btn_nav_right_ripple"
app:cornerRadius="999dp"
app:icon="@drawable/ic_chevron_right"
app:iconPadding="0dp"
app:iconSize="@dimen/nav_icon_size"
app:iconTint="@color/btn_nav_right_icon_tint"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
app:layout_constraintEnd_toEndOf="parent"
app:rippleColor="@color/btn_nav_right_ripple" />
<ScrollView
android:id="@+id/questionPromptScroll"
android:layout_width="0dp"
android:layout_height="0dp"
android:clipToPadding="false"
android:fillViewport="true"
android:overScrollMode="ifContentScrolls"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:layout_constraintBottom_toTopOf="@id/gPromptBottom"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/gTop"
app:layout_constraintWidth_percent="0.9">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold" />
<TextView
android:id="@+id/question"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:textStyle="bold" />
</LinearLayout>
</ScrollView>
<FrameLayout
android:id="@+id/answerArea"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/Qprev"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/gPromptBottom">
<ScrollView
android:id="@+id/glassScroll"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:fillViewport="true"
android:paddingTop="4dp"
android:paddingBottom="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical">
<LinearLayout
android:id="@+id/glass_header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="8dp"
android:weightSum="9" />
<TableLayout
android:id="@+id/glass_table"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp"
android:stretchColumns="1,2,3,4,5"
android:textAlignment="center"
android:textStyle="bold" />
</LinearLayout>
</ScrollView>
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/sessionMeasureCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/session_measure_row_spacing"
android:minHeight="@dimen/session_measure_row_min_height"
app:cardBackgroundColor="@color/brand_header_surface"
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
app:strokeColor="@color/brand_card_stroke"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:minHeight="@dimen/session_measure_row_min_height"
android:orientation="horizontal"
android:paddingStart="16dp"
android:paddingTop="14dp"
android:paddingEnd="16dp"
android:paddingBottom="14dp">
<FrameLayout
android:id="@+id/sessionMeasureIndicatorWrap"
android:layout_width="@dimen/session_measure_indicator_size"
android:layout_height="@dimen/session_measure_indicator_size">
<TextView
android:id="@+id/sessionMeasureIndicator"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:textSize="18sp"
android:textStyle="bold" />
</FrameLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/sessionMeasureTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:textColor="@color/brand_text_dark"
android:textSize="17sp"
android:textStyle="bold" />
<TextView
android:id="@+id/sessionMeasureStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="@color/brand_text_muted"
android:textSize="13sp" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@ -1,80 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:background="@drawable/bg_question_screen">
<androidx.constraintlayout.widget.Guideline
android:id="@+id/gTop"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_begin="48dp" />
<com.google.android.material.card.MaterialCardView
android:id="@+id/lastPageCard"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
app:cardBackgroundColor="@color/brand_surface"
app:cardCornerRadius="24dp"
app:cardElevation="@dimen/session_btn_elevation"
app:layout_constraintBottom_toTopOf="@+id/Qfinish"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/gTop"
app:layout_constraintVertical_bias="0.35"
app:strokeColor="@color/brand_card_stroke"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="vertical"
android:padding="28dp">
<TextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Barometer.QuestionHint" />
<TextView
android:id="@+id/question"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:gravity="center"
android:lineSpacingMultiplier="1.35"
android:textAppearance="@style/TextAppearance.Barometer.QuestionTitle" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Zurück (links unten) -->
<com.google.android.material.button.MaterialButton
android:id="@+id/Qprev"
android:layout_width="@dimen/nav_btn_size"
android:layout_height="@dimen/nav_btn_size"
android:layout_marginStart="20dp"
android:layout_marginBottom="16dp"
android:layout_marginBottom="20dp"
android:text=""
android:textAllCaps="false"
app:icon="@drawable/ic_chevron_left"
app:iconTint="@color/btn_nav_left_icon_tint"
app:iconSize="@dimen/nav_icon_size"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_left_tint"
app:rippleColor="@color/btn_nav_left_ripple"
app:cornerRadius="999dp"
app:icon="@drawable/ic_chevron_left"
app:iconPadding="0dp"
app:iconSize="@dimen/nav_icon_size"
app:iconTint="@color/btn_nav_left_icon_tint"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
app:layout_constraintStart_toStartOf="parent"
app:rippleColor="@color/btn_nav_left_ripple" />
<!-- Fertig/Speichern (rechts unten) gleiche Farbe wie Qprev -->
<com.google.android.material.button.MaterialButton
android:id="@+id/Qfinish"
android:layout_width="@dimen/finish_btn_width"
style="@style/Widget.Barometer.SessionPrimaryButton"
android:layout_width="0dp"
android:layout_height="@dimen/nav_btn_size"
android:layout_marginStart="12dp"
android:layout_marginEnd="20dp"
android:layout_marginBottom="16dp"
android:textAllCaps="false"
android:minWidth="0dp"
android:layout_marginBottom="20dp"
android:insetLeft="0dp"
android:insetRight="0dp"
android:paddingStart="24dp"
android:paddingEnd="24dp"
android:minWidth="0dp"
android:paddingStart="28dp"
android:paddingEnd="28dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_right_tint"
app:rippleColor="@color/btn_nav_right_ripple"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/textView"
android:layout_width="0dp"
android:layout_height="0dp"
android:gravity="start|center_vertical"
android:textAlignment="viewStart"
android:paddingStart="8dp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_percent="0.25"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.051"
app:layout_constraintWidth_percent="0.9" />
<TextView
android:id="@+id/question"
android:layout_width="0dp"
android:layout_height="0dp"
android:gravity="start|center_vertical"
android:textAlignment="viewStart"
android:paddingStart="8dp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_percent="0.25"
app:layout_constraintHorizontal_bias="0.512"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView"
app:layout_constraintVertical_bias="0.0"
app:layout_constraintWidth_percent="0.9" />
app:layout_constraintStart_toEndOf="@+id/Qprev" />
<ProgressBar
android:id="@+id/progressBar"
@ -82,9 +96,9 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -14,6 +14,13 @@
android:orientation="horizontal"
app:layout_constraintGuide_begin="32dp" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/gPromptBottom"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.32" />
<com.google.android.material.button.MaterialButton
android:id="@+id/Qprev"
android:layout_width="@dimen/nav_btn_size"
@ -22,15 +29,15 @@
android:layout_marginBottom="16dp"
android:text=""
android:textAllCaps="false"
app:icon="@drawable/ic_chevron_left"
app:iconTint="@color/btn_nav_left_icon_tint"
app:iconSize="@dimen/nav_icon_size"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_left_tint"
app:rippleColor="@color/btn_nav_left_ripple"
app:cornerRadius="999dp"
app:icon="@drawable/ic_chevron_left"
app:iconPadding="0dp"
app:iconSize="@dimen/nav_icon_size"
app:iconTint="@color/btn_nav_left_icon_tint"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
app:layout_constraintStart_toStartOf="parent"
app:rippleColor="@color/btn_nav_left_ripple" />
<com.google.android.material.button.MaterialButton
android:id="@+id/Qnext"
@ -40,63 +47,88 @@
android:layout_marginBottom="16dp"
android:text=""
android:textAllCaps="false"
app:icon="@drawable/ic_chevron_right"
app:iconTint="@color/btn_nav_right_icon_tint"
app:iconSize="@dimen/nav_icon_size"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_right_tint"
app:rippleColor="@color/btn_nav_right_ripple"
app:cornerRadius="999dp"
app:icon="@drawable/ic_chevron_right"
app:iconPadding="0dp"
app:iconSize="@dimen/nav_icon_size"
app:iconTint="@color/btn_nav_right_icon_tint"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/textView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:layout_constraintTop_toBottomOf="@id/gTop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
<TextView
android:id="@+id/question"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
app:layout_constraintTop_toBottomOf="@id/textView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
app:rippleColor="@color/btn_nav_right_ripple" />
<ScrollView
android:id="@+id/scrollView"
android:id="@+id/questionPromptScroll"
android:layout_width="0dp"
android:layout_height="0dp"
android:clipToPadding="false"
android:fillViewport="true"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintStart_toStartOf="parent"
android:overScrollMode="ifContentScrolls"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:layout_constraintBottom_toTopOf="@id/gPromptBottom"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/question"
app:layout_constraintBottom_toTopOf="@id/Qnext"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/gTop"
app:layout_constraintWidth_percent="0.9">
<LinearLayout
android:id="@+id/CheckboxContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp" />
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold" />
<TextView
android:id="@+id/question"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:textStyle="bold" />
</LinearLayout>
</ScrollView>
<FrameLayout
android:id="@+id/answerArea"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/Qprev"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/gPromptBottom"
app:layout_constraintWidth_percent="0.9">
<ScrollView
android:id="@+id/scrollView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:fillViewport="true"
android:paddingTop="8dp"
android:paddingBottom="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical">
<LinearLayout
android:id="@+id/CheckboxContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp" />
</LinearLayout>
</ScrollView>
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -3,7 +3,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/brand_window_bg"
android:background="@drawable/bg_question_screen"
android:orientation="vertical">
<LinearLayout
@ -77,21 +77,19 @@
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="12dp"
android:background="@color/brand_surface"
android:elevation="8dp">
android:elevation="4dp"
android:padding="16dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSave"
style="@style/Widget.Barometer.SessionPrimaryButton"
android:layout_width="match_parent"
android:layout_height="@dimen/nav_btn_size"
android:textAllCaps="false"
android:minWidth="0dp"
android:insetLeft="0dp"
android:insetRight="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_right_tint"
app:rippleColor="@color/btn_nav_right_ripple" />
app:cornerRadius="999dp" />
<ProgressBar
android:id="@+id/progressBar"

View File

@ -14,6 +14,13 @@
android:orientation="horizontal"
app:layout_constraintGuide_begin="32dp" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/gPromptBottom"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.32" />
<com.google.android.material.button.MaterialButton
android:id="@+id/Qprev"
android:layout_width="@dimen/nav_btn_size"
@ -22,15 +29,15 @@
android:layout_marginBottom="16dp"
android:text=""
android:textAllCaps="false"
app:icon="@drawable/ic_chevron_left"
app:iconTint="@color/btn_nav_left_icon_tint"
app:iconSize="@dimen/nav_icon_size"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_left_tint"
app:rippleColor="@color/btn_nav_left_ripple"
app:cornerRadius="999dp"
app:icon="@drawable/ic_chevron_left"
app:iconPadding="0dp"
app:iconSize="@dimen/nav_icon_size"
app:iconTint="@color/btn_nav_left_icon_tint"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
app:layout_constraintStart_toStartOf="parent"
app:rippleColor="@color/btn_nav_left_ripple" />
<com.google.android.material.button.MaterialButton
android:id="@+id/Qnext"
@ -40,64 +47,88 @@
android:layout_marginBottom="16dp"
android:text=""
android:textAllCaps="false"
app:icon="@drawable/ic_chevron_right"
app:iconTint="@color/btn_nav_right_icon_tint"
app:iconSize="@dimen/nav_icon_size"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_right_tint"
app:rippleColor="@color/btn_nav_right_ripple"
app:cornerRadius="999dp"
app:icon="@drawable/ic_chevron_right"
app:iconPadding="0dp"
app:iconSize="@dimen/nav_icon_size"
app:iconTint="@color/btn_nav_right_icon_tint"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/textView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:layout_constraintTop_toBottomOf="@id/gTop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
<TextView
android:id="@+id/question"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
app:layout_constraintTop_toBottomOf="@id/textView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
app:rippleColor="@color/btn_nav_right_ripple" />
<ScrollView
android:id="@+id/radioScroll"
android:id="@+id/questionPromptScroll"
android:layout_width="0dp"
android:layout_height="0dp"
android:fillViewport="true"
android:clipToPadding="false"
android:paddingTop="8dp"
android:paddingBottom="8dp"
app:layout_constraintTop_toBottomOf="@id/question"
app:layout_constraintBottom_toTopOf="@id/Qnext"
app:layout_constraintStart_toStartOf="parent"
android:fillViewport="true"
android:overScrollMode="ifContentScrolls"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:layout_constraintBottom_toTopOf="@id/gPromptBottom"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.80">
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/gTop"
app:layout_constraintWidth_percent="0.9">
<RadioGroup
android:id="@+id/RadioGroup"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="8dp" />
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold" />
<TextView
android:id="@+id/question"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:textStyle="bold" />
</LinearLayout>
</ScrollView>
<FrameLayout
android:id="@+id/answerArea"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/Qprev"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/gPromptBottom"
app:layout_constraintWidth_percent="0.85">
<ScrollView
android:id="@+id/radioScroll"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:fillViewport="true"
android:paddingTop="8dp"
android:paddingBottom="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical">
<RadioGroup
android:id="@+id/RadioGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="8dp" />
</LinearLayout>
</ScrollView>
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,142 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_question_screen"
android:clipToPadding="false"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center_horizontal"
android:orientation="vertical"
android:padding="@dimen/session_content_padding">
<View
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="0.35" />
<FrameLayout
android:id="@+id/sessionCompleteIconWrap"
android:layout_width="96dp"
android:layout_height="96dp"
android:background="@drawable/bg_session_icon_circle">
<ImageView
android:layout_width="52dp"
android:layout_height="52dp"
android:layout_gravity="center"
android:contentDescription="@null"
android:src="@drawable/ic_sync_complete"
app:tint="@color/white" />
</FrameLayout>
<TextView
android:id="@+id/sessionCompleteEyebrow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Barometer.SessionEyebrow" />
<TextView
android:id="@+id/sessionCompleteTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Barometer.SessionTitle"
android:textSize="32sp" />
<TextView
android:id="@+id/sessionCompleteSubtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:lineSpacingMultiplier="1.25"
android:textColor="@color/brand_text_muted"
android:textSize="15sp" />
<TextView
android:id="@+id/sessionCompleteClient"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:background="@drawable/bg_control_chip"
android:paddingStart="14dp"
android:paddingTop="8dp"
android:paddingEnd="14dp"
android:paddingBottom="8dp"
android:textColor="@color/brand_text_dark"
android:textSize="14sp"
android:textStyle="bold" />
<com.google.android.material.card.MaterialCardView
android:id="@+id/sessionCompleteCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="22dp"
app:cardBackgroundColor="@color/brand_surface"
app:cardCornerRadius="20dp"
app:cardElevation="@dimen/session_btn_elevation"
app:strokeColor="@color/brand_card_stroke"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:id="@+id/sessionCompleteMeasureHeading"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/brand_text_muted"
android:textSize="12sp"
android:textStyle="bold" />
<LinearLayout
android:id="@+id/sessionCompleteMeasureList"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:orientation="vertical"
android:paddingTop="4dp"
android:paddingBottom="6dp"
android:clipToPadding="false" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<TextView
android:id="@+id/sessionCompleteBody"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:gravity="center"
android:lineSpacingMultiplier="1.35"
android:textColor="@color/brand_text_muted"
android:textSize="15sp" />
<View
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="0.65" />
<com.google.android.material.button.MaterialButton
android:id="@+id/sessionCompleteContinue"
style="@style/Widget.Barometer.SessionPrimaryButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
app:icon="@drawable/ic_chevron_right"
app:iconGravity="textEnd"
app:iconPadding="12dp"
app:iconTint="@color/white" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>

View File

@ -0,0 +1,209 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_question_screen"
android:orientation="vertical">
<androidx.core.widget.NestedScrollView
android:id="@+id/interstitialScroll"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:clipToPadding="false"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="@dimen/session_content_padding"
android:paddingBottom="8dp">
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardBackgroundColor="@android:color/transparent"
app:cardCornerRadius="@dimen/session_card_radius"
app:cardElevation="0dp"
app:strokeWidth="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_session_hero"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="20dp">
<FrameLayout
android:layout_width="@dimen/session_icon_circle"
android:layout_height="@dimen/session_icon_circle"
android:background="@drawable/bg_session_icon_circle">
<ImageView
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_gravity="center"
android:contentDescription="@null"
android:src="@drawable/ic_dialog_info_24"
app:tint="@color/white" />
</FrameLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/interstitialEyebrow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.Barometer.SessionEyebrow" />
<TextView
android:id="@+id/interstitialTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:maxLines="2"
android:ellipsize="end"
android:textAppearance="@style/TextAppearance.Barometer.SessionTitle" />
<TextView
android:id="@+id/interstitialSessionLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:maxLines="2"
android:ellipsize="end"
android:textColor="@color/brand_text_dark"
android:textSize="16sp" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/interstitialProgressBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
app:indicatorColor="@color/brand_purple"
app:trackColor="@color/brand_header_stroke"
app:trackCornerRadius="8dp"
app:trackThickness="8dp" />
<TextView
android:id="@+id/interstitialProgress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:lineSpacingMultiplier="1.25"
android:textColor="@color/brand_text_muted"
android:textSize="14sp" />
<TextView
android:id="@+id/interstitialClient"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:background="@drawable/bg_control_chip"
android:paddingStart="14dp"
android:paddingTop="8dp"
android:paddingEnd="14dp"
android:paddingBottom="8dp"
android:textColor="@color/brand_text_dark"
android:textSize="14sp"
android:textStyle="bold" />
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
app:cardBackgroundColor="@color/brand_surface"
app:cardCornerRadius="20dp"
app:cardElevation="@dimen/session_btn_elevation"
app:strokeColor="@color/brand_card_stroke"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:id="@+id/interstitialMeasureHeading"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/brand_text_muted"
android:textSize="12sp"
android:textStyle="bold" />
<LinearLayout
android:id="@+id/interstitialMeasureList"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:orientation="vertical"
android:paddingTop="4dp"
android:paddingBottom="6dp"
android:clipToPadding="false" />
<TextView
android:id="@+id/interstitialSummary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:lineSpacingMultiplier="1.3"
android:textColor="@color/brand_text_muted"
android:textSize="14sp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
<LinearLayout
android:id="@+id/interstitialActions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/brand_surface"
android:elevation="8dp"
android:orientation="vertical"
android:paddingStart="@dimen/session_content_padding"
android:paddingTop="12dp"
android:paddingEnd="@dimen/session_content_padding"
android:paddingBottom="@dimen/session_content_padding">
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginBottom="12dp"
android:background="@color/brand_header_stroke" />
<com.google.android.material.button.MaterialButton
android:id="@+id/interstitialContinue"
style="@style/Widget.Barometer.SessionPrimaryButton"
android:layout_width="match_parent"
android:layout_height="@dimen/session_action_btn_height"
android:ellipsize="end"
android:maxLines="1"
app:icon="@drawable/ic_chevron_right"
app:iconGravity="textEnd"
app:iconPadding="12dp"
app:iconTint="@color/white" />
<com.google.android.material.button.MaterialButton
android:id="@+id/interstitialPause"
style="@style/Widget.Barometer.SessionSecondaryButton"
android:layout_width="match_parent"
android:layout_height="@dimen/session_action_btn_height"
android:layout_marginTop="10dp"
android:ellipsize="end"
android:maxLines="1" />
</LinearLayout>
</LinearLayout>

View File

@ -11,113 +11,135 @@
android:orientation="horizontal"
app:layout_constraintGuide_begin="32dp" />
<TextView
android:id="@+id/textView"
android:layout_width="0dp"
<androidx.constraintlayout.widget.Guideline
android:id="@+id/gPromptBottom"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold"
android:textColor="@color/brand_text_dark"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.32" />
<ScrollView
android:id="@+id/questionPromptScroll"
android:layout_width="0dp"
android:layout_height="0dp"
android:clipToPadding="false"
android:fillViewport="true"
android:overScrollMode="ifContentScrolls"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:layout_constraintBottom_toTopOf="@id/gPromptBottom"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/gTop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
app:layout_constraintWidth_percent="0.9">
<TextView
android:id="@+id/note_before"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="@color/brand_text_dark"
android:textStyle="bold" />
<TextView
android:id="@+id/note_before"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:visibility="gone" />
<TextView
android:id="@+id/question"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:textColor="@color/brand_text_dark"
android:textStyle="bold" />
<TextView
android:id="@+id/note_after"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:visibility="gone" />
</LinearLayout>
</ScrollView>
<FrameLayout
android:id="@+id/answerArea"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:visibility="gone"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:layout_constraintTop_toBottomOf="@id/textView"
app:layout_constraintStart_toStartOf="parent"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/Qprev"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
<TextView
android:id="@+id/question"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold"
android:textColor="@color/brand_text_dark"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
app:layout_constraintTop_toBottomOf="@id/note_before"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
app:layout_constraintTop_toBottomOf="@id/gPromptBottom"
app:layout_constraintWidth_percent="0.9">
<TextView
android:id="@+id/note_after"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:visibility="gone"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:layout_constraintTop_toBottomOf="@id/question"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center">
<TextView
android:id="@+id/slider_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textStyle="bold"
android:textColor="@color/brand_text_dark"
android:textSize="20sp"
android:minHeight="28dp"
app:layout_constraintTop_toBottomOf="@id/note_after"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/slider_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="28dp"
android:textColor="@color/brand_text_dark"
android:textSize="20sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/slider_min"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginEnd="6dp"
android:gravity="center"
android:minWidth="28dp"
android:textColor="@color/brand_text_muted"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="@id/value_slider"
app:layout_constraintEnd_toStartOf="@id/value_slider"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/value_slider" />
<TextView
android:id="@+id/slider_min"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginEnd="6dp"
android:gravity="center"
android:minWidth="28dp"
android:textColor="@color/brand_text_muted"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="@id/value_slider"
app:layout_constraintEnd_toStartOf="@id/value_slider"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/value_slider" />
<SeekBar
android:id="@+id/value_slider"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:layout_constraintEnd_toStartOf="@id/slider_max"
app:layout_constraintStart_toEndOf="@id/slider_min"
app:layout_constraintTop_toBottomOf="@id/slider_value" />
<SeekBar
android:id="@+id/value_slider"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:layout_constraintEnd_toStartOf="@id/slider_max"
app:layout_constraintStart_toEndOf="@id/slider_min"
app:layout_constraintTop_toBottomOf="@id/slider_value" />
<TextView
android:id="@+id/slider_max"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:layout_marginEnd="12dp"
android:gravity="center"
android:minWidth="28dp"
android:textColor="@color/brand_text_muted"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="@id/value_slider"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/value_slider"
app:layout_constraintTop_toTopOf="@id/value_slider" />
<TextView
android:id="@+id/slider_max"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:layout_marginEnd="12dp"
android:gravity="center"
android:minWidth="28dp"
android:textColor="@color/brand_text_muted"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="@id/value_slider"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/value_slider"
app:layout_constraintTop_toTopOf="@id/value_slider" />
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/Qprev"
@ -125,15 +147,15 @@
android:layout_height="@dimen/nav_btn_size"
android:layout_marginStart="20dp"
android:layout_marginBottom="16dp"
app:icon="@drawable/ic_chevron_left"
app:iconTint="@color/btn_nav_left_icon_tint"
app:iconSize="@dimen/nav_icon_size"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_left_tint"
app:rippleColor="@color/btn_nav_left_ripple"
app:cornerRadius="999dp"
app:icon="@drawable/ic_chevron_left"
app:iconPadding="0dp"
app:iconSize="@dimen/nav_icon_size"
app:iconTint="@color/btn_nav_left_icon_tint"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
app:layout_constraintStart_toStartOf="parent"
app:rippleColor="@color/btn_nav_left_ripple" />
<com.google.android.material.button.MaterialButton
android:id="@+id/Qnext"
@ -141,14 +163,14 @@
android:layout_height="@dimen/nav_btn_size"
android:layout_marginEnd="20dp"
android:layout_marginBottom="16dp"
app:icon="@drawable/ic_chevron_right"
app:iconTint="@color/btn_nav_right_icon_tint"
app:iconSize="@dimen/nav_icon_size"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_right_tint"
app:rippleColor="@color/btn_nav_right_ripple"
app:cornerRadius="999dp"
app:icon="@drawable/ic_chevron_right"
app:iconPadding="0dp"
app:iconSize="@dimen/nav_icon_size"
app:iconTint="@color/btn_nav_right_icon_tint"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
app:layout_constraintEnd_toEndOf="parent"
app:rippleColor="@color/btn_nav_right_ripple" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -12,17 +12,12 @@
android:orientation="horizontal"
app:layout_constraintGuide_begin="32dp" />
<Spinner
android:id="@+id/string_spinner"
android:layout_width="0dp"
<androidx.constraintlayout.widget.Guideline
android:id="@+id/gPromptBottom"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="@drawable/bg_field_dropdown"
android:popupBackground="@drawable/bg_spinner_popup"
app:layout_constraintWidth_percent="0.70"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/question" />
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.32" />
<com.google.android.material.button.MaterialButton
android:id="@+id/Qprev"
@ -32,15 +27,15 @@
android:layout_marginBottom="16dp"
android:text=""
android:textAllCaps="false"
app:icon="@drawable/ic_chevron_left"
app:iconTint="@color/btn_nav_left_icon_tint"
app:iconSize="@dimen/nav_icon_size"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_left_tint"
app:rippleColor="@color/btn_nav_left_ripple"
app:cornerRadius="999dp"
app:icon="@drawable/ic_chevron_left"
app:iconPadding="0dp"
app:iconSize="@dimen/nav_icon_size"
app:iconTint="@color/btn_nav_left_icon_tint"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
app:layout_constraintStart_toStartOf="parent"
app:rippleColor="@color/btn_nav_left_ripple" />
<com.google.android.material.button.MaterialButton
android:id="@+id/Qnext"
@ -50,42 +45,72 @@
android:layout_marginBottom="16dp"
android:text=""
android:textAllCaps="false"
app:icon="@drawable/ic_chevron_right"
app:iconTint="@color/btn_nav_right_icon_tint"
app:iconSize="@dimen/nav_icon_size"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_right_tint"
app:rippleColor="@color/btn_nav_right_ripple"
app:cornerRadius="999dp"
app:icon="@drawable/ic_chevron_right"
app:iconPadding="0dp"
app:iconSize="@dimen/nav_icon_size"
app:iconTint="@color/btn_nav_right_icon_tint"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
app:layout_constraintEnd_toEndOf="parent"
app:rippleColor="@color/btn_nav_right_ripple" />
<TextView
android:id="@+id/textView"
<ScrollView
android:id="@+id/questionPromptScroll"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold"
android:layout_height="0dp"
android:clipToPadding="false"
android:fillViewport="true"
android:overScrollMode="ifContentScrolls"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:layout_constraintBottom_toTopOf="@id/gPromptBottom"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/gTop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
app:layout_constraintWidth_percent="0.9">
<TextView
android:id="@+id/question"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold" />
<TextView
android:id="@+id/question"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:textStyle="bold" />
</LinearLayout>
</ScrollView>
<FrameLayout
android:id="@+id/answerArea"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
app:layout_constraintTop_toBottomOf="@id/textView"
app:layout_constraintStart_toStartOf="parent"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/Qprev"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/gPromptBottom"
app:layout_constraintWidth_percent="0.7">
<Spinner
android:id="@+id/string_spinner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="@drawable/bg_field_dropdown"
android:popupBackground="@drawable/bg_spinner_popup" />
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -12,30 +12,12 @@
android:orientation="horizontal"
app:layout_constraintGuide_begin="32dp" />
<Spinner
android:id="@+id/value_spinner"
android:layout_width="0dp"
<androidx.constraintlayout.widget.Guideline
android:id="@+id/gPromptBottom"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:background="@drawable/bg_field_dropdown"
android:popupBackground="@drawable/bg_spinner_popup"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.495"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/note_after"
app:layout_constraintVertical_bias="0.027"
app:layout_constraintWidth_percent="0.70" />
<TextView
android:id="@+id/note_after"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:visibility="gone"
app:layout_constraintTop_toBottomOf="@id/question"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.32" />
<com.google.android.material.button.MaterialButton
android:id="@+id/Qprev"
@ -45,15 +27,15 @@
android:layout_marginBottom="16dp"
android:text=""
android:textAllCaps="false"
app:icon="@drawable/ic_chevron_left"
app:iconTint="@color/btn_nav_left_icon_tint"
app:iconSize="@dimen/nav_icon_size"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_left_tint"
app:rippleColor="@color/btn_nav_left_ripple"
app:cornerRadius="999dp"
app:icon="@drawable/ic_chevron_left"
app:iconPadding="0dp"
app:iconSize="@dimen/nav_icon_size"
app:iconTint="@color/btn_nav_left_icon_tint"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
app:layout_constraintStart_toStartOf="parent"
app:rippleColor="@color/btn_nav_left_ripple" />
<com.google.android.material.button.MaterialButton
android:id="@+id/Qnext"
@ -63,53 +45,86 @@
android:layout_marginBottom="16dp"
android:text=""
android:textAllCaps="false"
app:icon="@drawable/ic_chevron_right"
app:iconTint="@color/btn_nav_right_icon_tint"
app:iconSize="@dimen/nav_icon_size"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_right_tint"
app:rippleColor="@color/btn_nav_right_ripple"
app:cornerRadius="999dp"
app:icon="@drawable/ic_chevron_right"
app:iconPadding="0dp"
app:iconSize="@dimen/nav_icon_size"
app:iconTint="@color/btn_nav_right_icon_tint"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
app:layout_constraintEnd_toEndOf="parent"
app:rippleColor="@color/btn_nav_right_ripple" />
<TextView
android:id="@+id/textView"
<ScrollView
android:id="@+id/questionPromptScroll"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold"
android:layout_height="0dp"
android:clipToPadding="false"
android:fillViewport="true"
android:overScrollMode="ifContentScrolls"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:layout_constraintBottom_toTopOf="@id/gPromptBottom"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/gTop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
app:layout_constraintWidth_percent="0.9">
<TextView
android:id="@+id/note_before"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:visibility="gone"
app:layout_constraintTop_toBottomOf="@id/textView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:id="@+id/question"
<TextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold" />
<TextView
android:id="@+id/note_before"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:visibility="gone" />
<TextView
android:id="@+id/question"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:textStyle="bold" />
<TextView
android:id="@+id/note_after"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:visibility="gone" />
</LinearLayout>
</ScrollView>
<FrameLayout
android:id="@+id/answerArea"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
app:layout_constraintTop_toBottomOf="@id/note_before"
app:layout_constraintStart_toStartOf="parent"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/Qprev"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/gPromptBottom"
app:layout_constraintWidth_percent="0.7">
<Spinner
android:id="@+id/value_spinner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="@drawable/bg_field_dropdown"
android:popupBackground="@drawable/bg_spinner_popup" />
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -63,4 +63,10 @@
<color name="brand_destructive_fill">#4A2028</color>
<color name="brand_destructive_text">#FFB4BE</color>
<color name="brand_bottom_sheet_handle">#5B5A70</color>
<color name="brand_session_bg_top">#161820</color>
<color name="brand_session_bg_bottom">#121218</color>
<color name="brand_answer_choice_checked">#1A4A44</color>
<color name="brand_nav_secondary">#123D39</color>
<color name="brand_nav_secondary_pressed">#14534D</color>
</resources>

View File

@ -3,8 +3,8 @@
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="brand_purple">#0F766E</color>
<color name="brand_window_bg">#F3F7F6</color>
<color name="brand_purple">#0D9488</color>
<color name="brand_window_bg">#F4FAF8</color>
<color name="brand_loading_scrim">#66000000</color>
<color name="brand_loading_track">#E8E4F5</color>
<color name="brand_surface">#FFFFFFFF</color>
@ -15,11 +15,11 @@
<color name="brand_stroke">#D5E4E0</color>
<color name="brand_field_locked_fill">#F4F4F6</color>
<color name="brand_field_locked_stroke">#C7C7D0</color>
<color name="brand_stroke_enabled">#0F766E</color>
<color name="brand_stroke_enabled">#0D9488</color>
<color name="brand_stroke_disabled">#D8E5E2</color>
<color name="brand_primary_container">#DDF4EF</color>
<color name="brand_emphasis_bg">#E7F5F1</color>
<color name="brand_questionnaire_highlight_bg">#DDF4EF</color>
<color name="brand_primary_container">#E6F7F3</color>
<color name="brand_emphasis_bg">#EDF8F5</color>
<color name="brand_questionnaire_highlight_bg">#E0F5F0</color>
<color name="brand_card_locked">#243331</color>
<color name="brand_card_locked_title">#FFFFFFFF</color>
<color name="brand_card_locked_subtitle">#E0E0E0</color>
@ -49,7 +49,7 @@
<color name="brand_control_chip_bg">#FCFEFD</color>
<color name="brand_control_chip_stroke">#DCE9E5</color>
<color name="brand_control_dropdown_bg">#FFFFFFFF</color>
<color name="brand_control_dropdown_stroke">#0F766E</color>
<color name="brand_control_dropdown_stroke">#0D9488</color>
<color name="brand_status_chip_online_bg">#E8F5E9</color>
<color name="brand_status_chip_online_text">#1B5E20</color>
<color name="brand_status_chip_offline_bg">#FFEBEE</color>
@ -71,4 +71,10 @@
<color name="brand_destructive_fill">#FCE8EA</color>
<color name="brand_destructive_text">#A63345</color>
<color name="brand_bottom_sheet_handle">#C7D9D4</color>
<color name="brand_session_bg_top">#F8FCFB</color>
<color name="brand_session_bg_bottom">#EEF7F4</color>
<color name="brand_answer_choice_checked">#D4F0EA</color>
<color name="brand_nav_secondary">#E6F7F3</color>
<color name="brand_nav_secondary_pressed">#D4F0EA</color>
</resources>

View File

@ -21,4 +21,13 @@
<dimen name="language_picker_height">44dp</dimen>
<dimen name="language_picker_min_width">128dp</dimen>
<dimen name="language_picker_padding_horizontal">10dp</dimen>
<dimen name="session_card_radius">24dp</dimen>
<dimen name="session_content_padding">24dp</dimen>
<dimen name="session_btn_elevation">2dp</dimen>
<dimen name="session_icon_circle">52dp</dimen>
<dimen name="session_action_btn_height">56dp</dimen>
<dimen name="session_measure_row_min_height">76dp</dimen>
<dimen name="session_measure_indicator_size">44dp</dimen>
<dimen name="session_measure_row_spacing">18dp</dimen>
</resources>

View File

@ -1,4 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<item name="system_bars_base_padding" type="id" />
<item name="questionnaire_lang_overlay" type="id" />
<item name="questionnaire_screen_wrapper" type="id" />
<item name="questionnaire_content_root" type="id" />
<item name="questionnaire_lang_toolbar" type="id" />
</resources>

View File

@ -11,4 +11,50 @@
<item name="android:textColor">@color/brand_text_dark</item>
<item name="android:textColorHint">@color/brand_text_muted</item>
</style>
<style name="TextAppearance.Barometer.QuestionHint" parent="TextAppearance.Material3.BodyMedium">
<item name="android:textColor">@color/brand_text_muted</item>
<item name="android:textSize">15sp</item>
<item name="android:textStyle">normal</item>
</style>
<style name="TextAppearance.Barometer.QuestionTitle" parent="TextAppearance.Material3.TitleLarge">
<item name="android:textColor">@color/brand_text_dark</item>
<item name="android:textSize">22sp</item>
<item name="android:textStyle">bold</item>
</style>
<style name="TextAppearance.Barometer.SessionEyebrow" parent="TextAppearance.Material3.LabelLarge">
<item name="android:textColor">@color/brand_purple</item>
<item name="android:textSize">12sp</item>
<item name="android:textStyle">bold</item>
<item name="android:letterSpacing">0.06</item>
</style>
<style name="TextAppearance.Barometer.SessionTitle" parent="TextAppearance.Material3.HeadlineMedium">
<item name="android:textColor">@color/brand_text_dark</item>
<item name="android:textSize">28sp</item>
<item name="android:textStyle">bold</item>
</style>
<style name="TextAppearance.Barometer.PrimaryButton" parent="TextAppearance.Material3.LabelLarge">
<item name="android:textSize">16sp</item>
<item name="android:textStyle">bold</item>
</style>
<style name="Widget.Barometer.SessionPrimaryButton" parent="Widget.Material3.Button">
<item name="android:textAllCaps">false</item>
<item name="android:minHeight">56dp</item>
<item name="cornerRadius">28dp</item>
<item name="backgroundTint">@color/brand_purple</item>
<item name="android:textAppearance">@style/TextAppearance.Barometer.PrimaryButton</item>
</style>
<style name="Widget.Barometer.SessionSecondaryButton" parent="Widget.Material3.Button.OutlinedButton">
<item name="android:textAllCaps">false</item>
<item name="android:minHeight">52dp</item>
<item name="cornerRadius">28dp</item>
<item name="strokeColor">@color/brand_purple</item>
<item name="android:textColor">@color/brand_purple</item>
</style>
</resources>

View File

@ -6,12 +6,14 @@
<item name="colorOnPrimaryContainer">@color/brand_text_dark</item>
<item name="colorSurface">@color/brand_surface</item>
<item name="colorOnSurface">@color/brand_text_dark</item>
<item name="colorSecondary">@color/brand_upload_accent</item>
<item name="android:colorBackground">@color/brand_window_bg</item>
<item name="android:windowBackground">@color/brand_window_bg</item>
<item name="android:statusBarColor">@color/brand_window_bg</item>
<item name="android:statusBarColor">@color/brand_session_bg_top</item>
<item name="android:navigationBarColor">@color/brand_surface</item>
<item name="android:windowLightStatusBar" tools:targetApi="m">true</item>
<item name="android:windowLightNavigationBar" tools:targetApi="o_mr1">true</item>
<item name="materialButtonStyle">@style/Widget.Barometer.SessionPrimaryButton</item>
<item name="textInputStyle">@style/Widget.App.TextInputLayout.OutlinedBox</item>
<item name="editTextStyle">@style/Widget.App.TextInputEditText</item>
</style>