commit db5757997fcb7bfa98066c1278ff59939f3565ab Author: Tom Hempel Date: Wed Jun 24 16:04:59 2026 +0200 initial copy from nat-as app diff --git a/.gitattributes.txt b/.gitattributes.txt new file mode 100644 index 0000000..5ebc158 --- /dev/null +++ b/.gitattributes.txt @@ -0,0 +1,31 @@ +# Bildformate +*.png filter=lfs diff=lfs merge=lfs -text +*.jpg filter=lfs diff=lfs merge=lfs -text +*.jpeg filter=lfs diff=lfs merge=lfs -text +*.gif filter=lfs diff=lfs merge=lfs -text +*.webp filter=lfs diff=lfs merge=lfs -text + +# Audioformate +*.mp3 filter=lfs diff=lfs merge=lfs -text +*.wav filter=lfs diff=lfs merge=lfs -text +*.ogg filter=lfs diff=lfs merge=lfs -text + +# Videoformate +*.mp4 filter=lfs diff=lfs merge=lfs -text +*.mov filter=lfs diff=lfs merge=lfs -text +*.webm filter=lfs diff=lfs merge=lfs -text + +# Fonts +*.ttf filter=lfs diff=lfs merge=lfs -text +*.otf filter=lfs diff=lfs merge=lfs -text + +# Große Binaries / Assets / Libraries +*.so filter=lfs diff=lfs merge=lfs -text +*.aar filter=lfs diff=lfs merge=lfs -text +*.jar filter=lfs diff=lfs merge=lfs -text +*.dll filter=lfs diff=lfs merge=lfs -text + +# Archive / export +*.zip filter=lfs diff=lfs merge=lfs -text +*.tar filter=lfs diff=lfs merge=lfs -text +*.7z filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.gitignore.txt b/.gitignore.txt new file mode 100644 index 0000000..36e9b9a --- /dev/null +++ b/.gitignore.txt @@ -0,0 +1,65 @@ +# Gradle +.gradle/ +build/ +*/build/ +!gradle/wrapper/gradle-wrapper.jar + +# IDE +.idea/ +*.iml +*.ipr +*.iws + +# VS Code +.vscode/ + +# OS +.DS_Store +Thumbs.db + +# Signing / secrets +*.jks +*.keystore +local.properties +*.pem +*.key + +# Android specific +captures/ +.externalNativeBuild/ +.cxx/ + +# Misc +*.log +*.tmp +*.bak + +# Ignore generated files +generated/ +gen/ + +# APK / AAB +*.apk +*.ap_ +*.aab + +# Testing / coverage +test-results/ +coverage/ +*.ec + +# Lint and analysis +lint/reports/ +lint/intermediates/ + +# Crashlytics / Firebase +crashlytics-build.properties +fabric.properties + +# Gradle cache +.gradle/ +build/ + +# Ignore Google services config (if private) +google-services.json + diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..a139aa0 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +NAT-AS \ No newline at end of file diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml new file mode 100644 index 0000000..371f2e2 --- /dev/null +++ b/.idea/appInsightsSettings.xml @@ -0,0 +1,26 @@ + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..7643783 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,123 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..ed7a4c6 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..639c779 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000..c224ad5 --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..6d98175 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/render.experimental.xml b/.idea/render.experimental.xml new file mode 100644 index 0000000..8ec256a --- /dev/null +++ b/.idea/render.experimental.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/studiobot.xml b/.idea/studiobot.xml new file mode 100644 index 0000000..539e3b8 --- /dev/null +++ b/.idea/studiobot.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.kotlin/errors/errors-1780606598936.log b/.kotlin/errors/errors-1780606598936.log new file mode 100644 index 0000000..1219b50 --- /dev/null +++ b/.kotlin/errors/errors-1780606598936.log @@ -0,0 +1,4 @@ +kotlin version: 2.0.21 +error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output: + 1. Kotlin compile daemon is ready + diff --git a/android-questionnaire-api.md b/android-questionnaire-api.md new file mode 100644 index 0000000..d1304ff --- /dev/null +++ b/android-questionnaire-api.md @@ -0,0 +1,416 @@ +# Android Questionnaire API Integration + +This document describes the API contract the Android app can use to fetch questionnaires, fetch translations, and upload completed questionnaire answers. + +Use the front-controller routes under `/api/...`. The clean route `/api/app_questionnaires` is preferred because it returns the unified response envelope used by the web app and supports `POST` uploads. + +## Base Requirements + +- Base URL: `https:///api` +- JSON header: `Content-Type: application/json; charset=UTF-8` +- Auth header after login: `Authorization: Bearer ` +- All timestamps are integer Unix timestamps. Use seconds unless the server contract is changed. +- Successful front-controller responses use: + +```json +{ + "ok": true, + "data": {} +} +``` + +- Error responses use: + +```json +{ + "ok": false, + "error": { + "code": "ERROR_CODE", + "message": "Human readable message" + } +} +``` + +Some low-level auth failures can still return a legacy body such as `{"error":"Missing Bearer token"}` or `{"error":"Invalid or expired token"}`. + +## Authentication + +### Login + +`POST /api/auth/login` + +Request: + +```json +{ + "username": "coach1", + "password": "secret" +} +``` + +Success: + +```json +{ + "ok": true, + "data": { + "token": "64-character-hex-token", + "user": "coach1", + "role": "coach" + } +} +``` + +If the account must change its password first, the response contains `mustChangePassword: true` and a temporary token. That temporary token cannot access questionnaire endpoints until the password is changed. + +## Fetch Questionnaires + +### Fetch Active Questionnaire List + +`GET /api/app_questionnaires` + +Headers: + +```http +Authorization: Bearer +``` + +Success: + +```json +{ + "ok": true, + "data": [ + { + "id": "questionnaire_1_demographic_information", + "name": "demographic information", + "showPoints": false, + "condition": {} + } + ] +} +``` + +Fields: + +- `id`: stable questionnaire identifier. Use this when fetching the questionnaire details and uploading answers. +- `name`: display/admin name. +- `showPoints`: whether the app may show the points result. +- `condition`: JSON condition object. Empty conditions are returned as `{}`. + +Only questionnaires with `state = "active"` are returned, ordered by server `orderIndex`. + +### Fetch One Questionnaire + +`GET /api/app_questionnaires?id=` + +Example: + +`GET /api/app_questionnaires?id=questionnaire_1_demographic_information` + +Success: + +```json +{ + "ok": true, + "data": { + "meta": { + "id": "questionnaire_1_demographic_information" + }, + "questions": [ + { + "id": "q1", + "layout": "radio_question", + "question": "consent_instruction", + "options": [ + { + "key": "consent_signed", + "nextQuestionId": "q6" + }, + { + "key": "consent_not_signed", + "nextQuestionId": "q2" + } + ], + "pointsMap": { + "consent_signed": 0, + "consent_not_signed": 0 + } + } + ] + } +} +``` + +Question fields: + +- `id`: short question ID used by the Android app and upload payload, for example `q1`. +- `layout`: question renderer type, for example `radio_question`, `multi_check_box_question`, `string_spinner`, `value_spinner`, `client_coach_code_question`, or `last_page`. +- `question`: translation key or default text for the question. +- `options`: answer choices for choice questions and spinner options. +- `options[].key`: answer option key. Send this back as `answerOptionKey` during upload. +- `options[].nextQuestionId`: optional conditional navigation target. +- `pointsMap`: optional map from answer option key to point value. +- `textKey`, `textKey1`, `textKey2`, `hint`, `hint1`, `hint2`, `symptoms`, `range`, `constraints`, `minSelection`: optional layout-specific config fields. + +Important ID rule: the fetch response returns short question IDs such as `q1`. The server resolves these back to full database IDs during upload, so the Android app should upload the same short IDs it received. + +## Fetch Translations + +`GET /api/app_questionnaires?translations=1` + +**Authentication:** not required (login screen). + +Success: + +```json +{ + "ok": true, + "data": { + "translations": { + "en": { + "consent_instruction": "Consent instruction text", + "consent_signed": "Consent signed", + "client_code": "Client code" + }, + "de": { + "consent_instruction": "Einwilligungstext", + "consent_signed": "Einwilligung unterschrieben", + "client_code": "Klientencode" + } + } + } +} +``` + +The translation map is grouped by language code. Each language contains a flat key-value map of **app UI strings only**. Questionnaire content translations are returned on each `GET ?id=` response in a sibling `translations` object. + +Android lookup process: + +1. Load `translations[activeLanguage]`. +2. For each questionnaire string, look up the string value as a key. +3. If a translation is missing, fall back to the raw key from the questionnaire JSON. + +## Recommended Android Sync Flow + +1. On app start (login screen), fetch app UI translations with `GET /api/app_questionnaires?translations=1` (no token). +2. Login with `/api/auth/login` and store the returned token securely. +3. Fetch the active questionnaire list with `GET /api/app_questionnaires`. +4. For each list item, fetch details with `GET /api/app_questionnaires?id=`. +5. Cache translations, the list, and questionnaire details locally for offline use. +6. When a questionnaire is completed, upload answers with `POST /api/app_questionnaires` (plaintext JSON over HTTPS). + +There is currently no version or `updatedAt` field in the app fetch response, so the safest refresh strategy is to re-fetch translations, list, and details at login or app start when network is available. + +## Android local data security + +The Android app encrypts sensitive data **at rest on the device**: + +- **Answer text** and **client codes** in the local Room database use AES-256-GCM with keys in Android Keystore (ciphertext on disk; plaintext only inside the running app). +- **Assigned client list** cache and **session token** use the same encryption / EncryptedSharedPreferences respectively. +- **Questionnaire definitions** and UI translations cached from this API are not encrypted (no user answers). + +Sensitive API traffic uses **HTTPS** plus **AES-256-CBC envelopes** keyed from the Bearer token (`clientsPayload` on login, encrypted `?clients=1` responses, encrypted upload POST). Questionnaire definitions and UI translations stay plain JSON. See `nat-as-server/docs/android-questionnaire-api.md` for the wire format. + +Re-uploading the same `clientCode` + `questionnaireID` updates server rows in place (supports correction / re-edit workflows). + +## Upload Questionnaire Data + +`POST /api/app_questionnaires` + +Allowed roles: `admin`, `supervisor`, `coach`. + +The authenticated user must be authorized for the submitted `clientCode`. A coach can upload for their own clients. A supervisor can upload for clients of their coaches. An admin can upload for any client. + +Request: + +```json +{ + "questionnaireID": "questionnaire_1_demographic_information", + "clientCode": "CLIENT-001", + "startedAt": 1714471200, + "completedAt": 1714471800, + "answers": [ + { + "questionID": "q1", + "answerOptionKey": "consent_signed", + "answeredAt": 1714471210 + }, + { + "questionID": "q9", + "numericValue": 34, + "answeredAt": 1714471300 + }, + { + "questionID": "q12", + "freeTextValue": "Free text answer", + "answeredAt": 1714471400 + } + ] +} +``` + +Required top-level fields: + +- `questionnaireID`: questionnaire ID from the list/detail endpoints. +- `clientCode`: existing client code. +- `answers`: array of answer objects. + +Optional top-level fields: + +- `startedAt`: questionnaire start timestamp. +- `completedAt`: questionnaire completion timestamp. + +Answer fields: + +- `questionID`: required per useful answer. Use the short question ID returned by the detail endpoint. +- `answerOptionKey`: for choice answers. Use `options[].key` from the detail endpoint. +- `freeTextValue`: for text answers. +- `numericValue`: for numeric/range answers. +- `answeredAt`: timestamp for this answer. + +Current upload behavior: + +- Unknown or empty `questionID` values are skipped. +- Unknown `answerOptionKey` values are stored as no selected option for that question. +- Re-uploading the same `clientCode` and `questionID` overwrites the previous answer. +- Re-uploading the same `clientCode` and `questionnaireID` overwrites the completed questionnaire status and point sum. +- `sumPoints` is calculated from recognized `answerOptionKey` values. +- The current database stores one selected answer option per question. If the Android UI allows multiple selections for a layout, coordinate a server change before uploading multiple option keys for one question. + +Success: + +```json +{ + "ok": true, + "data": { + "submitted": true, + "sumPoints": 0 + } +} +``` + +Common failures: + +- `400 INVALID_BODY`: request body is not valid JSON. +- `400 MISSING_FIELDS`: one or more required fields are missing. +- `400 INVALID_FIELD`: `answers` is not an array. +- `401`: missing token or invalid credentials. +- `403`: expired token, password change required, or insufficient role. +- `404 NOT_FOUND`: questionnaire does not exist, client does not exist, or user is not authorized for that client. +- `405 METHOD_NOT_ALLOWED`: wrong HTTP method. + +## Retrofit Shape + +Example Kotlin data shapes: + +```kotlin +data class ApiEnvelope( + val ok: Boolean, + val data: T? = null, + val error: ApiError? = null +) + +data class ApiError( + val code: String? = null, + val message: String +) + +data class QuestionnaireListItem( + val id: String, + val name: String, + val showPoints: Boolean, + val condition: Map = emptyMap() +) + +data class QuestionnaireDetail( + val meta: QuestionnaireMeta, + val questions: List +) + +data class QuestionnaireMeta( + val id: String +) + +data class Question( + val id: String, + val layout: String, + val question: String, + val options: List? = null, + val pointsMap: Map? = null, + val textKey: String? = null, + val textKey1: String? = null, + val textKey2: String? = null, + val hint: String? = null, + val hint1: String? = null, + val hint2: String? = null, + val symptoms: List? = null, + val range: Map? = null, + val constraints: Map? = null, + val minSelection: Int? = null +) + +data class QuestionOption( + val key: String, + val nextQuestionId: String? = null +) + +data class TranslationsResponse( + val translations: Map> +) + +data class SubmitQuestionnaireRequest( + val questionnaireID: String, + val clientCode: String, + val startedAt: Long? = null, + val completedAt: Long? = null, + val answers: List +) + +data class SubmitAnswer( + val questionID: String, + val answerOptionKey: String? = null, + val freeTextValue: String? = null, + val numericValue: Double? = null, + val answeredAt: Long? = null +) + +data class SubmitQuestionnaireResponse( + val submitted: Boolean, + val sumPoints: Int +) +``` + +Example Retrofit service: + +```kotlin +interface QuestionnaireApi { + @POST("auth/login") + suspend fun login(@Body body: LoginRequest): ApiEnvelope + + @GET("app_questionnaires") + suspend fun getQuestionnaires( + @Header("Authorization") bearerToken: String + ): ApiEnvelope> + + @GET("app_questionnaires") + suspend fun getQuestionnaire( + @Header("Authorization") bearerToken: String, + @Query("id") questionnaireId: String + ): ApiEnvelope + + @GET("app_questionnaires") + suspend fun getTranslations( + @Header("Authorization") bearerToken: String, + @Query("translations") translations: Int = 1 + ): ApiEnvelope + + @POST("app_questionnaires") + suspend fun submitQuestionnaire( + @Header("Authorization") bearerToken: String, + @Body body: SubmitQuestionnaireRequest + ): ApiEnvelope +} +``` + +Pass the token as `Bearer $token`. diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..dc63c87 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,89 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + id("kotlin-kapt") +} + +android { + namespace = "com.dano.test1" + compileSdk = 35 + + defaultConfig { + applicationId = "com.dano.test1" + minSdk = 29 + targetSdk = 35 + versionCode = 1 + versionName = "1.3" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures { + buildConfig = true + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + // Apache POI bringt viele META-INF-Dateien mit – hier aus dem APK ausschließen + packaging { + resources { + excludes += listOf( + "META-INF/DEPENDENCIES", + "META-INF/LICENSE", + "META-INF/LICENSE.txt", + "META-INF/NOTICE", + "META-INF/NOTICE.txt", + "META-INF/NOTICE.md", + "META-INF/LICENSE.md", + "META-INF/*.kotlin_module", + "META-INF/versions/**" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } +} + +val room_version = "2.6.1" + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + implementation(libs.androidx.activity) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.recyclerview) + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + + implementation("com.google.code.gson:gson:2.10.1") + + implementation("androidx.room:room-runtime:$room_version") + kapt("androidx.room:room-compiler:$room_version") + implementation("androidx.room:room-ktx:$room_version") + + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4") + + implementation("androidx.security:security-crypto:1.1.0-alpha06") + + // Server Upload + implementation("com.squareup.okhttp3:okhttp:4.12.0") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..f3c6b54 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Gson models used for API and on-disk cache deserialization. +-keep class com.dano.test1.network.** { *; } +-keep class com.dano.test1.data.** { *; } +-keep class com.dano.test1.questionnaire.** { *; } + +# Room +-keep class * extends androidx.room.RoomDatabase +-keep @androidx.room.Entity class * +-dontwarn androidx.room.paging.** + +# AndroidX Security crypto (EncryptedSharedPreferences / MasterKey) +-keep class androidx.security.crypto.** { *; } + +# Gson reflective serialization +-keepattributes Signature +-keepattributes *Annotation* +-dontwarn sun.misc.** + +# Keep line numbers for crash reports. +-keepattributes SourceFile,LineNumberTable +-renamesourcefileattribute SourceFile diff --git a/app/release/app-release.apk b/app/release/app-release.apk new file mode 100644 index 0000000..d0002e3 Binary files /dev/null and b/app/release/app-release.apk differ diff --git a/app/release/baselineProfiles/0/app-release.dm b/app/release/baselineProfiles/0/app-release.dm new file mode 100644 index 0000000..30bb932 Binary files /dev/null and b/app/release/baselineProfiles/0/app-release.dm differ diff --git a/app/release/baselineProfiles/1/app-release.dm b/app/release/baselineProfiles/1/app-release.dm new file mode 100644 index 0000000..06e6bb1 Binary files /dev/null and b/app/release/baselineProfiles/1/app-release.dm differ diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json new file mode 100644 index 0000000..2c7a83d --- /dev/null +++ b/app/release/output-metadata.json @@ -0,0 +1,37 @@ +{ + "version": 3, + "artifactType": { + "type": "APK", + "kind": "Directory" + }, + "applicationId": "com.dano.test1", + "variantName": "release", + "elements": [ + { + "type": "SINGLE", + "filters": [], + "attributes": [], + "versionCode": 1, + "versionName": "1.3", + "outputFile": "app-release.apk" + } + ], + "elementType": "File", + "baselineProfiles": [ + { + "minApi": 28, + "maxApi": 30, + "baselineProfiles": [ + "baselineProfiles/1/app-release.dm" + ] + }, + { + "minApi": 31, + "maxApi": 2147483647, + "baselineProfiles": [ + "baselineProfiles/0/app-release.dm" + ] + } + ], + "minSdkVersionForDexing": 29 +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/dano/test1/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/dano/test1/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..a05dd25 --- /dev/null +++ b/app/src/androidTest/java/com/dano/test1/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.dano.test1 + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.dano.test1", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..de469cc --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/dano/test1/MainActivity.kt b/app/src/main/java/com/dano/test1/MainActivity.kt new file mode 100644 index 0000000..2c92596 --- /dev/null +++ b/app/src/main/java/com/dano/test1/MainActivity.kt @@ -0,0 +1,506 @@ +package com.dano.test1 + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.os.Build +import android.os.Bundle +import android.content.Intent +import androidx.activity.OnBackPressedCallback +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import com.dano.test1.LanguageManager +import com.dano.test1.ui.AppLanguageStore +import com.dano.test1.ui.NotificationPermissionPrompter +import com.dano.test1.ui.AuthActivity +import com.dano.test1.ui.HandlerOpeningScreen +import com.dano.test1.ui.LogoutDialogs +import com.dano.test1.ui.SessionLogout +import com.dano.test1.ui.SystemBarInsets +import com.dano.test1.utils.SyncProgressOverlay +import com.dano.test1.utils.SyncProgressTexts +import com.dano.test1.utils.ViewUtils +import com.dano.test1.data.PendingUploadRepository +import com.dano.test1.network.NetworkUtils +import com.dano.test1.network.QuestionnaireApiClient +import com.dano.test1.network.SyncLog +import com.dano.test1.network.QuestionnaireCache +import com.dano.test1.network.SyncCoordinator +import com.dano.test1.network.SyncProgress +import com.dano.test1.network.SyncResult +import com.dano.test1.network.TokenStore +import com.dano.test1.questionnaire.QuestionnaireBase +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class MainActivity : AppCompatActivity() { + + private companion object { + private const val STARTUP_SYNC_PREFS = "startup_sync_state" + private const val KEY_LAST_STARTUP_SYNC_MS = "last_startup_sync_ms" + private const val STARTUP_SYNC_INTERVAL_MS = 30L * 60L * 1000L + } + + private lateinit var openingScreenHandler: HandlerOpeningScreen + + var isInQuestionnaire: Boolean = false + var isFirstQuestionnairePage: Boolean = false + /** Set before opening DevSettings so onResume runs a full opening-screen rebuild. */ + private var needsOpeningReinit: Boolean = false + + private var assignedClientsRefreshGeneration: Long = 0L + + fun markOpeningScreenReinit() { + needsOpeningReinit = true + } + + fun bumpAssignedClientsRefreshGeneration() { + assignedClientsRefreshGeneration++ + } + + private var syncProgressOverlay: SyncProgressOverlay? = null + private var backPressInterceptor: (() -> Boolean)? = null + private var notificationPromptRequested = false + + private val requestNotificationPermission = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* no-op */ } + + private val authLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode != RESULT_OK) { + finishAffinity() + return@registerForActivityResult + } + val token = result.data?.getStringExtra(AuthActivity.EXTRA_TOKEN) + if (token.isNullOrBlank()) { + finishAffinity() + return@registerForActivityResult + } + proceedAfterLogin(token) + } + + // LIVE: Network-Callback (optional für Statusleiste) + private var netCb: ConnectivityManager.NetworkCallback? = null + + private val bootLanguageId: String get() = AppLanguageStore.get(this) + private fun t(key: String): String = LanguageManager.getText(bootLanguageId, key) + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + when { + isInQuestionnaire -> confirmExitQuestionnaire() + backPressInterceptor?.invoke() == true -> Unit + ::openingScreenHandler.isInitialized && openingScreenHandler.onBackPressed() -> Unit + else -> { + isEnabled = false + onBackPressedDispatcher.onBackPressed() + } + } + } + }) + + TokenStore.ensureLegacyLoginTimestamp(this) + setContentView(R.layout.activity_startup_loading) + + // === Offline-Start ermöglichen === + // Bedingung: gespeicherter User/Token UND lokaler API-Cache vorhanden -> direkt OpeningScreen + val hasCreds = !TokenStore.getUsername(this).isNullOrBlank() && TokenStore.hasToken(this) + val hasCache = QuestionnaireCache.hasQuestionnaireCache(this) + SyncLog.d("MAIN", "onCreate: hasCreds=$hasCreds, hasCache=$hasCache") + SyncLog.d("MAIN", "onCreate: cached assigned clients count=${QuestionnaireCache.getAssignedClients(this).size}") + if (hasCreds && hasCache) { + validateStoredSessionThenOpen() + return + } + + SyncCoordinator.loadCachedTranslations(this) + CoroutineScope(Dispatchers.Main).launch { + SyncCoordinator.refreshPublicTranslations(this@MainActivity) + } + + // Sonst: Login-Screen -> Login -> Daten laden -> OpeningScreen + launchAuthScreen() + } + + private fun validateStoredSessionThenOpen() { + val token = TokenStore.getToken(this) + if (token.isNullOrBlank() || !TokenStore.isSessionValid(this)) { + requestLogin() + return + } + if (!NetworkUtils.isOnline(this)) { + showCachedOpeningScreen(refreshAfterOpen = false) + return + } + CoroutineScope(Dispatchers.Main).launch { + val sessionCheck = withContext(Dispatchers.IO) { + runCatching { QuestionnaireApiClient.getSession(token) } + } + val session = sessionCheck.getOrNull() + val error = sessionCheck.exceptionOrNull() + if (session != null && session.valid && session.role == "coach" && !session.mustChangePassword) { + routeCachedStartup(token) + } else if (error != null && TokenStore.isAuthError(error)) { + requestLogin() + } else if (error == null) { + requestLogin() + } else { + // Network/server transient failure: keep offline-capable cached mode. + showCachedOpeningScreen(refreshAfterOpen = false) + } + } + } + + private suspend fun routeCachedStartup(token: String) { + val hasPendingUploads = withContext(Dispatchers.IO) { + PendingUploadRepository.hasAny() + } + if (hasPendingUploads || !NetworkUtils.isOnline(this)) { + showCachedOpeningScreen(refreshAfterOpen = false, showUploadPrompt = hasPendingUploads) + return + } + if (!shouldRunStartupSync()) { + showCachedOpeningScreen(refreshAfterOpen = false) + return + } + + showSyncProgressOverlay(bootLanguageId) + val syncResult = withContext(Dispatchers.IO) { + SyncCoordinator.refreshManual( + context = this@MainActivity, + token = token, + onProgress = { reportSyncProgress(it) }, + ) + } + hideSyncProgressOverlay() + + when (syncResult) { + SyncResult.AuthRequired -> { + requestLogin() + return + } + else -> { + if (syncResult is SyncResult.Success) { + markSuccessfulSync() + } + showCachedOpeningScreen(refreshAfterOpen = false) + if (syncResult is SyncResult.Failure) { + SyncCoordinator.toastForResult(this, bootLanguageId, syncResult) + } + } + } + } + + private fun shouldRunStartupSync(): Boolean { + val lastSyncAt = getSharedPreferences(STARTUP_SYNC_PREFS, Context.MODE_PRIVATE) + .getLong(KEY_LAST_STARTUP_SYNC_MS, 0L) + return lastSyncAt <= 0L || + System.currentTimeMillis() - lastSyncAt >= STARTUP_SYNC_INTERVAL_MS + } + + fun markSuccessfulSync() { + getSharedPreferences(STARTUP_SYNC_PREFS, Context.MODE_PRIVATE) + .edit() + .putLong(KEY_LAST_STARTUP_SYNC_MS, System.currentTimeMillis()) + .apply() + } + + private fun showCachedOpeningScreen(refreshAfterOpen: Boolean, showUploadPrompt: Boolean = false) { + SyncCoordinator.loadCachedTranslations(this) + openingScreenHandler = HandlerOpeningScreen(this) + openingScreenHandler.init() + if (showUploadPrompt) { + openingScreenHandler.runAfterFirstClientLoad { + openingScreenHandler.showStartupUploadPrompt() + } + } + if (refreshAfterOpen) { + openingScreenHandler.runAfterFirstClientLoad { + refreshAssignedClientsInBackground() + } + } + scheduleNotificationPermissionPrompt() + } + + /** Refreshes assigned clients from the server when online (e.g. after app restart). */ + private fun refreshAssignedClientsInBackground() { + val token = TokenStore.getToken(this) ?: return + if (!NetworkUtils.isOnline(this)) { + SyncLog.d("MAIN", "refreshAssignedClients skipped: offline") + return + } + val generation = assignedClientsRefreshGeneration + CoroutineScope(Dispatchers.IO).launch { + when (val result = SyncCoordinator.refreshClientsOnly(this@MainActivity, token)) { + SyncResult.AuthRequired -> { + withContext(Dispatchers.Main) { requestLogin() } + return@launch + } + SyncResult.Success -> { + withContext(Dispatchers.Main) { + if (generation != assignedClientsRefreshGeneration) return@withContext + if (::openingScreenHandler.isInitialized && !isInQuestionnaire) { + openingScreenHandler.onAssignedClientsSynced() + } + } + } + else -> {} + } + } + } + + /** + * Clears all local data and shows the login screen (e.g. expired token). + * Does not check pending uploads — session is already invalid. + */ + fun requestLogin() { + isInQuestionnaire = false + CoroutineScope(Dispatchers.Main).launch { + val previousUser = TokenStore.getUsername(this@MainActivity) + withContext(Dispatchers.IO) { SessionLogout.wipeAllLocalData(this@MainActivity) } + launchAuthScreen(prefillUsername = previousUser) + } + } + + /** User-initiated logout from settings; blocked when offline or unuploaded data remains. */ + fun attemptLogout(languageId: String? = null) { + val lang = languageId ?: bootLanguageId + CoroutineScope(Dispatchers.Main).launch { + val block = withContext(Dispatchers.IO) { SessionLogout.evaluate(this@MainActivity) } + when (block) { + null -> LogoutDialogs.showConfirm(this@MainActivity, lang) { + CoroutineScope(Dispatchers.Main).launch { + withContext(Dispatchers.IO) { SessionLogout.wipeAllLocalData(this@MainActivity) } + isInQuestionnaire = false + launchAuthScreen() + } + } + SessionLogout.BlockReason.Offline -> LogoutDialogs.showBlocked( + activity = this@MainActivity, + languageId = lang, + title = SessionLogout.message(lang, "logout_offline_title", "Cannot log out"), + message = SessionLogout.message( + lang, + "logout_offline_message", + "You are offline. Connect to the internet, upload pending data, then try again.", + ), + tone = LogoutDialogs.Tone.Info, + ) + is SessionLogout.BlockReason.UnuploadedWork -> { + val details = SessionLogout.formatPendingDetails(lang, block.summary) + val bodyTemplate = SessionLogout.message( + lang, + "logout_pending_message", + "You still have data on this device that has not been uploaded.\n\n{details}\n\nPlease tap Upload and wait until it finishes.", + ) + val message = bodyTemplate.replace("{details}", "").trim() + LogoutDialogs.showBlocked( + activity = this@MainActivity, + languageId = lang, + title = SessionLogout.message(lang, "logout_pending_title", "Cannot log out"), + message = message, + details = details, + tone = LogoutDialogs.Tone.Warning, + ) + } + } + } + } + + private fun launchAuthScreen(prefillUsername: String? = null) { + val intent = Intent(this, AuthActivity::class.java) + prefillUsername?.takeIf { it.isNotBlank() }?.let { + intent.putExtra(AuthActivity.EXTRA_PREFILL_USERNAME, it) + } + authLauncher.launch(intent) + } + + fun showSyncProgressOverlay(languageId: String? = null) { + val locale = SyncProgressTexts.Locale.fromLanguageId(languageId ?: bootLanguageId) + ensureSyncProgressOverlay().setLocale(locale) + ensureSyncProgressOverlay().show() + } + + fun hideSyncProgressOverlay() { + syncProgressOverlay?.hide() + } + + fun reportSyncProgress(progress: SyncProgress) { + runOnUiThread { ensureSyncProgressOverlay().update(progress) } + } + + private fun ensureSyncProgressOverlay(): SyncProgressOverlay { + if (syncProgressOverlay == null) { + syncProgressOverlay = SyncProgressOverlay(this) + } + return syncProgressOverlay!! + } + + private fun proceedAfterLogin(token: String) { + SyncLog.d("MAIN", "Login success") + SyncLog.d("MAIN", "Assigned clients from cache after login count=${QuestionnaireCache.getAssignedClients(this).size}") + showSyncProgressOverlay(bootLanguageId) + CoroutineScope(Dispatchers.IO).launch { + val syncResult = SyncCoordinator.refreshAfterLogin( + context = this@MainActivity, + token = token, + languageId = bootLanguageId, + onProgress = { reportSyncProgress(it) }, + ) + + withContext(Dispatchers.Main) { + hideSyncProgressOverlay() + when (syncResult) { + SyncResult.AuthRequired -> { + requestLogin() + return@withContext + } + is SyncResult.Failure -> { + SyncCoordinator.toastForResult(this@MainActivity, bootLanguageId, syncResult) + } + SyncResult.Success -> markSuccessfulSync() + else -> {} + } + openingScreenHandler = HandlerOpeningScreen(this@MainActivity) + openingScreenHandler.init() + openingScreenHandler.refreshHeaderStatusLive() + scheduleNotificationPermissionPrompt() + } + } + } + + fun setBackPressInterceptor(interceptor: (() -> Boolean)?) { + backPressInterceptor = interceptor + } + + private fun scheduleNotificationPermissionPrompt() { + if (notificationPromptRequested) return + notificationPromptRequested = true + window.decorView.post { + NotificationPermissionPrompter.showIfNeeded(this, requestNotificationPermission) + } + } + + private fun confirmExitQuestionnaire() { + fun td(key: String, fallbackDe: String, fallbackEn: String): String { + val raw = LanguageManager.getText(bootLanguageId, key) + if (raw != key) return raw + return if (bootLanguageId == "GERMAN") fallbackDe else fallbackEn + } + MaterialAlertDialogBuilder(this) + .setTitle(td("questionnaire_exit_title", "Fragebogen verlassen?", "Leave questionnaire?")) + .setMessage( + td( + "questionnaire_exit_message", + "Nicht gespeicherte Eingaben in diesem Schritt können verloren gehen.", + "Unsaved answers on this screen may be lost.", + ), + ) + .setNegativeButton(td("cancel", "Abbrechen", "Cancel")) { d, _ -> d.dismiss() } + .setPositiveButton(td("leave", "Verlassen", "Leave")) { _, _ -> finishQuestionnaire() } + .show() + } + + // --- LIVE NETZSTATUS (optional, für deine Status-Leiste) --- + override fun onResume() { + super.onResume() + registerNetworkCallback() + if (::openingScreenHandler.isInitialized && !isInQuestionnaire) { + if (needsOpeningReinit) { + needsOpeningReinit = false + openingScreenHandler.init() + } else { + openingScreenHandler.refreshHeaderStatusLive() + } + if (QuestionnaireCache.getAssignedClients(this).isEmpty()) { + refreshAssignedClientsInBackground() + } + } + } + + override fun onPause() { + super.onPause() + unregisterNetworkCallback() + } + + private fun registerNetworkCallback() { + if (netCb != null) return + val cm = getSystemService(ConnectivityManager::class.java) + val req = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + netCb = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + runOnUiThread { + if (::openingScreenHandler.isInitialized && !isInQuestionnaire) { + openingScreenHandler.refreshHeaderStatusLive() + } + } + } + override fun onLost(network: Network) { + runOnUiThread { + if (::openingScreenHandler.isInitialized && !isInQuestionnaire) { + openingScreenHandler.refreshHeaderStatusLive() + } + } + } + override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) { + runOnUiThread { + if (::openingScreenHandler.isInitialized && !isInQuestionnaire) { + openingScreenHandler.refreshHeaderStatusLive() + } + } + } + } + cm.registerNetworkCallback(req, netCb!!) + } + + private fun unregisterNetworkCallback() { + val cb = netCb ?: return + val cm = getSystemService(ConnectivityManager::class.java) + cm.unregisterNetworkCallback(cb) + netCb = null + } + // --- /LIVE NETZSTATUS --- + + override fun onContentChanged() { + super.onContentChanged() + SystemBarInsets.applyToContentRoot(this, ensureMinContentPadding = true) + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + } + + fun startQuestionnaire(questionnaire: QuestionnaireBase<*>, languageID: String) { + isInQuestionnaire = true + isFirstQuestionnairePage = true + questionnaire.attach(this, languageID) + questionnaire.startQuestionnaire() + } + + fun finishQuestionnaire() { + isInQuestionnaire = false + isFirstQuestionnairePage = false + ViewUtils.setTextSizeScale(1f) + LanguageManager.clearQuestionnaireTranslations() + if (::openingScreenHandler.isInitialized) { + openingScreenHandler.init() + } + } +} diff --git a/app/src/main/java/com/dano/test1/MyApp.kt b/app/src/main/java/com/dano/test1/MyApp.kt new file mode 100644 index 0000000..50e30d3 --- /dev/null +++ b/app/src/main/java/com/dano/test1/MyApp.kt @@ -0,0 +1,52 @@ +package com.dano.test1 + +import android.app.Application +import android.util.Log +import androidx.appcompat.app.AppCompatDelegate +import androidx.room.Room +import androidx.room.RoomDatabase +import com.dano.test1.data.AppDatabase +import com.dano.test1.security.SensitiveDataCrypto + +/* +MyApp (Application) + - Einstiegspunkt der App, der einmal pro Prozessstart initialisiert wird. + +Besonderheiten der DB-Konfiguration: + - Name: "questionnaire_database" + - fallbackToDestructiveMigration(): + * Falls sich das Schema ändert und keine Migration vorliegt,wird die DB zerstört und neu angelegt. + - setJournalMode(TRUNCATE): + * Verwendet TRUNCATE-Journal (keine separaten -wal/-shm Dateien), es existiert nur die Hauptdatei „questionnaire_database“. + - Callback onOpen(): + * Loggt beim Öffnen der Datenbank einen Hinweis. +*/ +class MyApp : Application() { + + companion object { + lateinit var database: AppDatabase + private set + } + + override fun onCreate() { + super.onCreate() + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + SensitiveDataCrypto.init(this) + + // Room-Datenbank bauen: nur die Hauptdatei, ohne WAL und Journal + database = Room.databaseBuilder( + applicationContext, + AppDatabase::class.java, + "questionnaire_database" + ) + .fallbackToDestructiveMigration() + .setJournalMode(RoomDatabase.JournalMode.TRUNCATE) // TRUNCATE = keine -wal Datei, nur Hauptdatei + .addCallback(object : RoomDatabase.Callback() { + override fun onOpen(db: androidx.sqlite.db.SupportSQLiteDatabase) { + super.onOpen(db) + Log.d("DB", "Datenbank geöffnet. Nur questionnaire_database wird genutzt.") + } + }) + .build() + } +} diff --git a/app/src/main/java/com/dano/test1/data/AnswerRepository.kt b/app/src/main/java/com/dano/test1/data/AnswerRepository.kt new file mode 100644 index 0000000..a2e8372 --- /dev/null +++ b/app/src/main/java/com/dano/test1/data/AnswerRepository.kt @@ -0,0 +1,71 @@ +package com.dano.test1.data + +import com.dano.test1.MyApp +import com.dano.test1.security.SensitiveDataCrypto + +object AnswerRepository { + + private val answerDao get() = MyApp.database.answerDao() + + private fun key(clientCode: String) = SensitiveDataCrypto.clientCodeKey(clientCode) + + suspend fun getAnswersForClient(clientCode: String): List = + answerDao.getAnswersForClient(key(clientCode)).map { it.toDomain(clientCode) } + + /** + * Which (client, questionnaire) pairs already have answer rows — no Keystore decrypt. + * Used for bulk import pending-edit checks only. + */ + suspend fun localAnswerPresence(clientCodes: List): Set> { + if (clientCodes.isEmpty()) return emptySet() + val keyToCode = clientCodes.associateBy { key(it) } + return answerDao.listClientQuestionnairesWithAnswers(keyToCode.keys.toList()) + .mapNotNull { row -> + val code = keyToCode[row.clientCodeKey] ?: return@mapNotNull null + code to row.questionnaireId + } + .toSet() + } + + fun toEntities(answers: List): List = + answers.map { it.toEntity() } + + suspend fun deleteAnswersForClientAndQuestionnaires( + clientCode: String, + questionnaireIds: List, + ) { + if (questionnaireIds.isEmpty()) return + answerDao.deleteAnswersForClientAndQuestionnaires(key(clientCode), questionnaireIds) + } + + suspend fun getAnswersForClientAndQuestionnaire( + clientCode: String, + questionnaireId: String, + ): List = + answerDao.getAnswersForClientAndQuestionnaire(key(clientCode), questionnaireId) + .map { it.toDomain(clientCode) } + + suspend fun insertAnswers(answers: List) { + if (answers.isEmpty()) return + answerDao.insertAnswers(answers.map { it.toEntity() }) + } + + suspend fun deleteAnswersForClientAndQuestionnaire(clientCode: String, questionnaireId: String) { + answerDao.deleteAnswersForClientAndQuestionnaire(key(clientCode), questionnaireId) + } + + fun findAnswerValue(answers: List, roomQuestionId: String): String? = + answers.find { it.questionId == roomQuestionId }?.answerValue + + private fun Answer.toEntity() = AnswerEntity( + clientCodeKey = key(clientCode), + questionId = questionId, + answerValueEnc = SensitiveDataCrypto.encrypt(answerValue), + ) + + private fun AnswerEntity.toDomain(clientCode: String) = Answer( + clientCode = clientCode, + questionId = questionId, + answerValue = SensitiveDataCrypto.decrypt(answerValueEnc), + ) +} diff --git a/app/src/main/java/com/dano/test1/data/AppDatabase.kt b/app/src/main/java/com/dano/test1/data/AppDatabase.kt new file mode 100644 index 0000000..2ae527b --- /dev/null +++ b/app/src/main/java/com/dano/test1/data/AppDatabase.kt @@ -0,0 +1,38 @@ +package com.dano.test1.data + +import androidx.room.Database +import androidx.room.RoomDatabase + +/* +Zentrale Room-Datenbank der App. Diese Klasse beschreibt: + - welche Tabellen (entities) es gibt: Client, Questionnaire, Question, Answer, CompletedQuestionnaire + - die Datenbank-Version (version = 1) für Migrations/Schema-Updates + +Über die abstrakten DAO-Getter (clientDao(), questionnaireDao(), …) erhält der Rest der App Typsichere Zugriffe auf die jeweiligen Tabellen. + +Hinweis: + - Room erzeugt zur Build-Zeit die konkrete Implementierung dieser abstrakten Klasse. + - Eine Instanz der Datenbank wird typischerweise per Room.databaseBuilder(...) erstellt und als Singleton verwendet. + */ + +@Database( + entities = [ + Client::class, + Questionnaire::class, + Question::class, + AnswerEntity::class, + CompletedQuestionnaireEntity::class, + CoachScoringReviewEntity::class, + ], + version = 4, + exportSchema = false +) +abstract class AppDatabase : RoomDatabase() { + abstract fun clientDao(): ClientDao + abstract fun questionnaireDao(): QuestionnaireDao + abstract fun questionDao(): QuestionDao + abstract fun answerDao(): AnswerDao + abstract fun completedQuestionnaireDao(): CompletedQuestionnaireDao + abstract fun coachScoringReviewDao(): CoachScoringReviewDao +} + diff --git a/app/src/main/java/com/dano/test1/data/ClientAnswersCache.kt b/app/src/main/java/com/dano/test1/data/ClientAnswersCache.kt new file mode 100644 index 0000000..2b413bb --- /dev/null +++ b/app/src/main/java/com/dano/test1/data/ClientAnswersCache.kt @@ -0,0 +1,40 @@ +package com.dano.test1.data + +/** + * In-memory cache of answers for the active client to avoid repeated Room reads + * when evaluating questionnaire conditions (≤10 questionnaires per client). + */ +object ClientAnswersCache { + + private var cachedClient: String? = null + private val byQuestionnaire = mutableMapOf>() + private var allAnswers: List? = null + + suspend fun answersForQuestionnaire(clientCode: String, questionnaireId: String): List { + ensureClient(clientCode) + return byQuestionnaire.getOrPut(questionnaireId) { + AnswerRepository.getAnswersForClientAndQuestionnaire(clientCode, questionnaireId) + } + } + + suspend fun allForClient(clientCode: String): List { + ensureClient(clientCode) + return allAnswers ?: AnswerRepository.getAnswersForClient(clientCode).also { allAnswers = it } + } + + fun invalidate(clientCode: String? = null) { + if (clientCode == null || cachedClient == null || cachedClient.equals(clientCode, ignoreCase = true)) { + cachedClient = null + byQuestionnaire.clear() + allAnswers = null + } + } + + private fun ensureClient(clientCode: String) { + if (!cachedClient.equals(clientCode, ignoreCase = true)) { + cachedClient = clientCode + byQuestionnaire.clear() + allAnswers = null + } + } +} diff --git a/app/src/main/java/com/dano/test1/data/ClientRepository.kt b/app/src/main/java/com/dano/test1/data/ClientRepository.kt new file mode 100644 index 0000000..951d624 --- /dev/null +++ b/app/src/main/java/com/dano/test1/data/ClientRepository.kt @@ -0,0 +1,45 @@ +package com.dano.test1.data + +import com.dano.test1.MyApp +import com.dano.test1.security.SensitiveDataCrypto + +object ClientRepository { + + private val dao get() = MyApp.database.clientDao() + + suspend fun ensureClient(clientCode: String) { + val key = SensitiveDataCrypto.clientCodeKey(clientCode) + if (dao.getByKey(key) != null) return + dao.insertClient( + Client( + clientCodeKey = key, + clientCodeEnc = SensitiveDataCrypto.encrypt(clientCode.trim()), + ), + ) + } + + suspend fun exists(clientCode: String): Boolean = + dao.getByKey(SensitiveDataCrypto.clientCodeKey(clientCode)) != null + + suspend fun getAllClientCodes(): List = + dao.getAll().map { SensitiveDataCrypto.decrypt(it.clientCodeEnc) } + + suspend fun deleteAllClients() { + dao.deleteAllClients() + } + + suspend fun deleteClientsNotIn(clientCodes: List) { + val keys = clientCodes.map { SensitiveDataCrypto.clientCodeKey(it) }.distinct() + if (keys.isEmpty()) { + dao.deleteAllClients() + } else { + dao.deleteClientsNotIn(keys) + } + } + + suspend fun replaceClients(clientCodes: List) { + val codes = clientCodes.distinct() + deleteClientsNotIn(codes) + codes.forEach { ensureClient(it) } + } +} diff --git a/app/src/main/java/com/dano/test1/data/CoachScoringReviewRepository.kt b/app/src/main/java/com/dano/test1/data/CoachScoringReviewRepository.kt new file mode 100644 index 0000000..bbfa63b --- /dev/null +++ b/app/src/main/java/com/dano/test1/data/CoachScoringReviewRepository.kt @@ -0,0 +1,65 @@ +package com.dano.test1.data + +import com.dano.test1.MyApp +import com.dano.test1.security.SensitiveDataCrypto + +object CoachScoringReviewRepository { + + private val dao get() = MyApp.database.coachScoringReviewDao() + + private fun key(clientCode: String) = SensitiveDataCrypto.clientCodeKey(clientCode) + + suspend fun saveDecision( + clientCode: String, + profileID: String, + profileName: String, + coachBand: String, + calculatedBand: String, + weightedTotal: Double, + ) { + ClientRepository.ensureClient(clientCode) + val now = System.currentTimeMillis() + val existing = dao.getAllForClient(key(clientCode)).find { it.profileID == profileID } + dao.upsert( + CoachScoringReviewEntity( + clientCodeKey = key(clientCode), + profileID = profileID, + profileName = profileName, + coachBand = coachBand, + calculatedBand = calculatedBand, + weightedTotal = weightedTotal, + updatedAt = now, + uploadedAt = existing?.uploadedAt, + ), + ) + } + + suspend fun getAllForClient(clientCode: String): List = + dao.getAllForClient(key(clientCode)).map { it.toDomain(clientCode) } + + suspend fun getAllPendingUploads(): List { + val keyToCode = ClientRepository.getAllClientCodes() + .associateBy { SensitiveDataCrypto.clientCodeKey(it) } + return dao.getAllPendingUploads().mapNotNull { entity -> + val code = keyToCode[entity.clientCodeKey] ?: return@mapNotNull null + entity.toDomain(code) + } + } + + suspend fun countPendingUploads(): Int = dao.countPendingUploads() + + suspend fun markUploaded(clientCode: String, profileID: String, uploadedAt: Long) { + dao.markUploaded(key(clientCode), profileID, uploadedAt) + } + + private fun CoachScoringReviewEntity.toDomain(clientCode: String) = CoachScoringReview( + clientCode = clientCode, + profileID = profileID, + profileName = profileName, + coachBand = coachBand, + calculatedBand = calculatedBand, + weightedTotal = weightedTotal, + updatedAt = updatedAt, + uploadedAt = uploadedAt, + ) +} diff --git a/app/src/main/java/com/dano/test1/data/CompletedQuestionnaireRepository.kt b/app/src/main/java/com/dano/test1/data/CompletedQuestionnaireRepository.kt new file mode 100644 index 0000000..1a8a956 --- /dev/null +++ b/app/src/main/java/com/dano/test1/data/CompletedQuestionnaireRepository.kt @@ -0,0 +1,75 @@ +package com.dano.test1.data + +import com.dano.test1.MyApp +import com.dano.test1.security.SensitiveDataCrypto +object CompletedQuestionnaireRepository { + + private val dao get() = MyApp.database.completedQuestionnaireDao() + + private fun key(clientCode: String) = SensitiveDataCrypto.clientCodeKey(clientCode) + + suspend fun insert(entry: CompletedQuestionnaire) { + dao.insert(entry.toEntity()) + } + + suspend fun insertAll(entries: List) { + if (entries.isEmpty()) return + dao.insertAll(entries.map { it.toEntity() }) + } + + suspend fun getStatus(clientCode: String, questionnaireId: String): CompletedQuestionnaire? = + dao.getStatus(key(clientCode), questionnaireId)?.toDomain(clientCode) + + suspend fun getAllForClient(clientCode: String): List = + dao.getAllForClient(key(clientCode)).map { it.toDomain(clientCode) } + + /** One query for many clients (bulk answer import prep). */ + suspend fun getAllGroupedByClient(clientCodes: List): Map> { + if (clientCodes.isEmpty()) return emptyMap() + val keyToCode = clientCodes.associateBy { key(it) } + val grouped = mutableMapOf>() + for (entity in dao.getAllForClients(keyToCode.keys.toList())) { + val code = keyToCode[entity.clientCodeKey] ?: continue + grouped.getOrPut(code) { mutableListOf() }.add(entity.toDomain(code)) + } + return grouped + } + + suspend fun getCompletedQuestionnaireIds(clientCode: String): List = + dao.getCompletedQuestionnairesForClient(key(clientCode)) + + suspend fun getPendingUploadForClient(clientCode: String): List = + dao.getPendingUploadForClient(key(clientCode)).map { it.toDomain(clientCode) } + + suspend fun getAllPendingUploads(): List { + val keyToCode = ClientRepository.getAllClientCodes().associateBy { SensitiveDataCrypto.clientCodeKey(it) } + return dao.getAllPendingUploads().mapNotNull { entity -> + val code = keyToCode[entity.clientCodeKey] ?: return@mapNotNull null + entity.toDomain(code) + } + } + + suspend fun countPendingUploads(): Int = dao.countAllPendingUploads() + + suspend fun markUploaded(clientCode: String, questionnaireId: String, uploadedAt: Long) { + dao.markUploaded(key(clientCode), questionnaireId, uploadedAt) + } + + private fun CompletedQuestionnaire.toEntity() = CompletedQuestionnaireEntity( + clientCodeKey = key(clientCode), + questionnaireId = questionnaireId, + timestamp = timestamp, + isDone = isDone, + sumPoints = sumPoints, + uploadedAt = uploadedAt, + ) + + private fun CompletedQuestionnaireEntity.toDomain(clientCode: String) = CompletedQuestionnaire( + clientCode = clientCode, + questionnaireId = questionnaireId, + timestamp = timestamp, + isDone = isDone, + sumPoints = sumPoints, + uploadedAt = uploadedAt, + ) +} diff --git a/app/src/main/java/com/dano/test1/data/Daos.kt b/app/src/main/java/com/dano/test1/data/Daos.kt new file mode 100644 index 0000000..e54c1c4 --- /dev/null +++ b/app/src/main/java/com/dano/test1/data/Daos.kt @@ -0,0 +1,191 @@ +package com.dano.test1.data + +import androidx.room.* + +@Dao +interface ClientDao { + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertClient(client: Client) + + @Query("SELECT * FROM clients WHERE clientCodeKey = :key LIMIT 1") + suspend fun getByKey(key: String): Client? + + @Query("SELECT * FROM clients") + suspend fun getAll(): List + + @Query("DELETE FROM clients") + suspend fun deleteAllClients() + + @Query("DELETE FROM clients WHERE clientCodeKey NOT IN (:keep)") + suspend fun deleteClientsNotIn(keep: List) +} + +@Dao +interface QuestionnaireDao { + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertQuestionnaire(questionnaire: Questionnaire) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertQuestionnaires(questionnaires: List) + + @Query("SELECT * FROM questionnaires WHERE id = :id LIMIT 1") + suspend fun getById(id: String): Questionnaire? +} + +@Dao +interface QuestionDao { + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertQuestions(questions: List) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertQuestion(question: Question) + + @Query("SELECT * FROM questions WHERE questionId = :id LIMIT 1") + suspend fun getById(id: String): Question? + + @Query("SELECT * FROM questions WHERE questionnaireId = :questionnaireId") + suspend fun getQuestionsForQuestionnaire(questionnaireId: String): List +} + +data class ClientQuestionnaireKey( + val clientCodeKey: String, + val questionnaireId: String, +) + +@Dao +interface AnswerDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAnswers(answers: List) + + @Query(""" + SELECT a.* FROM answers a + INNER JOIN questions q ON a.questionId = q.questionId + WHERE a.clientCodeKey = :clientCodeKey AND q.questionnaireId = :questionnaireId + """) + suspend fun getAnswersForClientAndQuestionnaire( + clientCodeKey: String, + questionnaireId: String, + ): List + + @Query("SELECT * FROM answers WHERE clientCodeKey = :clientCodeKey") + suspend fun getAnswersForClient(clientCodeKey: String): List + + @Query("SELECT * FROM answers WHERE clientCodeKey IN (:clientCodeKeys)") + suspend fun getAnswersForClients(clientCodeKeys: List): List + + /** Existence only — no decryption (bulk import pending-edit check). */ + @Query( + """ + SELECT DISTINCT a.clientCodeKey, q.questionnaireId + FROM answers a + INNER JOIN questions q ON a.questionId = q.questionId + WHERE a.clientCodeKey IN (:clientCodeKeys) + """, + ) + suspend fun listClientQuestionnairesWithAnswers( + clientCodeKeys: List, + ): List + + @Query(""" + DELETE FROM answers + WHERE clientCodeKey = :clientCodeKey + AND questionId IN ( + SELECT questionId FROM questions WHERE questionnaireId = :questionnaireId + ) + """) + suspend fun deleteAnswersForClientAndQuestionnaire( + clientCodeKey: String, + questionnaireId: String, + ) + + @Query(""" + DELETE FROM answers + WHERE clientCodeKey = :clientCodeKey + AND questionId IN ( + SELECT questionId FROM questions WHERE questionnaireId IN (:questionnaireIds) + ) + """) + suspend fun deleteAnswersForClientAndQuestionnaires( + clientCodeKey: String, + questionnaireIds: List, + ) +} + +@Dao +interface CompletedQuestionnaireDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(entry: CompletedQuestionnaireEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(entries: List) + + @Query( + "SELECT * FROM completed_questionnaires WHERE clientCodeKey = :clientCodeKey " + + "AND questionnaireId = :questionnaireId LIMIT 1", + ) + suspend fun getStatus(clientCodeKey: String, questionnaireId: String): CompletedQuestionnaireEntity? + + @Query("SELECT * FROM completed_questionnaires WHERE clientCodeKey = :clientCodeKey") + suspend fun getAllForClient(clientCodeKey: String): List + + @Query("SELECT * FROM completed_questionnaires WHERE clientCodeKey IN (:clientCodeKeys)") + suspend fun getAllForClients(clientCodeKeys: List): List + + @Query("SELECT questionnaireId FROM completed_questionnaires WHERE clientCodeKey = :clientCodeKey") + suspend fun getCompletedQuestionnairesForClient(clientCodeKey: String): List + + @Query( + "UPDATE completed_questionnaires SET uploadedAt = :uploadedAt " + + "WHERE clientCodeKey = :clientCodeKey AND questionnaireId = :questionnaireId", + ) + suspend fun markUploaded(clientCodeKey: String, questionnaireId: String, uploadedAt: Long) + + @Query( + "SELECT * FROM completed_questionnaires WHERE clientCodeKey = :clientCodeKey AND isDone = 1 " + + "AND (uploadedAt IS NULL OR uploadedAt < timestamp)", + ) + suspend fun getPendingUploadForClient(clientCodeKey: String): List + + @Query( + "SELECT * FROM completed_questionnaires WHERE isDone = 1 " + + "AND (uploadedAt IS NULL OR uploadedAt < timestamp)", + ) + suspend fun getAllPendingUploads(): List + + @Query( + "SELECT COUNT(*) FROM completed_questionnaires WHERE isDone = 1 " + + "AND (uploadedAt IS NULL OR uploadedAt < timestamp)", + ) + suspend fun countAllPendingUploads(): Int +} + +@Dao +interface CoachScoringReviewDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(entry: CoachScoringReviewEntity) + + @Query("SELECT * FROM coach_scoring_reviews WHERE clientCodeKey = :clientCodeKey") + suspend fun getAllForClient(clientCodeKey: String): List + + @Query( + "SELECT * FROM coach_scoring_reviews WHERE uploadedAt IS NULL OR uploadedAt < updatedAt", + ) + suspend fun getAllPendingUploads(): List + + @Query( + "SELECT COUNT(*) FROM coach_scoring_reviews WHERE uploadedAt IS NULL OR uploadedAt < updatedAt", + ) + suspend fun countPendingUploads(): Int + + @Query( + "UPDATE coach_scoring_reviews SET uploadedAt = :uploadedAt " + + "WHERE clientCodeKey = :clientCodeKey AND profileID = :profileID", + ) + suspend fun markUploaded(clientCodeKey: String, profileID: String, uploadedAt: Long) +} diff --git a/app/src/main/java/com/dano/test1/data/Entities.kt b/app/src/main/java/com/dano/test1/data/Entities.kt new file mode 100644 index 0000000..4126711 --- /dev/null +++ b/app/src/main/java/com/dano/test1/data/Entities.kt @@ -0,0 +1,139 @@ +package com.dano.test1.data + +import androidx.room.* + +@Entity(tableName = "clients") +data class Client( + @PrimaryKey val clientCodeKey: String, + val clientCodeEnc: String, +) + +@Entity(tableName = "questionnaires") +data class Questionnaire( + @PrimaryKey val id: String, +) + +@Entity( + tableName = "questions", + foreignKeys = [ + ForeignKey( + entity = Questionnaire::class, + parentColumns = ["id"], + childColumns = ["questionnaireId"], + onDelete = ForeignKey.CASCADE, + ) + ], + indices = [Index("questionnaireId")], +) +data class Question( + @PrimaryKey val questionId: String, + val questionnaireId: String, + val question: String = "", +) + +@Entity( + tableName = "answers", + primaryKeys = ["clientCodeKey", "questionId"], + foreignKeys = [ + ForeignKey( + entity = Client::class, + parentColumns = ["clientCodeKey"], + childColumns = ["clientCodeKey"], + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = Question::class, + parentColumns = ["questionId"], + childColumns = ["questionId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("clientCodeKey"), Index("questionId")], +) +data class AnswerEntity( + val clientCodeKey: String, + val questionId: String, + val answerValueEnc: String = "", +) + +/** Plaintext answer row used by app code (never persisted without encryption). */ +data class Answer( + val clientCode: String, + val questionId: String, + val answerValue: String = "", +) + +@Entity( + tableName = "completed_questionnaires", + primaryKeys = ["clientCodeKey", "questionnaireId"], + foreignKeys = [ + ForeignKey( + entity = Client::class, + parentColumns = ["clientCodeKey"], + childColumns = ["clientCodeKey"], + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = Questionnaire::class, + parentColumns = ["id"], + childColumns = ["questionnaireId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("clientCodeKey"), Index("questionnaireId")], +) +data class CompletedQuestionnaireEntity( + val clientCodeKey: String, + val questionnaireId: String, + val timestamp: Long = System.currentTimeMillis(), + val isDone: Boolean, + val sumPoints: Int? = null, + val uploadedAt: Long? = null, +) + +data class CompletedQuestionnaire( + val clientCode: String, + val questionnaireId: String, + val timestamp: Long = System.currentTimeMillis(), + val isDone: Boolean, + val sumPoints: Int? = null, + val uploadedAt: Long? = null, +) + +@Entity( + tableName = "coach_scoring_reviews", + primaryKeys = ["clientCodeKey", "profileID"], + foreignKeys = [ + ForeignKey( + entity = Client::class, + parentColumns = ["clientCodeKey"], + childColumns = ["clientCodeKey"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("clientCodeKey")], +) +data class CoachScoringReviewEntity( + val clientCodeKey: String, + val profileID: String, + val profileName: String = "", + val coachBand: String, + val calculatedBand: String, + val weightedTotal: Double, + val updatedAt: Long = System.currentTimeMillis(), + val uploadedAt: Long? = null, +) + +data class CoachScoringReview( + val clientCode: String, + val profileID: String, + val profileName: String = "", + val coachBand: String, + val calculatedBand: String, + val weightedTotal: Double, + val updatedAt: Long = System.currentTimeMillis(), + val uploadedAt: Long? = null, +) { + val isPendingUpload: Boolean + get() = uploadedAt == null || uploadedAt < updatedAt +} diff --git a/app/src/main/java/com/dano/test1/data/PendingUploadRepository.kt b/app/src/main/java/com/dano/test1/data/PendingUploadRepository.kt new file mode 100644 index 0000000..44c27e5 --- /dev/null +++ b/app/src/main/java/com/dano/test1/data/PendingUploadRepository.kt @@ -0,0 +1,10 @@ +package com.dano.test1.data + +object PendingUploadRepository { + + suspend fun countAll(): Int = + CompletedQuestionnaireRepository.countPendingUploads() + + CoachScoringReviewRepository.countPendingUploads() + + suspend fun hasAny(): Boolean = countAll() > 0 +} diff --git a/app/src/main/java/com/dano/test1/data/UnuploadedWorkRepository.kt b/app/src/main/java/com/dano/test1/data/UnuploadedWorkRepository.kt new file mode 100644 index 0000000..7752a46 --- /dev/null +++ b/app/src/main/java/com/dano/test1/data/UnuploadedWorkRepository.kt @@ -0,0 +1,36 @@ +package com.dano.test1.data + +/** + * Summarises local interview data that must be uploaded (or finished) before logout. + */ +object UnuploadedWorkRepository { + + data class Summary( + val pendingQuestionnaires: Int, + val pendingScoringReviews: Int, + val inProgressQuestionnaires: Int, + ) { + val hasBlockingWork: Boolean + get() = pendingQuestionnaires > 0 || pendingScoringReviews > 0 || inProgressQuestionnaires > 0 + } + + suspend fun summarize(): Summary { + val codes = ClientRepository.getAllClientCodes() + return Summary( + pendingQuestionnaires = CompletedQuestionnaireRepository.countPendingUploads(), + pendingScoringReviews = CoachScoringReviewRepository.countPendingUploads(), + inProgressQuestionnaires = countInProgressQuestionnaires(codes), + ) + } + + private suspend fun countInProgressQuestionnaires(clientCodes: List): Int { + if (clientCodes.isEmpty()) return 0 + val pairs = AnswerRepository.localAnswerPresence(clientCodes) + var count = 0 + for ((clientCode, questionnaireId) in pairs) { + val status = CompletedQuestionnaireRepository.getStatus(clientCode, questionnaireId) + if (status == null || !status.isDone) count++ + } + return count + } +} diff --git a/app/src/main/java/com/dano/test1/network/ApiModels.kt b/app/src/main/java/com/dano/test1/network/ApiModels.kt new file mode 100644 index 0000000..e2d871a --- /dev/null +++ b/app/src/main/java/com/dano/test1/network/ApiModels.kt @@ -0,0 +1,141 @@ +package com.dano.test1.network + +data class ApiError( + val code: String? = null, + val message: String = "" +) + +data class LoginResult( + val token: String, + val user: String, + val role: String? = null, + val mustChangePassword: Boolean = false, + val assignedClientCodes: List = emptyList() +) + +data class SessionInfo( + val valid: Boolean, + val user: String = "", + val role: String = "", + val mustChangePassword: Boolean = false, +) + +data class QuestionnaireListItem( + val id: String, + val name: String, + val showPoints: Boolean = false, + /** Raw JSON from API `condition`; cached for offline availability rules. */ + val conditionJson: String = "{}", + /** Optional opening-screen group key (label via app UI strings). */ + val categoryKey: String? = null, + /** Server structure revision; bump when questions/options change structurally. */ + val structureRevision: Int = 1, +) + +/** Server payload for one completed questionnaire (GET ?answers=1). */ +data class ServerQuestionnaireAnswers( + val questionnaireID: String, + val completedAt: Long? = null, + val sumPoints: Int = 0, + val answers: List = emptyList(), +) + +data class ClientAnswersBundle( + val clientCode: String, + val questionnaires: List = emptyList(), +) + +data class SubmitQuestionnaireRequest( + val questionnaireID: String, + val clientCode: String, + val startedAt: Long? = null, + val completedAt: Long? = null, + val structureRevision: Int? = null, + val answers: List +) + +data class SubmitAnswer( + val questionID: String, + val answerOptionKey: String? = null, + val freeTextValue: String? = null, + val numericValue: Double? = null, + val answeredAt: Long? = null +) + +data class SubmitQuestionnaireResponse( + val submitted: Boolean = false, + val sumPoints: Int = 0, + val structureRevision: Int? = null, + val legacySubmit: Boolean = false, +) + +/** One questionnaire already completed on the server side. */ +data class ServerCompletedQuestionnaire( + val questionnaireID: String, + val sumPoints: Int = 0, + /** Unix seconds (as returned by server). */ + val completedAt: Long = 0L +) + +/** A client with its server-side completion records. */ +data class AssignedClientInfo( + val clientCode: String, + val completedQuestionnaires: List = emptyList() +) + +data class ScoringProfileMember( + val questionnaireID: String, + val weight: Double = 1.0, + val orderIndex: Int = 0, + val name: String = "", +) + +data class ScoringProfileDefinition( + val profileID: String, + val name: String, + val description: String = "", + val isActive: Boolean = true, + val greenMin: Int = 0, + val greenMax: Int = 12, + val yellowMin: Int = 13, + val yellowMax: Int = 36, + val redMin: Int = 37, + val questionnaires: List = emptyList(), +) + +data class ScoringReviewProfile( + val profileID: String, + val name: String, + val weightedTotal: Double = 0.0, + val calculatedBand: String = "", + val greenMin: Int = 0, + val greenMax: Int = 12, + val yellowMin: Int = 13, + val yellowMax: Int = 36, + val redMin: Int = 37, + val coachBand: String? = null, + val pendingReview: Boolean = true, + /** Coach decision saved on device but not uploaded yet. */ + val pendingUpload: Boolean = false, +) + +data class ClientScoringReview( + val clientCode: String, + val profiles: List = emptyList(), +) + +data class CoachScoringReviewRequest( + val action: String = "coachScoringReview", + val clientCode: String, + val profileID: String, + val coachBand: String, + val calculatedBand: String, + val weightedTotal: Double, +) + +class ApiException( + message: String, + val code: String? = null, + /** Server `error.details` (e.g. validation errors list). */ + val details: com.google.gson.JsonElement? = null, +) : Exception(message) diff --git a/app/src/main/java/com/dano/test1/network/ClientAnswerSyncStore.kt b/app/src/main/java/com/dano/test1/network/ClientAnswerSyncStore.kt new file mode 100644 index 0000000..b8b8540 --- /dev/null +++ b/app/src/main/java/com/dano/test1/network/ClientAnswerSyncStore.kt @@ -0,0 +1,48 @@ +package com.dano.test1.network + +import android.content.Context +import com.dano.test1.security.SensitiveDataCrypto + +/** Persists per-client answer sync timestamps (survives process restarts). */ +internal object ClientAnswerSyncStore { + private const val PREFS = "client_answer_sync_v1" + + fun lastPullMs(context: Context, clientCode: String): Long { + val key = normalize(clientCode) ?: return 0L + return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getLong(key, 0L) + } + + fun markPulled(context: Context, clientCode: String, atMs: Long = System.currentTimeMillis()) { + val key = normalize(clientCode) ?: return + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit() + .putLong(key, atMs) + .apply() + } + + fun markPulledBatch(context: Context, clientCodes: List, atMs: Long = System.currentTimeMillis()) { + if (clientCodes.isEmpty()) return + val edit = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + for (code in clientCodes) { + val key = normalize(code) ?: continue + edit.putLong(key, atMs) + } + edit.apply() + } + + fun clear(context: Context, clientCode: String? = null) { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + if (clientCode.isNullOrBlank()) { + prefs.edit().clear().apply() + } else { + normalize(clientCode)?.let { prefs.edit().remove(it).apply() } + prefs.edit().remove(clientCode.trim().uppercase()).apply() + } + } + + private fun normalize(clientCode: String): String? = + clientCode.trim() + .takeIf { it.isNotBlank() } + ?.uppercase() + ?.let { SensitiveDataCrypto.clientCodeKey(it) } +} diff --git a/app/src/main/java/com/dano/test1/network/DatabaseUploader.kt b/app/src/main/java/com/dano/test1/network/DatabaseUploader.kt new file mode 100644 index 0000000..2770bb7 --- /dev/null +++ b/app/src/main/java/com/dano/test1/network/DatabaseUploader.kt @@ -0,0 +1,221 @@ +package com.dano.test1.network + +import android.content.Context +import com.dano.test1.LanguageManager +import com.dano.test1.notification.UploadReminderScheduler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +object DatabaseUploader { + + data class UploadResult( + val submittedQuestionnaires: Int, + val submittedScoringReviews: Int, + val skipped: Int, + val errors: List, + val revisionNotices: List = emptyList(), + ) { + val submitted get() = submittedQuestionnaires + submittedScoringReviews + val hasErrors get() = errors.isNotEmpty() + val allFailed get() = submitted == 0 && errors.isNotEmpty() + val needsReLogin get() = errors.any { it.isAuthError } + /** Sync/upload ran but nothing reached the server (no pending rows or all skipped). */ + val nothingUploaded get() = submitted == 0 && !hasErrors + } + + data class UploadError( + val clientCode: String, + val questionnaireId: String = "", + val profileID: String = "", + val profileName: String = "", + val message: String, + /** Non-null when the server rejected with a specific error code (e.g. NOT_FOUND, 403). */ + val serverCode: String? = null, + val isAuthError: Boolean = false, + ) + + /** + * Uploads all pending (completed but not yet uploaded, or re-edited after last upload) + * questionnaires for all assigned clients. Reports results via [onResult] on the main thread. + */ + fun uploadDatabaseWithToken( + context: Context, + token: String, + languageId: String = "GERMAN", + onProgress: ((done: Int, total: Int) -> Unit)? = null, + onResult: (UploadResult) -> Unit, + ) { + CoroutineScope(Dispatchers.IO).launch { + val allPending = com.dano.test1.data.CompletedQuestionnaireRepository + .getAllPendingUploads() + val pendingScoring = com.dano.test1.data.CoachScoringReviewRepository + .getAllPendingUploads() + if (allPending.isEmpty() && pendingScoring.isEmpty()) { + val assigned = QuestionnaireCache.getAssignedClients(context) + if (assigned.isEmpty()) { + withContext(Dispatchers.Main) { + onResult( + UploadResult( + submittedQuestionnaires = 0, + submittedScoringReviews = 0, + skipped = 0, + errors = listOf( + UploadError( + clientCode = "", + message = LanguageManager.getText(languageId, "no_profile"), + ), + ), + ), + ) + } + } else { + withContext(Dispatchers.Main) { + onResult( + UploadResult( + submittedQuestionnaires = 0, + submittedScoringReviews = 0, + skipped = 0, + errors = emptyList(), + ), + ) + } + } + return@launch + } + + var submittedQuestionnaires = 0 + var submittedScoringReviews = 0 + var skipped = 0 + val errors = mutableListOf() + val revisionNotices = mutableListOf() + val total = allPending.size + pendingScoring.size + var processed = 0 + + withContext(Dispatchers.Main) { onProgress?.invoke(0, total) } + + allPending.forEach { entry -> + val clientCode = entry.clientCode + val request = runCatching { + QuestionnaireSubmitMapper.buildRequest( + context = context, + clientCode = clientCode, + questionnaireId = entry.questionnaireId + ) + }.getOrNull() + + if (request == null) { + skipped++ + SyncLog.w("UPLOAD", "Skipped ${entry.questionnaireId} for ${SyncLog.clientRef(clientCode)}: buildRequest returned null") + } else { + runCatching { + QuestionnaireApiClient.submitQuestionnaire(token, request) + }.onSuccess { response -> + submittedQuestionnaires++ + com.dano.test1.data.CompletedQuestionnaireRepository + .markUploaded(clientCode, entry.questionnaireId, System.currentTimeMillis()) + if (response.legacySubmit) { + val submitRev = response.structureRevision + ?: request.structureRevision + ?: 1 + val serverRev = QuestionnaireCache.getQuestionnaireList(context) + .find { it.id == entry.questionnaireId } + ?.structureRevision + ?: submitRev + revisionNotices += "Uploaded ${entry.questionnaireId} using form revision $submitRev; server is on $serverRev." + } + SyncLog.d("UPLOAD", "Submitted ${entry.questionnaireId} for ${SyncLog.clientRef(clientCode)}") + }.onFailure { e -> + val apiEx = e as? ApiException + val serverCode = apiEx?.code + val authErr = TokenStore.isAuthError(e) + val displayMsg = when (apiEx) { + null -> e.message ?: "Unknown error" + else -> QuestionnaireApiClient.formatErrorMessage(apiEx) + } + errors += UploadError( + clientCode = clientCode, + questionnaireId = entry.questionnaireId, + message = displayMsg, + serverCode = serverCode, + isAuthError = authErr, + ) + SyncLog.e("UPLOAD", "Failed ${entry.questionnaireId} for ${SyncLog.clientRef(clientCode)}: ${e.message}", e) + } + } + processed++ + withContext(Dispatchers.Main) { onProgress?.invoke(processed, total) } + } + + pendingScoring.forEach { review -> + runCatching { + QuestionnaireApiClient.submitCoachScoringReview( + token, + CoachScoringReviewRequest( + clientCode = review.clientCode, + profileID = review.profileID, + coachBand = review.coachBand, + calculatedBand = review.calculatedBand, + weightedTotal = review.weightedTotal, + ), + ) + }.onSuccess { + submittedScoringReviews++ + com.dano.test1.data.CoachScoringReviewRepository.markUploaded( + review.clientCode, + review.profileID, + System.currentTimeMillis(), + ) + SyncLog.d("UPLOAD", "Submitted scoring review ${review.profileID} for ${SyncLog.clientRef(review.clientCode)}") + }.onFailure { e -> + val apiEx = e as? ApiException + val displayMsg = when (apiEx) { + null -> e.message ?: "Unknown error" + else -> QuestionnaireApiClient.formatErrorMessage(apiEx) + } + errors += UploadError( + clientCode = review.clientCode, + profileID = review.profileID, + profileName = review.profileName, + message = displayMsg, + serverCode = apiEx?.code, + isAuthError = TokenStore.isAuthError(e), + ) + SyncLog.e("UPLOAD", "Failed scoring ${review.profileID} for ${SyncLog.clientRef(review.clientCode)}: ${e.message}", e) + } + processed++ + withContext(Dispatchers.Main) { onProgress?.invoke(processed, total) } + } + + if (!errors.any() && (submittedQuestionnaires + submittedScoringReviews) > 0) { + val stillPending = UploadReminderScheduler.hasPendingUploads() + 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( + submittedQuestionnaires = submittedQuestionnaires, + submittedScoringReviews = submittedScoringReviews, + skipped = skipped, + errors = errors, + revisionNotices = revisionNotices, + ), + ) + } + } + } +} diff --git a/app/src/main/java/com/dano/test1/network/HttpModule.kt b/app/src/main/java/com/dano/test1/network/HttpModule.kt new file mode 100644 index 0000000..ac56504 --- /dev/null +++ b/app/src/main/java/com/dano/test1/network/HttpModule.kt @@ -0,0 +1,22 @@ +package com.dano.test1.network + +import okhttp3.Dispatcher +import okhttp3.OkHttpClient +import java.util.concurrent.TimeUnit + +object HttpModule { + const val BASE_URL = "http://49.13.157.44/nat-as-server/api" + + val client: OkHttpClient by lazy { + val dispatcher = Dispatcher().apply { + maxRequests = 24 + maxRequestsPerHost = 8 + } + OkHttpClient.Builder() + .dispatcher(dispatcher) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .build() + } +} diff --git a/app/src/main/java/com/dano/test1/network/LoginManager.kt b/app/src/main/java/com/dano/test1/network/LoginManager.kt new file mode 100644 index 0000000..40b7e14 --- /dev/null +++ b/app/src/main/java/com/dano/test1/network/LoginManager.kt @@ -0,0 +1,86 @@ +package com.dano.test1.network + +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +object LoginManager { + + suspend fun login( + context: Context, + username: String, + password: String, + onMustChangePassword: suspend (tempToken: String, username: String) -> Unit, + onSuccess: suspend (token: String, username: String, assignedClients: List) -> Unit, + onError: suspend (String) -> Unit + ) { + try { + val result = withContext(Dispatchers.IO) { + QuestionnaireApiClient.login(username, password) + } + + if (result.mustChangePassword) { + onMustChangePassword(result.token, result.user.ifBlank { username }) + return + } + + val user = result.user.ifBlank { username } + onSuccess(result.token, user, result.assignedClientCodes) + } catch (e: Exception) { + SyncLog.e("LOGIN", "login failed", e) + onError(e.message ?: "Login failed") + } + } + + suspend fun changePassword( + context: Context, + token: String, + username: String, + oldPassword: String, + newPassword: String, + onSuccess: suspend (token: String, username: String, assignedClients: List) -> Unit, + onError: suspend (String) -> Unit + ) { + try { + val result = withContext(Dispatchers.IO) { + QuestionnaireApiClient.changePassword(token, username, oldPassword, newPassword) + } + val user = result.user.ifBlank { username } + onSuccess(result.token, user, result.assignedClientCodes) + } catch (e: Exception) { + SyncLog.e("LOGIN", "changePassword failed", e) + onError(e.message ?: "Password change failed") + } + } + + /** @deprecated Use [login] from [AuthActivity] instead. */ + fun loginUserWithCredentials( + context: Context, + username: String, + password: String, + onSuccess: (String) -> Unit, + onError: (String) -> Unit + ) { + CoroutineScope(Dispatchers.Main).launch { + login( + context = context, + username = username, + password = password, + onMustChangePassword = { _, _ -> + onError("Password change required. Please use the login screen.") + }, + onSuccess = { token, user, clients -> + withContext(Dispatchers.IO) { + QuestionnaireCache.clearAllClientData(context) + TokenStore.save(context, token, user) + QuestionnaireCache.replaceAssignedClients(context, clients) + } + onSuccess(token) + }, + onError = onError + ) + } + } +} diff --git a/app/src/main/java/com/dano/test1/network/NetworkUtilis.kt b/app/src/main/java/com/dano/test1/network/NetworkUtilis.kt new file mode 100644 index 0000000..0a2bb0e --- /dev/null +++ b/app/src/main/java/com/dano/test1/network/NetworkUtilis.kt @@ -0,0 +1,33 @@ +package com.dano.test1.network + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities + +/* +Zweck: +- Einfache Hilfsklasse, um den aktuellen Online-Status des Geräts zu prüfen. + +Funktionsweise: +- `isOnline(context)` nutzt den systemweiten `ConnectivityManager`, fragt die aktive Verbindung (`activeNetwork`) ab und prüft deren `NetworkCapabilities`. +- Es wird nur dann `true` zurückgegeben, wenn: + * eine aktive Verbindung existiert und + * die Verbindung die Fähigkeit „INTERNET“ besitzt und + * die Verbindung als „VALIDATED“ gilt (vom System als funktionsfähig verifiziert). + +Verwendung: +- Vo Netzwerkaufrufen (Login, Upload, Download) aufrufen, um „Offline“-Fälle frühzeitig abzufangen und nutzerfreundliche Meldungen zu zeigen. +*/ +object NetworkUtils { + fun isOnline(context: Context): Boolean { + return try { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager? ?: return false + val network = cm.activeNetwork ?: return false + val caps = cm.getNetworkCapabilities(network) ?: return false + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } catch (_: SecurityException) { + false + } + } +} diff --git a/app/src/main/java/com/dano/test1/network/QuestionnaireAnswerSync.kt b/app/src/main/java/com/dano/test1/network/QuestionnaireAnswerSync.kt new file mode 100644 index 0000000..c4d600e --- /dev/null +++ b/app/src/main/java/com/dano/test1/network/QuestionnaireAnswerSync.kt @@ -0,0 +1,472 @@ +package com.dano.test1.network + +import android.content.Context +import com.dano.test1.MyApp +import com.dano.test1.data.AnswerEntity +import com.dano.test1.data.AnswerRepository +import com.dano.test1.data.CompletedQuestionnaire +import com.dano.test1.data.CompletedQuestionnaireRepository +import com.dano.test1.data.Question +import com.dano.test1.data.Questionnaire +import com.dano.test1.questionnaire.AnswerKeyUtils +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import androidx.room.withTransaction +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext + +object QuestionnaireAnswerSync { + private const val TAG = "AnswerSync" + /** Parallel HTTP downloads during per-client answer fallback. */ + private const val CLIENT_FETCH_PARALLELISM = 6 + /** Room/SQLite allows one writer — serialize imports to avoid lock contention. */ + private val dbImportGate = Semaphore(1) + /** Re-download on client switch only if last successful sync is older than this. */ + const val CLIENT_PULL_STALE_MS = 3_600_000L + + private val sessionPullAtMs = mutableMapOf() + + private data class PreparedBlockImport( + val questionnaireId: String, + val questions: List, + val answerEntities: List, + val completion: CompletedQuestionnaire, + ) + + private data class PreparedClientImport( + val clientCode: String, + val blocks: List, + ) + + private data class DetailCaches( + val parsed: Map, + val rawJson: Map, + ) + + /** + * Pulls server answers for assigned clients (offline facility prep). + * When [force] is false, only clients not synced within [CLIENT_PULL_STALE_MS] are fetched. + * Pass [prefetchedBundles] when bulk HTTP was started earlier (e.g. parallel with detail fetch). + */ + suspend fun pullForAllAssignedClients( + context: Context, + token: String, + clientCodes: List? = null, + force: Boolean = false, + prefetchedBundles: List? = null, + pacer: SyncProgressPacer? = null, + ): Int { + if (!NetworkUtils.isOnline(context)) { + SyncLog.d(TAG, "offline — skip answer pull for all clients") + return 0 + } + val codes = (clientCodes ?: QuestionnaireCache.getAssignedClients(context)) + .filter { it.isNotBlank() } + .distinct() + if (codes.isEmpty()) { + SyncLog.d(TAG, "no assigned clients — skip answer pull") + return 0 + } + val toPull = clientsNeedingPull(context, codes, force) + if (toPull.isEmpty()) { + SyncLog.d(TAG, "all ${codes.size} clients fresh (synced within 1h)") + return codes.size + } + SyncLog.d(TAG, "pulling answers for ${toPull.size}/${codes.size} clients (force=$force)") + pullClientsParallel(context, token, toPull, prefetchedBundles, pacer) + return toPull.size + } + + fun clientsNeedingPull(context: Context, codes: List, force: Boolean): List = + if (force) codes else codes.filter { shouldPull(context, it) } + + /** + * Pulls answers for one client when selected on the opening screen. + * Skips network if synced within [CLIENT_PULL_STALE_MS] unless [force] is true. + */ + suspend fun pullForClient( + context: Context, + token: String, + clientCode: String, + force: Boolean = false, + ): Boolean { + if (!NetworkUtils.isOnline(context)) { + SyncLog.d(TAG, "offline — skip answer pull for ${SyncLog.clientRef(clientCode)}") + return false + } + val code = clientCode.trim() + if (code.isBlank()) return false + if (!force && !shouldPull(context, code)) { + SyncLog.d(TAG, "skip answer pull for ${SyncLog.clientRef(code)} (synced within 1h)") + return true + } + return runCatching { + val bundle = QuestionnaireApiClient.fetchClientAnswers(token, code) + importClientBundle(context, code, bundle.questionnaires) + markPulled(context, code) + true + }.onFailure { e -> + SyncLog.w(TAG, "answer pull failed for ${SyncLog.clientRef(code)}: ${e.message}") + }.getOrDefault(false) + } + + fun invalidatePullCache(context: Context, clientCode: String? = null) { + ClientAnswerSyncStore.clear(context, clientCode) + if (clientCode.isNullOrBlank()) { + sessionPullAtMs.clear() + } else { + sessionPullAtMs.remove(clientCode.trim().uppercase()) + } + } + + fun needsPull(context: Context, clientCode: String): Boolean = + shouldPull(context, clientCode.trim()) + + private fun shouldPull(context: Context, clientCode: String): Boolean { + val key = clientCode.trim().uppercase() + val last = maxOf( + sessionPullAtMs[key] ?: 0L, + ClientAnswerSyncStore.lastPullMs(context, key), + ) + if (last <= 0L) return true + return System.currentTimeMillis() - last > CLIENT_PULL_STALE_MS + } + + private fun markPulled(context: Context, clientCode: String) { + val now = System.currentTimeMillis() + val key = clientCode.trim().uppercase() + sessionPullAtMs[key] = now + ClientAnswerSyncStore.markPulled(context, clientCode, now) + } + + /** + * Bulk HTTP when possible; one Room transaction per chunk. Falls back to parallel per-client fetches. + */ + private suspend fun pullClientsParallel( + context: Context, + token: String, + codes: List, + prefetchedBundles: List?, + pacer: SyncProgressPacer? = null, + ): Int { + if (!NetworkUtils.isOnline(context)) { + SyncLog.d(TAG, "offline — skip answer pull") + return 0 + } + if (prefetchedBundles != null) { + return importBulkBundles(context, codes, prefetchedBundles, pacer) + } + pacer?.emit(SyncPhase.DOWNLOADING_ANSWERS) + val bulkResult = runCatching { + withContext(Dispatchers.IO) { + QuestionnaireApiClient.fetchBulkClientAnswers(token) + } + } + if (bulkResult.isSuccess) { + return importBulkBundles(context, codes, bulkResult.getOrDefault(emptyList()), pacer) + } + SyncLog.w(TAG, "bulk answer pull failed, falling back to per-client: ${bulkResult.exceptionOrNull()?.message}") + return pullClientsParallelFallback(context, token, codes, pacer) + } + + private suspend fun importBulkBundles( + context: Context, + codes: List, + bundles: List, + pacer: SyncProgressPacer?, + ): Int { + val byCode = bundles.associateBy { it.clientCode.trim().uppercase() } + val imported = runCatching { + importManyClients( + context = context, + codes = codes, + blocksByCode = { code -> byCode[code.trim().uppercase()]?.questionnaires.orEmpty() }, + pacer = pacer, + ) + ClientAnswerSyncStore.markPulledBatch(context, codes) + codes.size + }.getOrElse { + SyncLog.w(TAG, "bulk import failed: ${it.message}") + 0 + } + SyncLog.d(TAG, "bulk answer pull finished: $imported/${codes.size} clients") + return imported + } + + private suspend fun pullClientsParallelFallback( + context: Context, + token: String, + codes: List, + pacer: SyncProgressPacer? = null, + ): Int = coroutineScope { + pacer?.emit(SyncPhase.DOWNLOADING_ANSWERS) + val fetchGate = Semaphore(CLIENT_FETCH_PARALLELISM) + + data class Fetched(val code: String, val blocks: List) + + val fetched = codes.map { code -> + async(Dispatchers.IO) { + fetchGate.withPermit { + runCatching { + val bundle = QuestionnaireApiClient.fetchClientAnswers(token, code) + Fetched(code, bundle.questionnaires) + }.getOrElse { + SyncLog.w(TAG, "fetch failed for ${SyncLog.clientRef(code)}: ${it.message}") + null + } + } + } + }.awaitAll().filterNotNull() + + val fetchedByCode = fetched.associate { it.code.trim().uppercase() to it.blocks } + val imported = runCatching { + importManyClients( + context = context, + codes = fetched.map { it.code }, + blocksByCode = { code -> fetchedByCode[code.trim().uppercase()].orEmpty() }, + pacer = pacer, + ) + ClientAnswerSyncStore.markPulledBatch(context, fetched.map { it.code }) + fetched.size + }.getOrElse { + SyncLog.w(TAG, "fallback import failed: ${it.message}") + 0 + } + + SyncLog.d(TAG, "answer pull finished: $imported/${codes.size} clients (${fetched.size} fetched)") + imported + } + + /** + * Prepares all clients on a worker thread (encrypt + JSON), then one Room transaction for SQLite writes. + */ + private suspend fun importManyClients( + context: Context, + codes: List, + blocksByCode: (String) -> List, + pacer: SyncProgressPacer? = null, + ) { + if (codes.isEmpty()) return + pacer?.emit(SyncPhase.PROCESSING) + + val listQnIds = QuestionnaireCache.getQuestionnaireList(context).map { it.id }.toSet() + val allQnIds = buildSet { + addAll(listQnIds) + codes.forEach { code -> + blocksByCode(code).forEach { add(it.questionnaireID) } + } + } + val detailCaches = loadDetailCaches(context, allQnIds) + val localAnswerPresence = AnswerRepository.localAnswerPresence(codes) + val completionsByClient = CompletedQuestionnaireRepository.getAllGroupedByClient(codes) + + val prepared = withContext(Dispatchers.Default) { + buildList { + for (code in codes) { + val blocks = blocksByCode(code) + if (blocks.isEmpty()) continue + val completionByQn = completionsByClient[code].orEmpty() + .associateBy { it.questionnaireId } + val clientBlocks = mutableListOf() + for (block in blocks) { + prepareBlockImport( + clientCode = code, + block = block, + localAnswerPresence = localAnswerPresence, + completionByQn = completionByQn, + detailCaches = detailCaches, + )?.let { clientBlocks.add(it) } + } + if (clientBlocks.isNotEmpty()) { + add(PreparedClientImport(code, clientBlocks)) + } + } + } + } + + if (prepared.isEmpty()) return + + pacer?.emit(SyncPhase.SECURELY_SAVING) + dbImportGate.withPermit { + val db = MyApp.database + db.withTransaction { + val allQuestionnaires = prepared.flatMap { it.blocks } + .map { Questionnaire(id = it.questionnaireId) } + .distinctBy { it.id } + db.questionnaireDao().insertQuestionnaires(allQuestionnaires) + for (client in prepared) { + val qnIds = client.blocks.map { it.questionnaireId } + AnswerRepository.deleteAnswersForClientAndQuestionnaires(client.clientCode, qnIds) + val allQuestions = client.blocks.flatMap { it.questions } + if (allQuestions.isNotEmpty()) { + db.questionDao().insertQuestions(allQuestions) + } + val allAnswers = client.blocks.flatMap { it.answerEntities } + if (allAnswers.isNotEmpty()) { + db.answerDao().insertAnswers(allAnswers) + } + db.completedQuestionnaireDao().insertAll( + client.blocks.map { it.completion.toEntity(client.clientCode) }, + ) + } + } + } + } + + private suspend fun importClientBundle( + context: Context, + clientCode: String, + blocks: List, + ) { + if (blocks.isEmpty()) return + importManyClients( + context = context, + codes = listOf(clientCode), + blocksByCode = { if (it == clientCode) blocks else emptyList() }, + ) + } + + private fun loadDetailCaches(context: Context, questionnaireIds: Set): DetailCaches { + val parsed = mutableMapOf() + val rawJson = mutableMapOf() + for (id in questionnaireIds) { + if (id.isBlank()) continue + val json = QuestionnaireCache.loadQuestionnaireJson(context, id) ?: continue + rawJson[id] = json + parsed[id] = JsonParser.parseString(json).asJsonObject + } + return DetailCaches(parsed, rawJson) + } + + private fun prepareBlockImport( + clientCode: String, + block: ServerQuestionnaireAnswers, + localAnswerPresence: Set>, + completionByQn: Map, + detailCaches: DetailCaches, + ): PreparedBlockImport? { + val questionnaireId = block.questionnaireID + if (questionnaireId.isBlank()) return null + + val status = completionByQn[questionnaireId] + val pendingEdit = status?.isDone == true && + (status.uploadedAt == null || (status.uploadedAt ?: 0L) < status.timestamp) + if (localAnswerPresence.contains(clientCode to questionnaireId) && pendingEdit) { + return null + } + + val detail = detailCaches.parsed[questionnaireId] ?: return null + val questions = detail.getAsJsonArray("questions") ?: return null + val stored = mapServerAnswersToStored(block, questions) ?: return null + + val completedAtMs = (block.completedAt ?: 0L).let { if (it > 0) it * 1000L else System.currentTimeMillis() } + val sumPoints = block.sumPoints + val clientKey = com.dano.test1.security.SensitiveDataCrypto.clientCodeKey(clientCode) + + return PreparedBlockImport( + questionnaireId = questionnaireId, + questions = stored.keys.map { key -> + Question( + questionId = AnswerKeyUtils.roomQuestionId(questionnaireId, key), + questionnaireId = questionnaireId, + question = key, + ) + }, + answerEntities = stored.map { (key, value) -> + AnswerEntity( + clientCodeKey = clientKey, + questionId = AnswerKeyUtils.roomQuestionId(questionnaireId, key), + answerValueEnc = com.dano.test1.security.SensitiveDataCrypto.encrypt(value), + ) + }, + completion = CompletedQuestionnaire( + clientCode = clientCode, + questionnaireId = questionnaireId, + isDone = true, + sumPoints = sumPoints, + timestamp = completedAtMs, + uploadedAt = completedAtMs, + ), + ) + } + + private fun mapServerAnswersToStored( + block: ServerQuestionnaireAnswers, + questions: JsonArray, + ): Map? { + val submitByQuestion = block.answers.groupBy { it.questionID } + val stored = mutableMapOf() + val symptomKeys = mutableSetOf() + + questions.forEach { el -> + val q = el.asJsonObject + val shortId = q.get("id")?.asString ?: return@forEach + val layout = q.get("layout")?.asString.orEmpty() + val questionKey = q.get("question")?.asString?.takeIf { it.isNotBlank() } ?: shortId + val rows = submitByQuestion[shortId].orEmpty() + + q.getAsJsonArray("symptoms")?.forEach { s -> + symptomKeys.add(s.asString) + } + + when (layout) { + "multi_check_box_question" -> { + val keys = rows.mapNotNull { it.answerOptionKey?.takeIf { k -> k.isNotBlank() } } + if (keys.isNotEmpty()) { + stored[questionKey] = keys.joinToString( + prefix = "[", + postfix = "]", + ) { "\"$it\"" } + } + } + "glass_scale_question" -> Unit + "radio_question", "string_spinner" -> { + val row = rows.firstOrNull() ?: return@forEach + val value = row.answerOptionKey?.takeIf { it.isNotBlank() } + ?: row.freeTextValue?.takeIf { it.isNotBlank() } + if (value != null) stored[questionKey] = value + } + "value_spinner", "slider_question" -> { + val row = rows.firstOrNull() ?: return@forEach + val num = row.numericValue + val value = when { + num != null -> num.toString() + else -> row.freeTextValue + } + if (!value.isNullOrBlank()) stored[questionKey] = value + } + else -> { + val row = rows.firstOrNull() ?: return@forEach + val value = row.freeTextValue?.takeIf { it.isNotBlank() } + ?: row.answerOptionKey?.takeIf { it.isNotBlank() } + ?: row.numericValue?.toString() + if (!value.isNullOrBlank()) stored[questionKey] = value + } + } + } + + symptomKeys.forEach { symptomKey -> + val row = block.answers.find { it.questionID == symptomKey } ?: return@forEach + val value = row.answerOptionKey?.takeIf { it.isNotBlank() } ?: return@forEach + stored[symptomKey] = value + } + + return stored.takeIf { it.isNotEmpty() } + } + + private fun CompletedQuestionnaire.toEntity(clientCode: String) = + com.dano.test1.data.CompletedQuestionnaireEntity( + clientCodeKey = com.dano.test1.security.SensitiveDataCrypto.clientCodeKey(clientCode), + questionnaireId = questionnaireId, + timestamp = timestamp, + isDone = isDone, + sumPoints = sumPoints, + uploadedAt = uploadedAt, + ) +} diff --git a/app/src/main/java/com/dano/test1/network/QuestionnaireApiClient.kt b/app/src/main/java/com/dano/test1/network/QuestionnaireApiClient.kt new file mode 100644 index 0000000..0382579 --- /dev/null +++ b/app/src/main/java/com/dano/test1/network/QuestionnaireApiClient.kt @@ -0,0 +1,570 @@ +package com.dano.test1.network + +import com.dano.test1.security.SessionPayloadCrypto +import com.google.gson.Gson +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject + +object QuestionnaireApiClient { + //URL ONLY VALID ON CURRENT NETWORK; HAS TO BE CHANGED IN FUTURE + private val jsonMediaType = "application/json; charset=UTF-8".toMediaType() + private val client get() = HttpModule.client + private val gson = Gson() + + suspend fun changePassword( + token: String, + username: String, + oldPassword: String, + newPassword: String + ): LoginResult = withContext(Dispatchers.IO) { + val body = JSONObject() + .put("username", username) + .put("old_password", oldPassword) + .put("new_password", newPassword) + .toString() + .toRequestBody(jsonMediaType) + + val request = authorizedRequest(token) + .url("${HttpModule.BASE_URL}/auth/change-password") + .post(body) + .build() + + client.newCall(request).execute().use { response -> + val bodyText = response.body?.string() + SyncLog.apiResponse("API_CHANGE_PW", "changePassword", response.code, bodyText) + val root = parseEnvelope(response.code, bodyText) + val data = root.getAsJsonObject("data") ?: throw ApiException("Change password response missing data") + parseLoginResult(data) + } + } + + suspend fun login(username: String, password: String): LoginResult = withContext(Dispatchers.IO) { + val body = JSONObject() + .put("username", username) + .put("password", password) + .toString() + .toRequestBody(jsonMediaType) + + val request = Request.Builder() + .url("${HttpModule.BASE_URL}/auth/login") + .post(body) + .build() + + client.newCall(request).execute().use { response -> + val bodyText = response.body?.string() + SyncLog.apiResponse("API_LOGIN", "login", response.code, bodyText) + val root = parseEnvelope(response.code, bodyText) + val data = root.getAsJsonObject("data") ?: throw ApiException("Login response missing data") + parseLoginResult(data) + } + } + + /** App UI strings for the login screen — no Bearer token required. */ + suspend fun getPublicTranslations(): JsonObject = withContext(Dispatchers.IO) { + val request = Request.Builder() + .url("${HttpModule.BASE_URL}/app_questionnaires?translations=1") + .get() + .build() + fetchTranslationsEnvelope(request, logTag = "API_TRANSLATIONS_PUBLIC") + } + + suspend fun getTranslations(token: String): JsonObject = withContext(Dispatchers.IO) { + val request = authorizedRequest(token) + .url("${HttpModule.BASE_URL}/app_questionnaires?translations=1") + .get() + .build() + fetchTranslationsEnvelope(request, logTag = "API_TRANSLATIONS") + } + + suspend fun getSession(token: String): SessionInfo = withContext(Dispatchers.IO) { + val request = authorizedRequest(token) + .url("${HttpModule.BASE_URL}/session") + .get() + .build() + + client.newCall(request).execute().use { response -> + val bodyText = response.body?.string() + SyncLog.apiResponse("API_SESSION", "getSession", response.code, bodyText) + val root = parseEnvelope(response.code, bodyText) + val data = root.getAsJsonObject("data") ?: throw ApiException("Session response missing data") + SessionInfo( + valid = data.get("valid")?.asBoolean ?: false, + user = data.get("user")?.asString.orEmpty(), + role = data.get("role")?.asString.orEmpty(), + mustChangePassword = data.get("mustChangePassword")?.asBoolean ?: false, + ) + } + } + + private fun fetchTranslationsEnvelope(request: Request, logTag: String): JsonObject = + client.newCall(request).execute().use { response -> + val bodyText = response.body?.string() + SyncLog.apiResponse(logTag, "translations", response.code, bodyText) + val root = parseEnvelope(response.code, bodyText) + val data = root.get("data") + when { + data == null || data.isJsonNull -> JsonObject() + data.isJsonObject -> data.asJsonObject + else -> { + SyncLog.w(logTag, "unexpected data type for translations: ${data::class.simpleName}") + JsonObject() + } + } + } + + suspend fun getQuestionnaires(token: String): List = withContext(Dispatchers.IO) { + val request = authorizedRequest(token) + .url("${HttpModule.BASE_URL}/app_questionnaires") + .get() + .build() + + client.newCall(request).execute().use { response -> + val bodyText = response.body?.string() + SyncLog.apiResponse("API_QUESTIONNAIRES", "getQuestionnaires", response.code, bodyText) + val root = parseEnvelope(response.code, bodyText) + val data = root.getAsJsonArray("data") ?: JsonArray() + data.mapNotNull { item -> + val obj = item.asJsonObject + val id = obj.get("id")?.asString ?: return@mapNotNull null + val conditionElement = obj.get("condition") + val conditionJson = when { + conditionElement == null || conditionElement.isJsonNull -> "{}" + conditionElement.isJsonObject -> conditionElement.asJsonObject.toString() + else -> "{}" + } + QuestionnaireListItem( + id = id, + name = obj.get("name")?.asString ?: id, + showPoints = obj.get("showPoints")?.asBoolean ?: false, + conditionJson = conditionJson, + categoryKey = obj.get("categoryKey")?.asString?.takeIf { it.isNotBlank() }, + structureRevision = obj.get("structureRevision")?.asInt?.coerceAtLeast(1) ?: 1, + ) + } + } + } + + /** + * Fetches assigned clients together with their server-side completion records. + * Supports both the legacy plain-string array and the new rich object format: + * { "data": { "clients": [ { "clientCode": "...", "completedQuestionnaires": [...] } ] } } + * { "data": { "clients": ["CLIENT-001", ...] } } + * { "data": ["CLIENT-001", ...] } + */ + suspend fun getAssignedClients(token: String): List = withContext(Dispatchers.IO) { + val request = authorizedRequest(token) + .url("${HttpModule.BASE_URL}/app_questionnaires?clients=1") + .get() + .build() + + client.newCall(request).execute().use { response -> + val bodyText = response.body?.string() + SyncLog.apiResponse("API_CLIENTS", "getAssignedClients", response.code, bodyText) + val root = parseEnvelope(response.code, bodyText) + val dataEl = root.get("data") ?: return@use emptyList() + val data = when { + dataEl.isJsonObject -> SessionPayloadCrypto.decryptDataElement(dataEl.asJsonObject, token) + else -> return@use emptyList() + } + + val arr = when { + data.has("clients") -> data.getAsJsonArray("clients") + else -> null + } ?: return@use emptyList() + + arr.mapNotNull { item -> + when { + item.isJsonPrimitive -> { + val code = item.asString.takeIf { it.isNotBlank() } ?: return@mapNotNull null + AssignedClientInfo(clientCode = code) + } + item.isJsonObject -> { + val obj = item.asJsonObject + val code = obj.get("clientCode")?.asString?.takeIf { it.isNotBlank() } + ?: return@mapNotNull null + val completions = obj.getAsJsonArray("completedQuestionnaires") + ?.mapNotNull { cItem -> + if (!cItem.isJsonObject) return@mapNotNull null + val cObj = cItem.asJsonObject + val qId = cObj.get("questionnaireID")?.asString?.takeIf { it.isNotBlank() } + ?: return@mapNotNull null + ServerCompletedQuestionnaire( + questionnaireID = qId, + sumPoints = cObj.get("sumPoints")?.asInt ?: 0, + completedAt = cObj.get("completedAt")?.asLong ?: 0L + ) + } ?: emptyList() + AssignedClientInfo(clientCode = code, completedQuestionnaires = completions) + } + else -> null + } + } + } + } + + suspend fun fetchBulkClientAnswers(token: String): List = + withContext(Dispatchers.IO) { + val request = authorizedRequest(token) + .url("${HttpModule.BASE_URL}/app_questionnaires?answersBulk=1") + .get() + .build() + + client.newCall(request).execute().use { response -> + val bodyText = response.body?.string() + SyncLog.apiResponse("API_ANSWERS", "fetchBulkClientAnswers", response.code, bodyText) + val root = parseEnvelope(response.code, bodyText) + val dataEl = root.get("data") ?: return@use emptyList() + val data = when { + dataEl.isJsonObject -> SessionPayloadCrypto.decryptDataElement(dataEl.asJsonObject, token) + else -> return@use emptyList() + } + parseClientAnswerBundles(data.getAsJsonArray("clients")) + } + } + + suspend fun fetchClientAnswers(token: String, clientCode: String): ClientAnswersBundle = + withContext(Dispatchers.IO) { + val request = authorizedRequest(token) + .url( + "${HttpModule.BASE_URL}/app_questionnaires" + + "?clientCode=${java.net.URLEncoder.encode(clientCode, Charsets.UTF_8.name())}&answers=1", + ) + .get() + .build() + + client.newCall(request).execute().use { response -> + val bodyText = response.body?.string() + SyncLog.apiResponse("API_ANSWERS", "fetchClientAnswers(${SyncLog.clientRef(clientCode)})", response.code, bodyText) + val root = parseEnvelope(response.code, bodyText) + val dataEl = root.get("data") ?: return@use ClientAnswersBundle(clientCode) + val data = when { + dataEl.isJsonObject -> SessionPayloadCrypto.decryptDataElement(dataEl.asJsonObject, token) + else -> return@use ClientAnswersBundle(clientCode) + } + val blocks = parseQuestionnaireAnswerBlocks(data.getAsJsonArray("questionnaires")) + ClientAnswersBundle( + clientCode = data.get("clientCode")?.asString ?: clientCode, + questionnaires = blocks, + ) + } + } + + private fun parseClientAnswerBundles(clientsArr: JsonArray?): List { + if (clientsArr == null) return emptyList() + return clientsArr.mapNotNull { clientEl -> + if (!clientEl.isJsonObject) return@mapNotNull null + val clientObj = clientEl.asJsonObject + val code = clientObj.get("clientCode")?.asString?.takeIf { it.isNotBlank() } + ?: return@mapNotNull null + ClientAnswersBundle( + clientCode = code, + questionnaires = parseQuestionnaireAnswerBlocks(clientObj.getAsJsonArray("questionnaires")), + ) + } + } + + private fun parseQuestionnaireAnswerBlocks(qnArr: JsonArray?): List { + if (qnArr == null) return emptyList() + return qnArr.mapNotNull { item -> + if (!item.isJsonObject) return@mapNotNull null + val obj = item.asJsonObject + val qnId = obj.get("questionnaireID")?.asString?.takeIf { it.isNotBlank() } + ?: return@mapNotNull null + val answersArr = obj.getAsJsonArray("answers") ?: JsonArray() + val answers = answersArr.mapNotNull { aEl -> + if (!aEl.isJsonObject) return@mapNotNull null + val a = aEl.asJsonObject + val qid = a.get("questionID")?.asString ?: return@mapNotNull null + SubmitAnswer( + questionID = qid, + answerOptionKey = a.get("answerOptionKey")?.asString, + freeTextValue = a.get("freeTextValue")?.asString, + numericValue = a.get("numericValue")?.takeIf { !it.isJsonNull }?.asDouble, + answeredAt = a.get("answeredAt")?.asLong, + ) + } + ServerQuestionnaireAnswers( + questionnaireID = qnId, + completedAt = obj.get("completedAt")?.asLong, + sumPoints = obj.get("sumPoints")?.asInt ?: 0, + answers = answers, + ) + } + } + + suspend fun getQuestionnaire(token: String, questionnaireId: String): JsonObject = withContext(Dispatchers.IO) { + val request = authorizedRequest(token) + .url("${HttpModule.BASE_URL}/app_questionnaires?id=$questionnaireId") + .get() + .build() + + client.newCall(request).execute().use { response -> + val root = parseEnvelope(response.code, response.body?.string()) + root.getAsJsonObject("data") ?: throw ApiException("Questionnaire response missing data") + } + } + + suspend fun submitQuestionnaire( + token: String, + requestBody: SubmitQuestionnaireRequest + ): SubmitQuestionnaireResponse = withContext(Dispatchers.IO) { + val envelope = SessionPayloadCrypto.envelope(gson.toJson(requestBody), token) + val request = authorizedRequest(token) + .url("${HttpModule.BASE_URL}/app_questionnaires") + .post(gson.toJson(envelope).toRequestBody(jsonMediaType)) + .build() + + client.newCall(request).execute().use { response -> + val root = parseEnvelope(response.code, response.body?.string()) + val data = root.getAsJsonObject("data") ?: JsonObject() + gson.fromJson(data, SubmitQuestionnaireResponse::class.java) + ?: SubmitQuestionnaireResponse() + } + } + + suspend fun getScoringProfiles(token: String): List = + withContext(Dispatchers.IO) { + val request = authorizedRequest(token) + .url("${HttpModule.BASE_URL}/app_questionnaires?scoringProfiles=1") + .get() + .build() + client.newCall(request).execute().use { response -> + val bodyText = response.body?.string() + SyncLog.apiResponse("API_SCORING_PROFILES", "getScoringProfiles", response.code, bodyText) + val root = parseEnvelope(response.code, bodyText) + val dataEl = root.get("data") ?: return@use emptyList() + val data = when { + dataEl.isJsonObject -> SessionPayloadCrypto.decryptDataElement(dataEl.asJsonObject, token) + else -> return@use emptyList() + } + parseScoringProfiles(data.getAsJsonArray("profiles")) + } + } + + suspend fun getScoringReview(token: String, clientCode: String? = null): List = + withContext(Dispatchers.IO) { + val urlBuilder = StringBuilder("${HttpModule.BASE_URL}/app_questionnaires?scoringReview=1") + if (!clientCode.isNullOrBlank()) { + urlBuilder.append("&clientCode=") + .append(java.net.URLEncoder.encode(clientCode, Charsets.UTF_8.name())) + } + val request = authorizedRequest(token).url(urlBuilder.toString()).get().build() + client.newCall(request).execute().use { response -> + val bodyText = response.body?.string() + SyncLog.apiResponse("API_SCORING_REVIEW", "getScoringReview", response.code, bodyText) + val root = parseEnvelope(response.code, bodyText) + val dataEl = root.get("data") ?: return@use emptyList() + val data = when { + dataEl.isJsonObject -> SessionPayloadCrypto.decryptDataElement(dataEl.asJsonObject, token) + else -> return@use emptyList() + } + parseScoringReviewClients(data.getAsJsonArray("clients")) + } + } + + suspend fun submitCoachScoringReview( + token: String, + requestBody: CoachScoringReviewRequest, + ): ScoringReviewProfile = withContext(Dispatchers.IO) { + val envelope = SessionPayloadCrypto.envelope(gson.toJson(requestBody), token) + val request = authorizedRequest(token) + .url("${HttpModule.BASE_URL}/app_questionnaires") + .post(gson.toJson(envelope).toRequestBody(jsonMediaType)) + .build() + + client.newCall(request).execute().use { response -> + val root = parseEnvelope(response.code, response.body?.string()) + val data = root.getAsJsonObject("data") ?: throw ApiException("Review response missing data") + val profileObj = data.getAsJsonObject("profile") + ?: throw ApiException("Review response missing profile") + parseScoringReviewProfile(profileObj) + ?: throw ApiException("Review response profile invalid") + } + } + + private fun parseScoringProfiles(arr: JsonArray?): List { + if (arr == null) return emptyList() + return arr.mapNotNull { item -> + if (!item.isJsonObject) return@mapNotNull null + val obj = item.asJsonObject + val profileID = obj.get("profileID")?.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 + ScoringProfileMember( + questionnaireID = qId, + weight = m.get("weight")?.asDouble ?: 1.0, + orderIndex = m.get("orderIndex")?.asInt ?: 0, + name = m.get("name")?.asString.orEmpty(), + ) + } ?: emptyList() + ScoringProfileDefinition( + profileID = profileID, + name = obj.get("name")?.asString ?: profileID, + description = obj.get("description")?.asString.orEmpty(), + isActive = obj.get("isActive")?.asInt == 1 || obj.get("isActive")?.asBoolean == true, + greenMin = obj.get("greenMin")?.asInt ?: 0, + greenMax = obj.get("greenMax")?.asInt ?: 12, + yellowMin = obj.get("yellowMin")?.asInt ?: 13, + yellowMax = obj.get("yellowMax")?.asInt ?: 36, + redMin = obj.get("redMin")?.asInt ?: 37, + questionnaires = members, + ) + } + } + + private fun parseScoringReviewClients(arr: JsonArray?): List { + if (arr == null) return emptyList() + return arr.mapNotNull { item -> + if (!item.isJsonObject) return@mapNotNull null + val obj = item.asJsonObject + val code = obj.get("clientCode")?.asString?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + ClientScoringReview( + clientCode = code, + profiles = parseScoringReviewProfiles(obj.getAsJsonArray("profiles")), + ) + } + } + + private fun parseScoringReviewProfiles(arr: JsonArray?): List { + if (arr == null) return emptyList() + return arr.mapNotNull { item -> + if (!item.isJsonObject) return@mapNotNull null + parseScoringReviewProfile(item.asJsonObject) + } + } + + private fun parseScoringReviewProfile(obj: JsonObject): ScoringReviewProfile? { + val profileID = obj.get("profileID")?.asString?.takeIf { it.isNotBlank() } ?: return null + val coachBand = obj.get("coachBand")?.takeIf { !it.isJsonNull }?.asString + return ScoringReviewProfile( + profileID = profileID, + name = obj.get("name")?.asString ?: profileID, + weightedTotal = obj.get("weightedTotal")?.asDouble ?: 0.0, + calculatedBand = obj.get("calculatedBand")?.asString ?: obj.get("band")?.asString.orEmpty(), + greenMin = obj.get("greenMin")?.asInt ?: 0, + greenMax = obj.get("greenMax")?.asInt ?: 12, + yellowMin = obj.get("yellowMin")?.asInt ?: 13, + yellowMax = obj.get("yellowMax")?.asInt ?: 36, + redMin = obj.get("redMin")?.asInt ?: 37, + coachBand = coachBand, + pendingReview = obj.get("pendingReview")?.asBoolean ?: coachBand.isNullOrBlank(), + ) + } + + private fun authorizedRequest(token: String): Request.Builder = + Request.Builder().header("Authorization", "Bearer $token") + + private fun apiExceptionFromErrorElement(errorEl: JsonElement?, httpCode: Int): ApiException { + if (errorEl == null || errorEl.isJsonNull) { + return ApiException("Unexpected server response ($httpCode)") + } + if (errorEl.isJsonObject) { + val o = errorEl.asJsonObject + return ApiException( + o.get("message")?.asString ?: "Server error ($httpCode)", + o.get("code")?.asString, + o.get("details"), + ) + } + if (errorEl.isJsonPrimitive) { + return ApiException(errorEl.asString, null, null) + } + return ApiException("Unexpected server response ($httpCode)") + } + + /** User-visible text for failed API calls (includes validation breakdown). */ + fun formatErrorMessage(exception: ApiException): String { + if (exception.code != "VALIDATION_FAILED") { + return exception.message ?: "Unknown error" + } + val details = exception.details?.takeIf { it.isJsonObject }?.asJsonObject ?: return exception.message ?: "Validation failed" + val errors = details.getAsJsonArray("errors") ?: return exception.message ?: "Validation failed" + val lines = mutableListOf() + errors.forEach { item -> + if (!item.isJsonObject) return@forEach + val o = item.asJsonObject + val qid = o.get("questionID")?.asString?.takeIf { it.isNotBlank() } + val msg = o.get("message")?.asString ?: o.get("code")?.asString ?: "Invalid" + lines += if (qid != null) "$qid: $msg" else msg + } + return if (lines.isEmpty()) { + exception.message ?: "Validation failed" + } else { + (exception.message ?: "Validation failed") + "\n" + lines.joinToString("\n") + } + } + + private fun parseEnvelope(httpCode: Int, body: String?): JsonObject { + if (body.isNullOrBlank()) { + throw ApiException("Empty server response ($httpCode)") + } + + val root = try { + JsonParser.parseString(body).asJsonObject + } catch (e: Exception) { + SyncLog.e("API", "Failed to parse JSON (HTTP $httpCode, ${body?.length ?: 0} bytes)", e) + throw ApiException("Server response was not valid JSON ($httpCode). See logs for details.") + } + + if (!root.has("ok")) { + throw apiExceptionFromErrorElement(root.get("error"), httpCode) + } + + if (!root.get("ok").asBoolean) { + throw apiExceptionFromErrorElement(root.get("error"), httpCode) + } + + return root + } + + private fun parseLoginResult(data: JsonObject): LoginResult { + val token = data.get("token")?.asString ?: throw ApiException("Login response missing token") + val user = data.get("user")?.asString ?: "" + val clientsPayload = data.getAsJsonObject("clientsPayload") + val clientSource = if (clientsPayload != null) { + SessionPayloadCrypto.decryptEnvelope(clientsPayload, token) + } else { + data + } + return LoginResult( + token = token, + user = user, + role = data.get("role")?.asString, + mustChangePassword = data.get("mustChangePassword")?.asBoolean ?: false, + assignedClientCodes = parseAssignedClientCodes(clientSource), + ) + } + + private fun parseAssignedClientCodes(data: JsonObject): List { + val sources = listOf("assignedClientCodes", "clientCodes", "clients") + return sources.firstNotNullOfOrNull { key -> + data.get(key)?.let(::extractClientCodes) + ?.takeIf { it.isNotEmpty() } + }.orEmpty() + } + + private fun extractClientCodes(element: JsonElement): List { + if (!element.isJsonArray) return emptyList() + return element.asJsonArray.mapNotNull { item -> + when { + item.isJsonPrimitive -> item.asString + item.isJsonObject -> { + val obj = item.asJsonObject + obj.get("clientCode")?.asString ?: obj.get("code")?.asString + } + else -> null + } + }.filter { it.isNotBlank() }.distinct() + } +} diff --git a/app/src/main/java/com/dano/test1/network/QuestionnaireCache.kt b/app/src/main/java/com/dano/test1/network/QuestionnaireCache.kt new file mode 100644 index 0000000..543dc03 --- /dev/null +++ b/app/src/main/java/com/dano/test1/network/QuestionnaireCache.kt @@ -0,0 +1,178 @@ +package com.dano.test1.network + +import android.content.Context +import com.dano.test1.LanguageManager +import com.dano.test1.MyApp +import com.dano.test1.data.ClientRepository +import com.dano.test1.security.SensitiveDataCrypto +import com.dano.test1.questionnaire.GlobalValues +import com.google.gson.Gson +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import java.io.File + +object QuestionnaireCache { + private const val CACHE_DIR = "api_questionnaire_cache" + private const val DETAILS_DIR = "details" + 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 SCORING_PROFILES_FILE = "scoring_profiles.json" + + private val gson = Gson() + + fun hasQuestionnaireCache(context: Context): Boolean = + questionnairesFile(context).exists() && detailsDir(context).listFiles()?.isNotEmpty() == true + + fun saveTranslations(context: Context, data: JsonObject) { + // Server may return { "translations": { "en": {...} } }, directly { "en": {...} }, + // or { "translations": [] } when no translations are configured yet. + val translationsElement = if (data.has("translations")) data.get("translations") else data + val translations = if (translationsElement.isJsonObject) translationsElement.asJsonObject else JsonObject() + SyncLog.d("CACHE", "saveTranslations: ${translations.keySet().size} language(s)") + writeCacheText(translationsFile(context), gson.toJson(translations)) + LanguageManager.setApiTranslations(parseTranslationMap(translations)) + } + + fun applyTranslations(context: Context) { + val file = translationsFile(context) + if (!file.exists()) return + val parsed = runCatching { JsonParser.parseString(readCacheText(file) ?: return) }.getOrNull() ?: return + val translations = if (parsed.isJsonObject) parsed.asJsonObject else JsonObject() + LanguageManager.setApiTranslations(parseTranslationMap(translations)) + } + + fun parseTranslationMap(root: JsonObject): Map> = parseTranslations(root) + + fun applyQuestionnaireTranslationsFromJson(jsonString: String) { + val root = runCatching { JsonParser.parseString(jsonString).asJsonObject }.getOrNull() ?: return + val translationsElement = root.get("translations") ?: return + if (!translationsElement.isJsonObject) return + LanguageManager.setQuestionnaireTranslations(parseTranslations(translationsElement.asJsonObject)) + } + + fun saveQuestionnaireList(context: Context, list: List) { + writeCacheText(questionnairesFile(context), gson.toJson(list)) + } + + fun getQuestionnaireList(context: Context): List { + val file = questionnairesFile(context) + if (!file.exists()) return emptyList() + return runCatching { + gson.fromJson(readCacheText(file), Array::class.java).toList() + }.getOrDefault(emptyList()) + } + + fun saveQuestionnaireDetail(context: Context, questionnaireId: String, detail: JsonObject) { + writeCacheText(File(detailsDir(context), "$questionnaireId.json"), gson.toJson(detail)) + } + + fun loadQuestionnaireJson(context: Context, fileOrId: String): String? { + val id = fileOrId.removeSuffix(".json") + val file = File(detailsDir(context), "$id.json") + return if (file.exists()) readCacheText(file) else null + } + + fun getCachedStructureRevision(context: Context, questionnaireId: String): Int? { + val json = loadQuestionnaireJson(context, questionnaireId) ?: return null + return runCatching { + JsonParser.parseString(json).asJsonObject.get("structureRevision")?.asInt + }.getOrNull()?.coerceAtLeast(1) + } + + fun saveAssignedClients(context: Context, clientCodes: List) { + writeCacheText(clientsFile(context), gson.toJson(clientCodes.distinct())) + } + + fun clearAssignedClients(context: Context) { + val file = clientsFile(context) + if (file.exists()) file.delete() + GlobalValues.LAST_CLIENT_CODE = null + GlobalValues.LOADED_CLIENT_CODE = null + } + + suspend fun clearAllClientData(context: Context) { + clearAssignedClients(context) + ClientRepository.deleteAllClients() + } + + /** + * Removes all interview data, questionnaire cache files, and Room tables from the device. + * Call on logout for security; does not clear auth tokens (see [SessionLogout.wipeAllLocalData]). + */ + suspend fun wipeEntireLocalStore(context: Context) { + clearAssignedClients(context) + com.dano.test1.MyApp.database.clearAllTables() + cacheDir(context).deleteRecursively() + com.dano.test1.notification.UploadReminderScheduler.cancel(context) + LanguageManager.setApiTranslations(emptyMap()) + LanguageManager.setQuestionnaireTranslations(emptyMap()) + } + + /** + * Updates assigned clients without wiping local answers/completions for clients that stay assigned. + * (Previously deleteAllClients() CASCADE-deleted all interview data on every sync.) + */ + suspend fun replaceAssignedClients(context: Context, clientCodes: List) { + val codes = clientCodes.distinct() + saveAssignedClients(context, codes) + ClientRepository.replaceClients(codes) + SyncLog.d("CACHE", "replaceAssignedClients: ${codes.size} clients") + } + + fun saveScoringProfiles(context: Context, profiles: List) { + writeCacheText(File(cacheDir(context), SCORING_PROFILES_FILE), gson.toJson(profiles)) + } + + fun getScoringProfiles(context: Context): List { + val file = File(cacheDir(context), SCORING_PROFILES_FILE) + if (!file.exists()) return emptyList() + return runCatching { + gson.fromJson(readCacheText(file), Array::class.java).toList() + }.getOrDefault(emptyList()) + } + + fun getAssignedClients(context: Context): List { + val file = clientsFile(context) + if (!file.exists()) return emptyList() + return runCatching { + gson.fromJson(readCacheText(file), Array::class.java).toList() + }.getOrDefault(emptyList()) + } + + private fun parseTranslations(root: JsonObject): Map> { + return root.entrySet().mapNotNull { (language, values) -> + if (!values.isJsonObject) return@mapNotNull null + val strings = values.asJsonObject.entrySet().mapNotNull { (key, value) -> + if (value.isJsonPrimitive) key to value.asString else null + }.toMap() + language to strings + }.toMap() + } + + private fun cacheDir(context: Context): File = + File(context.filesDir, CACHE_DIR).apply { mkdirs() } + + private fun detailsDir(context: Context): File = + File(cacheDir(context), DETAILS_DIR).apply { mkdirs() } + + private fun translationsFile(context: Context): File = + File(cacheDir(context), TRANSLATIONS_FILE) + + private fun questionnairesFile(context: Context): File = + File(cacheDir(context), QUESTIONNAIRES_FILE) + + private fun clientsFile(context: Context): File = + File(cacheDir(context), CLIENTS_FILE) + + private fun writeCacheText(file: File, plain: String) { + file.writeText(SensitiveDataCrypto.encrypt(plain)) + } + + /** Reads encrypted cache files; legacy plaintext files are still accepted. */ + private fun readCacheText(file: File): String? { + if (!file.exists()) return null + val raw = file.readText() + return if (raw.startsWith("v1:")) SensitiveDataCrypto.decrypt(raw) else raw + } +} diff --git a/app/src/main/java/com/dano/test1/network/QuestionnaireSubmitMapper.kt b/app/src/main/java/com/dano/test1/network/QuestionnaireSubmitMapper.kt new file mode 100644 index 0000000..9e8fa7c --- /dev/null +++ b/app/src/main/java/com/dano/test1/network/QuestionnaireSubmitMapper.kt @@ -0,0 +1,150 @@ +package com.dano.test1.network + +import android.content.Context +import com.dano.test1.data.AnswerRepository +import com.dano.test1.data.CompletedQuestionnaireRepository +import com.dano.test1.questionnaire.AnswerKeyUtils +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import com.google.gson.JsonParser + +object QuestionnaireSubmitMapper { + private const val TAG = "SubmitMapper" + + suspend fun buildRequest( + context: Context, + clientCode: String, + questionnaireId: String + ): SubmitQuestionnaireRequest? { + val detailJson = QuestionnaireCache.loadQuestionnaireJson(context, questionnaireId) + if (detailJson == null) { + SyncLog.w(TAG, "No cached questionnaire JSON for $questionnaireId") + return null + } + val detail = JsonParser.parseString(detailJson).asJsonObject + val questions = detail.getAsJsonArray("questions") ?: return null + val answers = AnswerRepository.getAnswersForClientAndQuestionnaire(clientCode, questionnaireId) + .associateBy( + { AnswerKeyUtils.lookupKey(questionnaireId, it.questionId) }, + { it.answerValue }, + ) + val completed = CompletedQuestionnaireRepository.getStatus(clientCode, questionnaireId) + + val submitAnswers = mutableListOf() + questions.forEach { questionElement -> + val question = questionElement.asJsonObject + val questionId = question.get("id")?.asString ?: return@forEach + val layout = question.get("layout")?.asString.orEmpty() + + resolveStoredAnswer(answers, question)?.let { value -> + submitAnswers += mapAnswer(questionId, layout, value, optionKeys(question)) + } + + val symptoms = question.getAsJsonArray("symptoms") + symptoms?.forEach { symptom -> + val symptomKey = symptom.asString + answers[symptomKey]?.let { value -> + submitAnswers += SubmitAnswer( + questionID = symptomKey, + answerOptionKey = value.takeIf { it.isNotBlank() } + ) + } + } + } + + if (submitAnswers.isEmpty()) { + SyncLog.w( + TAG, + "No mappable answers for ${SyncLog.clientRef(clientCode)} / $questionnaireId " + + "(stored keys count=${answers.size}, questions: ${questions.size()})" + ) + return null + } + + val structureRevision = detail.get("structureRevision")?.asInt?.coerceAtLeast(1) + + return SubmitQuestionnaireRequest( + questionnaireID = questionnaireId, + clientCode = clientCode, + completedAt = completed?.timestamp?.let { it / 1000 }, + structureRevision = structureRevision, + answers = submitAnswers + ) + } + + /** + * Resolve a stored answer for an API question object. + * Saving uses [question] (stable key) in most handlers; some use [id] (short local id). + */ + private fun resolveStoredAnswer(answers: Map, question: JsonObject): String? { + val questionKey = question.get("question")?.asString?.takeIf { it.isNotBlank() } + val shortId = question.get("id")?.asString?.takeIf { it.isNotBlank() } + if (questionKey != null && questionKey != shortId) { + return answers[questionKey] + } + return shortId?.let { answers[it] } + } + + private fun mapAnswer( + questionId: String, + layout: String, + rawValue: String, + optionKeys: Set + ): List { + val value = rawValue.trim() + if (value.isBlank()) return emptyList() + + return when (layout) { + "radio_question" -> listOf( + if (optionKeys.isEmpty() || optionKeys.contains(value)) { + SubmitAnswer(questionID = questionId, answerOptionKey = value) + } else { + SubmitAnswer(questionID = questionId, freeTextValue = value) + } + ) + // Country lists and similar spinners store choices in question config.options, + // not in answer_option rows — server accepts freeTextValue (see app_submit_validate). + "string_spinner" -> listOf( + SubmitAnswer(questionID = questionId, freeTextValue = value) + ) + "multi_check_box_question" -> parseMultiValue(value).map { + SubmitAnswer(questionID = questionId, answerOptionKey = it) + } + "value_spinner", "slider_question" -> listOf( + SubmitAnswer(questionID = questionId, numericValue = value.toDoubleOrNull()) + ) + "date_spinner", "free_text" -> listOf( + SubmitAnswer(questionID = questionId, freeTextValue = value) + ) + else -> listOf( + if (optionKeys.contains(value)) { + SubmitAnswer(questionID = questionId, answerOptionKey = value) + } else { + SubmitAnswer( + questionID = questionId, + numericValue = value.toDoubleOrNull(), + freeTextValue = value.takeUnless { value.toDoubleOrNull() != null } + ) + } + ) + } + } + + private fun optionKeys(question: JsonObject): Set { + val options = question.getAsJsonArray("options") ?: return emptySet() + return options.mapNotNull { option -> + when { + option.isJsonObject -> option.asJsonObject.get("key")?.asString + option.isJsonPrimitive -> option.asString + else -> null + } + }.toSet() + } + + private fun parseMultiValue(value: String): List { + val trimmed = value.trim().removePrefix("[").removeSuffix("]") + return trimmed.split(",", ";") + .map { it.trim().trim('"', '\'') } + .filter { it.isNotBlank() } + } +} diff --git a/app/src/main/java/com/dano/test1/network/QuestionnaireSyncService.kt b/app/src/main/java/com/dano/test1/network/QuestionnaireSyncService.kt new file mode 100644 index 0000000..07e3347 --- /dev/null +++ b/app/src/main/java/com/dano/test1/network/QuestionnaireSyncService.kt @@ -0,0 +1,262 @@ +package com.dano.test1.network + +import android.content.Context +import com.dano.test1.MyApp +import com.dano.test1.data.CompletedQuestionnaire +import com.dano.test1.data.Questionnaire +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit + +object QuestionnaireSyncService { + private const val TAG = "SYNC" + private const val DETAIL_FETCH_PARALLELISM = 8 + + suspend fun refreshAll( + context: Context, + token: String, + assignedClientCodes: List = emptyList(), + pullAnswers: Boolean = true, + forceAnswerPull: Boolean = true, + onProgress: SyncProgressCallback? = null, + ) = coroutineScope { + SyncLog.d(TAG, "refreshAll start (${assignedClientCodes.size} fallback clients)") + val pacer = SyncProgressPacer(onProgress) + + pacer.emit(SyncPhase.PREPARING) + val translationsDeferred = async(Dispatchers.IO) { QuestionnaireApiClient.getTranslations(token) } + val questionnairesDeferred = async(Dispatchers.IO) { QuestionnaireApiClient.getQuestionnaires(token) } + val translations = translationsDeferred.await() + val questionnaires = questionnairesDeferred.await() + QuestionnaireCache.saveTranslations(context, translations) + QuestionnaireCache.saveQuestionnaireList(context, questionnaires) + SyncLog.d(TAG, "questionnaire list: ${questionnaires.size} items") + + val db = MyApp.database + db.questionnaireDao().insertQuestionnaires( + questionnaires.map { Questionnaire(it.id) }, + ) + + pacer.emit(SyncPhase.DOWNLOADING_QUESTIONNAIRES) + val clientsDeferred = async(Dispatchers.IO) { + runCatching { QuestionnaireApiClient.getAssignedClients(token) } + } + val scoringProfilesDeferred = async(Dispatchers.IO) { + runCatching { QuestionnaireApiClient.getScoringProfiles(token) } + } + val detailsDeferred = async { + fetchAndCacheQuestionnaireDetails(context, token, questionnaires) + } + + val clientResult = clientsDeferred.await() + scoringProfilesDeferred.await().onSuccess { profiles -> + QuestionnaireCache.saveScoringProfiles(context, profiles) + SyncLog.d(TAG, "scoring profiles cached: ${profiles.size}") + }.onFailure { e -> + SyncLog.w(TAG, "getScoringProfiles failed: ${e.message}") + } + pacer.emit(SyncPhase.DOWNLOADING_CLIENT_INFO) + val clientInfosFromApi = clientResult.getOrElse { e -> + SyncLog.w(TAG, "getAssignedClients failed: ${e.message}") + emptyList() + } + val apiSucceeded = clientResult.isSuccess + + val clientCodesToSave: List = when { + apiSucceeded -> clientInfosFromApi.map { it.clientCode } + assignedClientCodes.isNotEmpty() -> assignedClientCodes + else -> emptyList() + } + + persistAssignedClients(context, apiSucceeded, assignedClientCodes, clientCodesToSave) + + if (!forceAnswerPull || !pullAnswers) { + seedServerCompletions(clientInfosFromApi) + } + + detailsDeferred.await() + + if (pullAnswers && apiSucceeded && clientCodesToSave.isNotEmpty()) { + val toPull = QuestionnaireAnswerSync.clientsNeedingPull( + context, + clientCodesToSave, + forceAnswerPull, + ) + if (toPull.isNotEmpty()) { + pacer.emit(SyncPhase.DOWNLOADING_ANSWERS) + val bulkDeferred = async(Dispatchers.IO) { + runCatching { QuestionnaireApiClient.fetchBulkClientAnswers(token) } + } + val bulkResult = bulkDeferred.await() + val prefetchedBundles = bulkResult.getOrNull() + if (bulkResult.isFailure) { + SyncLog.w( + TAG, + "bulk answer pull failed, import will retry fetch: ${bulkResult.exceptionOrNull()?.message}", + ) + } + QuestionnaireAnswerSync.pullForAllAssignedClients( + context, + token, + toPull, + force = forceAnswerPull, + prefetchedBundles = prefetchedBundles, + pacer = pacer, + ) + } else { + SyncLog.d(TAG, "all ${clientCodesToSave.size} clients fresh — skip answer pull") + } + } + + pacer.emitFinished() + SyncLog.d(TAG, "refreshAll complete") + } + + suspend fun refreshAssignedClients(context: Context, token: String): Boolean { + SyncLog.d(TAG, "refreshAssignedClients start") + val apiSucceeded = fetchAndPersistAssignedClients( + context = context, + token = token, + fallbackCodes = emptyList(), + pullAnswers = true, + forceAnswerPull = false, + onProgress = null, + ) + val saved = QuestionnaireCache.getAssignedClients(context) + SyncLog.d(TAG, "refreshAssignedClients done: ${saved.size} clients, api=$apiSucceeded") + return apiSucceeded + } + + private suspend fun fetchAndPersistAssignedClients( + context: Context, + token: String, + fallbackCodes: List, + pullAnswers: Boolean = false, + forceAnswerPull: Boolean = false, + onProgress: SyncProgressCallback? = null, + ): Boolean { + val pacer = SyncProgressPacer(onProgress) + pacer.emit(SyncPhase.DOWNLOADING_CLIENT_INFO) + val clientResult = runCatching { + QuestionnaireApiClient.getAssignedClients(token) + } + val clientInfosFromApi = clientResult.getOrElse { e -> + SyncLog.w(TAG, "getAssignedClients failed: ${e.message}") + emptyList() + } + val apiSucceeded = clientResult.isSuccess + + val clientCodesToSave: List = when { + apiSucceeded -> clientInfosFromApi.map { it.clientCode } + fallbackCodes.isNotEmpty() -> fallbackCodes + else -> emptyList() + } + + persistAssignedClients(context, apiSucceeded, fallbackCodes, clientCodesToSave) + + if (!forceAnswerPull || !pullAnswers) { + seedServerCompletions(clientInfosFromApi) + } + + if (pullAnswers && apiSucceeded && clientCodesToSave.isNotEmpty()) { + val toPull = QuestionnaireAnswerSync.clientsNeedingPull( + context, + clientCodesToSave, + forceAnswerPull, + ) + if (toPull.isNotEmpty()) { + QuestionnaireAnswerSync.pullForAllAssignedClients( + context, + token, + toPull, + force = forceAnswerPull, + pacer = pacer, + ) + } + } + + return apiSucceeded + } + + private suspend fun persistAssignedClients( + context: Context, + apiSucceeded: Boolean, + fallbackCodes: List, + clientCodesToSave: List, + ) { + when { + apiSucceeded -> { + QuestionnaireCache.replaceAssignedClients(context, clientCodesToSave) + SyncLog.d(TAG, "assigned clients from API: ${clientCodesToSave.size}") + } + fallbackCodes.isNotEmpty() -> { + QuestionnaireCache.replaceAssignedClients(context, clientCodesToSave) + SyncLog.d(TAG, "assigned clients from login fallback: ${clientCodesToSave.size}") + } + else -> SyncLog.w(TAG, "client fetch failed; keeping clients from login step only") + } + } + + private suspend fun seedServerCompletions(clientInfosFromApi: List) { + val toInsert = mutableListOf() + for (clientInfo in clientInfosFromApi) { + val existingByQn = com.dano.test1.data.CompletedQuestionnaireRepository + .getAllForClient(clientInfo.clientCode) + .associateBy { it.questionnaireId } + for (serverCompletion in clientInfo.completedQuestionnaires) { + val serverTimestampMs = serverCompletion.completedAt * 1000L + val existing = existingByQn[serverCompletion.questionnaireID] + if (existing == null || serverTimestampMs > existing.timestamp) { + toInsert.add( + CompletedQuestionnaire( + clientCode = clientInfo.clientCode, + questionnaireId = serverCompletion.questionnaireID, + timestamp = serverTimestampMs, + isDone = true, + sumPoints = serverCompletion.sumPoints, + uploadedAt = serverTimestampMs, + ), + ) + } + } + } + if (toInsert.isNotEmpty()) { + runCatching { + com.dano.test1.data.CompletedQuestionnaireRepository.insertAll(toInsert) + }.onSuccess { + SyncLog.d(TAG, "seeded ${toInsert.size} server completion(s)") + }.onFailure { e -> + SyncLog.w(TAG, "batch seed completions failed: ${e.message}") + } + } + } + + fun loadCachedTranslations(context: Context) { + QuestionnaireCache.applyTranslations(context) + } + + private suspend fun fetchAndCacheQuestionnaireDetails( + context: Context, + token: String, + questionnaires: List, + ) = coroutineScope { + if (questionnaires.isEmpty()) return@coroutineScope + val gate = Semaphore(DETAIL_FETCH_PARALLELISM) + questionnaires.map { item -> + async(Dispatchers.IO) { + gate.withPermit { + val cachedRev = QuestionnaireCache.getCachedStructureRevision(context, item.id) + if (cachedRev != null && item.structureRevision <= cachedRev) { + return@withPermit + } + val detail = QuestionnaireApiClient.getQuestionnaire(token, item.id) + QuestionnaireCache.saveQuestionnaireDetail(context, item.id, detail) + } + } + }.awaitAll() + SyncLog.d(TAG, "questionnaire details cached: ${questionnaires.size}") + } +} diff --git a/app/src/main/java/com/dano/test1/network/SyncCoordinator.kt b/app/src/main/java/com/dano/test1/network/SyncCoordinator.kt new file mode 100644 index 0000000..4430043 --- /dev/null +++ b/app/src/main/java/com/dano/test1/network/SyncCoordinator.kt @@ -0,0 +1,104 @@ +package com.dano.test1.network + +import android.content.Context +import android.widget.Toast +import com.dano.test1.LanguageManager + +sealed class SyncResult { + data object Success : SyncResult() + data class Failure(val error: Throwable?, val usedOfflineCache: Boolean = false) : SyncResult() + data object AuthRequired : SyncResult() + data object Offline : SyncResult() +} + +object SyncCoordinator { + + fun loadCachedTranslations(context: Context) { + QuestionnaireSyncService.loadCachedTranslations(context) + } + + /** Fetches app UI strings without login (login screen + offline cache refresh). */ + suspend fun refreshPublicTranslations(context: Context): Boolean { + if (!NetworkUtils.isOnline(context)) return false + return runCatching { + val data = QuestionnaireApiClient.getPublicTranslations() + QuestionnaireCache.saveTranslations(context, data) + }.isSuccess + } + + suspend fun refreshAfterLogin( + context: Context, + token: String, + languageId: String, + onProgress: SyncProgressCallback? = null, + ): SyncResult { + if (!NetworkUtils.isOnline(context)) { + loadCachedTranslations(context) + return SyncResult.Offline + } + val result = runCatching { + QuestionnaireSyncService.refreshAll( + context = context, + token = token, + onProgress = onProgress, + ) + } + val error = result.exceptionOrNull() + if (error != null && TokenStore.isAuthError(error)) { + return SyncResult.AuthRequired + } + if (result.isSuccess) return SyncResult.Success + loadCachedTranslations(context) + return SyncResult.Failure(error, usedOfflineCache = true) + } + + suspend fun refreshManual( + context: Context, + token: String, + onProgress: SyncProgressCallback? = null, + ): SyncResult { + if (!NetworkUtils.isOnline(context)) return SyncResult.Offline + val result = runCatching { + QuestionnaireSyncService.refreshAll( + context = context, + token = token, + onProgress = onProgress, + ) + } + val error = result.exceptionOrNull() + if (error != null && TokenStore.isAuthError(error)) return SyncResult.AuthRequired + return if (result.isSuccess) SyncResult.Success else SyncResult.Failure(error) + } + + suspend fun refreshClientsOnly(context: Context, token: String): SyncResult { + if (!NetworkUtils.isOnline(context)) return SyncResult.Offline + val result = runCatching { + QuestionnaireSyncService.refreshAssignedClients(context, token) + } + val error = result.exceptionOrNull() + if (error != null && TokenStore.isAuthError(error)) return SyncResult.AuthRequired + return if (result.isSuccess) SyncResult.Success else SyncResult.Failure(error) + } + + fun toastForResult(context: Context, languageId: String, result: SyncResult) { + when (result) { + is SyncResult.Failure -> { + if (result.usedOfflineCache) { + Toast.makeText( + context, + LanguageManager.getText(languageId, "download_failed_use_offline"), + Toast.LENGTH_LONG, + ).show() + } + } + SyncResult.Offline -> { + Toast.makeText( + context, + LanguageManager.getText(languageId, "offline"), + Toast.LENGTH_LONG, + ).show() + } + else -> {} + } + } +} diff --git a/app/src/main/java/com/dano/test1/network/SyncLog.kt b/app/src/main/java/com/dano/test1/network/SyncLog.kt new file mode 100644 index 0000000..a759f04 --- /dev/null +++ b/app/src/main/java/com/dano/test1/network/SyncLog.kt @@ -0,0 +1,37 @@ +package com.dano.test1.network + +import android.util.Log +import com.dano.test1.BuildConfig +import java.security.MessageDigest + +/** Debug-gated logging for sync/API paths (avoids logging full JSON bodies in production). */ +internal object SyncLog { + fun d(tag: String, message: String) { + if (BuildConfig.DEBUG) Log.d(tag, message) + } + + fun w(tag: String, message: String) { + if (BuildConfig.DEBUG) Log.w(tag, message) + } + + fun e(tag: String, message: String, throwable: Throwable? = null) { + if (!BuildConfig.DEBUG) return + if (throwable != null) Log.e(tag, message, throwable) else Log.e(tag, message) + } + + fun apiResponse(tag: String, label: String, httpCode: Int, body: String?) { + if (!BuildConfig.DEBUG) return + val bytes = body?.length ?: 0 + Log.d(tag, "$label → HTTP $httpCode (${bytes} bytes)") + } + + fun clientRef(clientCode: String?): String { + val normalized = clientCode?.trim().orEmpty() + if (normalized.isEmpty()) return "client=" + val digest = MessageDigest.getInstance("SHA-256") + .digest(normalized.uppercase().toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + .take(8) + return "clientHash=$digest" + } +} diff --git a/app/src/main/java/com/dano/test1/network/SyncProgress.kt b/app/src/main/java/com/dano/test1/network/SyncProgress.kt new file mode 100644 index 0000000..74414b6 --- /dev/null +++ b/app/src/main/java/com/dano/test1/network/SyncProgress.kt @@ -0,0 +1,18 @@ +package com.dano.test1.network + +/** User-facing sync step — no counts; shown as friendly status text only. */ +enum class SyncPhase { + PREPARING, + DOWNLOADING_QUESTIONNAIRES, + DOWNLOADING_CLIENT_INFO, + DOWNLOADING_ANSWERS, + PROCESSING, + SECURELY_SAVING, + FINISHED, +} + +data class SyncProgress( + val phase: SyncPhase, +) + +typealias SyncProgressCallback = (SyncProgress) -> Unit diff --git a/app/src/main/java/com/dano/test1/network/SyncProgressPacer.kt b/app/src/main/java/com/dano/test1/network/SyncProgressPacer.kt new file mode 100644 index 0000000..5ae8393 --- /dev/null +++ b/app/src/main/java/com/dano/test1/network/SyncProgressPacer.kt @@ -0,0 +1,30 @@ +package com.dano.test1.network + +import kotlinx.coroutines.delay + +/** Keeps each sync phase visible briefly without the old long artificial pause. */ +class SyncProgressPacer( + private val delegate: SyncProgressCallback?, +) { + private var lastEmitMs: Long = 0L + + suspend fun emit(phase: SyncPhase) { + val callback = delegate ?: return + val now = System.currentTimeMillis() + if (lastEmitMs > 0L) { + val wait = MIN_PHASE_MS - (now - lastEmitMs) + if (wait > 0L) delay(wait) + } + callback(SyncProgress(phase)) + lastEmitMs = System.currentTimeMillis() + } + + suspend fun emitFinished(phase: SyncPhase = SyncPhase.FINISHED) { + emit(phase) + delay(MIN_PHASE_MS) + } + + companion object { + const val MIN_PHASE_MS = 1_000L + } +} diff --git a/app/src/main/java/com/dano/test1/network/TokenStore.kt b/app/src/main/java/com/dano/test1/network/TokenStore.kt new file mode 100644 index 0000000..d5bbdcf --- /dev/null +++ b/app/src/main/java/com/dano/test1/network/TokenStore.kt @@ -0,0 +1,86 @@ +package com.dano.test1.network + +import android.content.Context +import android.util.Log +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey + +object TokenStore { + private const val TAG = "TokenStore" + private const val PREF = "qdb_secure_prefs" + private const val KEY_TOKEN = "token" + private const val KEY_USER = "user" + private const val KEY_LOGIN_TS = "login_ts" + + private const val SESSION_TTL_MS = 30L * 24 * 60 * 60 * 1000 + + private fun prefs(context: Context) = EncryptedSharedPreferences.create( + context, + PREF, + MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build(), + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + + fun save(context: Context, token: String, username: String) { + val now = System.currentTimeMillis() + prefs(context).edit() + .putString(KEY_TOKEN, token) + .putString(KEY_USER, username) + .putLong(KEY_LOGIN_TS, now) + .apply() + } + + fun getToken(context: Context): String? = + prefs(context).getString(KEY_TOKEN, null) + + fun getUsername(context: Context): String? = + prefs(context).getString(KEY_USER, null) + + fun getLoginTimestamp(context: Context): Long = + prefs(context).getLong(KEY_LOGIN_TS, 0L) + + fun clear(context: Context) { + prefs(context).edit().clear().apply() + } + + fun hasToken(context: Context): Boolean = !getToken(context).isNullOrBlank() + + fun ensureLegacyLoginTimestamp(context: Context) { + if (!hasToken(context)) return + if (getLoginTimestamp(context) != 0L) return + val now = System.currentTimeMillis() + prefs(context).edit().putLong(KEY_LOGIN_TS, now).apply() + Log.d(TAG, "Set login_ts for session without timestamp") + } + + fun isSessionValid(context: Context): Boolean { + ensureLegacyLoginTimestamp(context) + val token = getToken(context) + if (token.isNullOrBlank()) return false + val ts = getLoginTimestamp(context) + if (ts <= 0L) return false + return System.currentTimeMillis() - ts < SESSION_TTL_MS + } + + fun isAuthError(throwable: Throwable): Boolean { + val msg = throwable.message.orEmpty() + if (throwable is ApiException) { + val code = throwable.code.orEmpty() + if (code == "UNAUTHORIZED" + || code == "FORBIDDEN" + || code == "PASSWORD_CHANGE_REQUIRED" + ) { + return true + } + } + return msg.contains("401", ignoreCase = true) + || msg.contains("403", ignoreCase = true) + || msg.contains("Invalid or expired token", ignoreCase = true) + || msg.contains("Missing Bearer token", ignoreCase = true) + || msg.contains("Insufficient permissions", ignoreCase = true) + || msg.contains("Password change required", ignoreCase = true) + } +} diff --git a/app/src/main/java/com/dano/test1/notification/UploadReminderReceiver.kt b/app/src/main/java/com/dano/test1/notification/UploadReminderReceiver.kt new file mode 100644 index 0000000..751c012 --- /dev/null +++ b/app/src/main/java/com/dano/test1/notification/UploadReminderReceiver.kt @@ -0,0 +1,85 @@ +package com.dano.test1.notification + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.dano.test1.MainActivity +import com.dano.test1.MyApp +import com.dano.test1.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class UploadReminderReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + val pendingResult = goAsync() + + CoroutineScope(Dispatchers.IO).launch { + try { + // Only fire the notification if there are actually pending uploads + val hasPending = UploadReminderScheduler.hasPendingUploads() + if (hasPending) { + withContext(Dispatchers.Main) { + showNotification(context) + } + } + } finally { + pendingResult.finish() + } + } + } + + private fun showNotification(context: Context) { + ensureChannel(context) + + val tapIntent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val pendingIntent = PendingIntent.getActivity( + context, 0, tapIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_dot_16) + .setContentTitle("Data ready to upload") + .setContentText("You have completed questionnaires that have not been uploaded yet.") + .setStyle(NotificationCompat.BigTextStyle() + .bigText("You have completed questionnaires that have not been uploaded yet. Tap to open the app and upload.")) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .build() + + try { + NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification) + } catch (e: SecurityException) { + // POST_NOTIFICATIONS not granted — silently skip + } + } + + private fun ensureChannel(context: Context) { + val manager = context.getSystemService(NotificationManager::class.java) + if (manager.getNotificationChannel(CHANNEL_ID) != null) return + val channel = NotificationChannel( + CHANNEL_ID, + "Upload Reminders", + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = "Reminds you to upload completed questionnaires" + } + manager.createNotificationChannel(channel) + } + + companion object { + const val CHANNEL_ID = "upload_reminder" + const val NOTIFICATION_ID = 1001 + } +} diff --git a/app/src/main/java/com/dano/test1/notification/UploadReminderScheduler.kt b/app/src/main/java/com/dano/test1/notification/UploadReminderScheduler.kt new file mode 100644 index 0000000..55ce480 --- /dev/null +++ b/app/src/main/java/com/dano/test1/notification/UploadReminderScheduler.kt @@ -0,0 +1,68 @@ +package com.dano.test1.notification + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log + +object UploadReminderScheduler { + + private const val TAG = "UploadReminder" + private const val DELAY_MS = 2 * 60 * 60 * 1000L // 2 hours + + /** + * Schedule (or re-schedule) a reminder alarm for 2 hours from now. + * Call this every time a questionnaire is marked as completed. + * Safe to call multiple times — replaces any existing alarm. + */ + fun schedule(context: Context) { + val alarmManager = context.getSystemService(AlarmManager::class.java) ?: return + val triggerAt = System.currentTimeMillis() + DELAY_MS + + // On API 31+ we need canScheduleExactAlarms(); fall back to inexact if not granted + val pi = buildPendingIntent(context) + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) { + alarmManager.set(AlarmManager.RTC_WAKEUP, triggerAt, pi) + Log.d(TAG, "Inexact alarm scheduled (no SCHEDULE_EXACT_ALARM permission)") + } else { + alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi) + Log.d(TAG, "Exact alarm scheduled for +2h") + } + } catch (e: SecurityException) { + Log.w(TAG, "Could not schedule alarm: ${e.message}") + } + } + + /** + * Cancel any pending upload reminder. Call after a successful upload. + */ + fun cancel(context: Context) { + val alarmManager = context.getSystemService(AlarmManager::class.java) ?: return + alarmManager.cancel(buildPendingIntent(context)) + Log.d(TAG, "Alarm cancelled") + } + + /** + * Returns true if any assigned client has a pending (completed but not uploaded) questionnaire. + * Runs on the calling thread — call from a background coroutine. + */ + suspend fun hasPendingUploads(): Boolean { + return try { + com.dano.test1.data.PendingUploadRepository.hasAny() + } catch (e: Exception) { + Log.w(TAG, "hasPendingUploads check failed: ${e.message}") + false + } + } + + private fun buildPendingIntent(context: Context): PendingIntent { + val intent = Intent(context, UploadReminderReceiver::class.java) + return PendingIntent.getBroadcast( + context, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } +} diff --git a/app/src/main/java/com/dano/test1/questionnaire/AnswerKeyUtils.kt b/app/src/main/java/com/dano/test1/questionnaire/AnswerKeyUtils.kt new file mode 100644 index 0000000..e7a4b2e --- /dev/null +++ b/app/src/main/java/com/dano/test1/questionnaire/AnswerKeyUtils.kt @@ -0,0 +1,24 @@ +package com.dano.test1.questionnaire + +/** + * Stable keys for Room answer rows and submit mapping. + */ +object AnswerKeyUtils { + + /** Room `questions.questionId` / `answers.questionId` format. */ + fun roomQuestionId(questionnaireId: String, answerKey: String): String = + "$questionnaireId-$answerKey" + + /** + * Lookup key inside a per-client answer map. + * Accepts full room ids (`questionnaireId-key`) or short ids. + */ + fun lookupKey(questionnaireId: String, storedQuestionId: String): String { + val prefix = "$questionnaireId-" + return if (storedQuestionId.startsWith(prefix)) { + storedQuestionId.removePrefix(prefix) + } else { + storedQuestionId.substringAfterLast('-') + } + } +} diff --git a/app/src/main/java/com/dano/test1/questionnaire/AnswerLookup.kt b/app/src/main/java/com/dano/test1/questionnaire/AnswerLookup.kt new file mode 100644 index 0000000..a59d881 --- /dev/null +++ b/app/src/main/java/com/dano/test1/questionnaire/AnswerLookup.kt @@ -0,0 +1,23 @@ +package com.dano.test1.questionnaire + +/** + * Resolves stored answers from the in-memory map using the same keys as persistence/sync. + */ +object AnswerLookup { + + fun stringValue(answers: Map, question: QuestionItem.StringSpinnerQuestion): String? = + firstString(answers, listOf(question.question, question.id)) + + fun valueSpinnerValue(answers: Map, question: QuestionItem.ValueSpinnerQuestion): String? = + firstString(answers, listOf(question.question, question.id)) + + private fun firstString(answers: Map, keys: List): String? { + for (key in keys) { + if (key.isBlank()) continue + val raw = answers[key] ?: continue + val text = raw.toString().trim() + if (text.isNotEmpty()) return text + } + return null + } +} diff --git a/app/src/main/java/com/dano/test1/questionnaire/AnswerRestoreHelper.kt b/app/src/main/java/com/dano/test1/questionnaire/AnswerRestoreHelper.kt new file mode 100644 index 0000000..0ce2180 --- /dev/null +++ b/app/src/main/java/com/dano/test1/questionnaire/AnswerRestoreHelper.kt @@ -0,0 +1,37 @@ +package com.dano.test1.questionnaire + +import com.dano.test1.data.AnswerRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +object AnswerRestoreHelper { + + /** + * Loads a prior answer from Room when the in-memory map has no value yet. + */ + fun restoreIfMissing( + scope: CoroutineScope, + questionnaireId: String, + answerMapKey: String, + onRestored: (String) -> Unit, + ) { + scope.launch(Dispatchers.IO) { + try { + val clientCode = GlobalValues.LAST_CLIENT_CODE + if (clientCode.isNullOrBlank() || answerMapKey.isBlank()) return@launch + + val roomId = AnswerKeyUtils.roomQuestionId(questionnaireId, answerMapKey) + val dbAnswer = AnswerRepository.findAnswerValue( + AnswerRepository.getAnswersForClient(clientCode), + roomId, + ) + if (!dbAnswer.isNullOrBlank()) { + withContext(Dispatchers.Main) { onRestored(dbAnswer) } + } + } catch (_: Exception) { + } + } + } +} diff --git a/app/src/main/java/com/dano/test1/questionnaire/AppLanguages.kt b/app/src/main/java/com/dano/test1/questionnaire/AppLanguages.kt new file mode 100644 index 0000000..c3f2a9e --- /dev/null +++ b/app/src/main/java/com/dano/test1/questionnaire/AppLanguages.kt @@ -0,0 +1,69 @@ +package com.dano.test1.questionnaire + +import com.dano.test1.LanguageManager + +/** + * Single source for coach language selection across opening screen and questionnaires. + */ +object AppLanguages { + const val DEFAULT_ID = "GERMAN" + + val ids: List = listOf( + "GERMAN", "ENGLISH", "FRENCH", "ROMANIAN", "ARABIC", + "POLISH", "TURKISH", "UKRAINIAN", "RUSSIAN", "SPANISH", + ) + + /** Short labels shown on in-questionnaire language spinners. */ + val shortLabels: List = listOf( + "DE", "EN", "FR", "RO", "AR", + "PL", "TR", "UA", "RU", "ES", + ) + + /** Human-readable names for language spinners (opening screen, upload). */ + fun spinnerLabels(uiLanguageId: String): List = + ids.map { displayName(uiLanguageId, it) } + + fun displayName(uiLanguageId: String, languageId: String): String { + val apiKey = "language_${languageId.lowercase()}" + val fromApi = LanguageManager.getText(uiLanguageId, apiKey) + if (fromApi != apiKey) return fromApi + return nativeNamesFor(uiLanguageId)[languageId] + ?: nativeNamesFor("ENGLISH")[languageId] + ?: shortLabels.getOrElse(indexOf(languageId)) { languageId } + } + + fun indexOf(languageId: String): Int = + ids.indexOf(languageId).coerceAtLeast(0) + + private fun nativeNamesFor(uiLanguageId: String): Map = when (uiLanguageId) { + "GERMAN" -> NAMES_DE + "ENGLISH" -> NAMES_EN + else -> NAMES_EN + } + + private val NAMES_DE = mapOf( + "GERMAN" to "Deutsch", + "ENGLISH" to "Englisch", + "FRENCH" to "Französisch", + "ROMANIAN" to "Rumänisch", + "ARABIC" to "Arabisch", + "POLISH" to "Polnisch", + "TURKISH" to "Türkisch", + "UKRAINIAN" to "Ukrainisch", + "RUSSIAN" to "Russisch", + "SPANISH" to "Spanisch", + ) + + private val NAMES_EN = mapOf( + "GERMAN" to "German", + "ENGLISH" to "English", + "FRENCH" to "French", + "ROMANIAN" to "Romanian", + "ARABIC" to "Arabic", + "POLISH" to "Polish", + "TURKISH" to "Turkish", + "UKRAINIAN" to "Ukrainian", + "RUSSIAN" to "Russian", + "SPANISH" to "Spanish", + ) +} diff --git a/app/src/main/java/com/dano/test1/questionnaire/ClientAssignmentGuard.kt b/app/src/main/java/com/dano/test1/questionnaire/ClientAssignmentGuard.kt new file mode 100644 index 0000000..78f87ce --- /dev/null +++ b/app/src/main/java/com/dano/test1/questionnaire/ClientAssignmentGuard.kt @@ -0,0 +1,19 @@ +package com.dano.test1.questionnaire + +import android.content.Context +import com.dano.test1.network.QuestionnaireCache + +object ClientAssignmentGuard { + + fun isAssigned(context: Context, clientCode: String): Boolean { + if (clientCode.isBlank()) return false + val assigned = QuestionnaireCache.getAssignedClients(context) + return assigned.isNotEmpty() && + assigned.any { it.equals(clientCode, ignoreCase = true) } + } + + fun canPersistAnswers(context: Context, answers: Map): Boolean { + val clientCode = answers["client_code"] as? String ?: return false + return isAssigned(context, clientCode) + } +} diff --git a/app/src/main/java/com/dano/test1/questionnaire/ConditionJsonParser.kt b/app/src/main/java/com/dano/test1/questionnaire/ConditionJsonParser.kt new file mode 100644 index 0000000..3f29ca2 --- /dev/null +++ b/app/src/main/java/com/dano/test1/questionnaire/ConditionJsonParser.kt @@ -0,0 +1,88 @@ +package com.dano.test1.questionnaire + +import android.util.Log +import org.json.JSONArray +import org.json.JSONObject + +/** + * Parses questionnaire availability conditions from server [conditionJson] / API `condition` objects. + * Same schema as web dashboard and legacy assets/questionnaire_order.json. + */ +object ConditionJsonParser { + private const val TAG = "ConditionJsonParser" + + /** App string key for locked-card subtitle / toast (from conditionJson.messageKey). */ + fun messageKey(conditionJson: String?): String? { + if (conditionJson.isNullOrBlank() || conditionJson.trim() == "{}") return null + return try { + JSONObject(conditionJson).optString("messageKey").takeIf { it.isNotBlank() } + } catch (e: Exception) { + Log.w(TAG, "messageKey parse failed: ${e.message}") + null + } + } + + fun parse(conditionJson: String?): QuestionItem.Condition? { + if (conditionJson.isNullOrBlank() || conditionJson.trim() == "{}") return null + return try { + parse(JSONObject(conditionJson)) + } catch (e: Exception) { + Log.w(TAG, "Invalid condition JSON: ${e.message}") + null + } + } + + fun parse(conditionObj: JSONObject?): QuestionItem.Condition? { + if (conditionObj == null) return null + if (conditionObj.has("anyOf")) { + val arr = conditionObj.optJSONArray("anyOf") ?: JSONArray() + val conditions = mutableListOf() + for (i in 0 until arr.length()) { + val sub = arr.optJSONObject(i) + parse(sub)?.let { conditions.add(it) } + } + return QuestionItem.Condition.AnyOf(conditions) + } + if (conditionObj.has("alwaysAvailable") && conditionObj.optBoolean("alwaysAvailable", false)) { + return QuestionItem.Condition.AlwaysAvailable + } + val requiresList = mutableListOf() + if (conditionObj.has("requiresCompleted")) { + val reqArr = conditionObj.optJSONArray("requiresCompleted") + if (reqArr != null) { + for (i in 0 until reqArr.length()) requiresList.add(reqArr.optString(i)) + } else { + conditionObj.optString("requiresCompleted")?.let { if (it.isNotBlank()) requiresList.add(it) } + } + } + val questionnaire = conditionObj.optString("questionnaire").takeIf { it.isNotBlank() } + val questionId = conditionObj.optString("questionId").takeIf { it.isNotBlank() } + val operator = conditionObj.optString("operator").takeIf { it.isNotBlank() } + val value = conditionObj.optString("value").takeIf { it.isNotBlank() } + val hasQuestionCheck = + !questionnaire.isNullOrBlank() && !questionId.isNullOrBlank() && + !operator.isNullOrBlank() && value != null + return when { + requiresList.isNotEmpty() && hasQuestionCheck -> + QuestionItem.Condition.Combined( + requiresList, + QuestionItem.Condition.QuestionCondition( + questionnaire!!, + questionId!!, + operator!!, + value!! + ) + ) + hasQuestionCheck -> + QuestionItem.Condition.QuestionCondition( + questionnaire!!, + questionId!!, + operator!!, + value!! + ) + requiresList.isNotEmpty() -> + QuestionItem.Condition.RequiresCompleted(requiresList) + else -> null + } + } +} diff --git a/app/src/main/java/com/dano/test1/questionnaire/QuestionFlowResolver.kt b/app/src/main/java/com/dano/test1/questionnaire/QuestionFlowResolver.kt new file mode 100644 index 0000000..9e7e5e7 --- /dev/null +++ b/app/src/main/java/com/dano/test1/questionnaire/QuestionFlowResolver.kt @@ -0,0 +1,141 @@ +package com.dano.test1.questionnaire + +import com.dano.test1.LanguageManager + +object QuestionFlowResolver { + + fun questionLevelNextId(nextQuestionId: String?): String? = + nextQuestionId?.trim()?.takeIf { it.isNotEmpty() } + + fun navigateAfterAnswer( + nextQuestionId: String?, + goToQuestionById: (String) -> Unit, + goToNextQuestion: () -> Unit, + ) { + val nextId = questionLevelNextId(nextQuestionId) + if (nextId != null) { + goToQuestionById(nextId) + } else { + goToNextQuestion() + } + } + + fun nextQuestionIdForRadio(question: QuestionItem.RadioQuestion, selectedKey: String): String? = + question.options?.find { it.key == selectedKey }?.nextQuestionId + + fun nextQuestionIdForValueSpinner( + question: QuestionItem.ValueSpinnerQuestion, + selectedValue: Int?, + ): String? { + if (selectedValue != null) { + question.options?.find { it.value == selectedValue }?.nextQuestionId + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { return it } + } + return questionLevelNextId(question.nextQuestionId) + } + + fun nextQuestionIdForMultiCheckbox( + question: QuestionItem.MultiCheckboxQuestion, + selectedKeys: Set, + ): String? { + val otherKey = question.otherOptionKey?.trim()?.takeIf { it.isNotEmpty() } + val otherNext = question.otherNextQuestionId?.trim()?.takeIf { it.isNotEmpty() } + if (otherKey != null && otherNext != null && selectedKeys.contains(otherKey)) { + return otherNext + } + return questionLevelNextId(question.nextQuestionId) + } + + fun nextQuestionIdForStringSpinner( + question: QuestionItem.StringSpinnerQuestion, + languageId: String, + selected: String, + ): String? { + val otherKey = question.otherOptionKey?.trim()?.takeIf { it.isNotEmpty() } + val otherNext = question.otherNextQuestionId?.trim()?.takeIf { it.isNotEmpty() } + if (otherKey != null && otherNext != null) { + val otherLabel = LanguageManager.getText(languageId, otherKey) + if (selected == otherLabel || selected == otherKey) return otherNext + } + return questionLevelNextId(question.nextQuestionId) + } + + fun hasBranchingOptions(question: QuestionItem): Boolean = when (question) { + is QuestionItem.RadioQuestion -> + question.options?.any { !it.nextQuestionId.isNullOrBlank() } == true + is QuestionItem.ValueSpinnerQuestion -> + question.options?.any { !it.nextQuestionId.isNullOrBlank() } == true || + !question.nextQuestionId.isNullOrBlank() + is QuestionItem.StringSpinnerQuestion -> + !question.nextQuestionId.isNullOrBlank() || !question.otherNextQuestionId.isNullOrBlank() + is QuestionItem.FreeTextQuestion -> + !question.nextQuestionId.isNullOrBlank() + is QuestionItem.MultiCheckboxQuestion -> + !question.nextQuestionId.isNullOrBlank() || !question.otherNextQuestionId.isNullOrBlank() + is QuestionItem.DateSpinnerQuestion -> + !question.nextQuestionId.isNullOrBlank() + is QuestionItem.SliderQuestion -> + !question.nextQuestionId.isNullOrBlank() + is QuestionItem.GlassScaleQuestion -> + !question.nextQuestionId.isNullOrBlank() + is QuestionItem.ClientCoachCodeQuestion -> + !question.nextQuestionId.isNullOrBlank() + else -> false + } + + /** + * Walk questions in order using [answerProvider] for branching decisions (All-in-One visibility). + */ + fun visibleQuestionIndices( + questions: List, + answerProvider: (QuestionItem) -> BranchAnswer?, + ): Set { + val visible = mutableSetOf() + var i = 0 + while (i < questions.size) { + val q = questions[i] + if (q is QuestionItem.LastPage) { + i++ + continue + } + visible.add(i) + val branch = answerProvider(q) + val nextId = branch?.let { resolveNextId(q, it) } + when { + nextId != null -> { + if (questions.any { it is QuestionItem.LastPage && it.id == nextId }) break + val target = questions.indexOfFirst { it.id == nextId } + i = if (target != -1) target else i + 1 + } + hasBranchingOptions(q) && branch == null -> break + else -> i++ + } + } + return visible + } + + private fun resolveNextId(question: QuestionItem, branch: BranchAnswer): String? = when (question) { + is QuestionItem.RadioQuestion -> branch.selectedKey?.let { nextQuestionIdForRadio(question, it) } + is QuestionItem.ValueSpinnerQuestion -> nextQuestionIdForValueSpinner(question, branch.selectedInt) + is QuestionItem.FreeTextQuestion -> questionLevelNextId(question.nextQuestionId) + is QuestionItem.StringSpinnerQuestion -> + branch.selectedDisplay?.let { nextQuestionIdForStringSpinner(question, branch.languageId, it) } + is QuestionItem.MultiCheckboxQuestion -> + branch.selectedKeys?.let { nextQuestionIdForMultiCheckbox(question, it) } + is QuestionItem.DateSpinnerQuestion -> questionLevelNextId(question.nextQuestionId) + is QuestionItem.SliderQuestion -> questionLevelNextId(question.nextQuestionId) + is QuestionItem.GlassScaleQuestion -> questionLevelNextId(question.nextQuestionId) + is QuestionItem.ClientCoachCodeQuestion -> questionLevelNextId(question.nextQuestionId) + else -> null + } + + data class BranchAnswer( + val languageId: String, + val selectedKey: String? = null, + val selectedInt: Int? = null, + val selectedDisplay: String? = null, + val selectedKeys: Set? = null, + ) +} diff --git a/app/src/main/java/com/dano/test1/questionnaire/QuestionHandler.kt b/app/src/main/java/com/dano/test1/questionnaire/QuestionHandler.kt new file mode 100644 index 0000000..fda19e2 --- /dev/null +++ b/app/src/main/java/com/dano/test1/questionnaire/QuestionHandler.kt @@ -0,0 +1,10 @@ +package com.dano.test1.questionnaire +import android.view.View + +interface QuestionHandler { + fun bind(layout: View, question: QuestionItem) + + fun validate(): Boolean + + fun saveAnswer() +} diff --git a/app/src/main/java/com/dano/test1/questionnaire/QuestionHandlerFactory.kt b/app/src/main/java/com/dano/test1/questionnaire/QuestionHandlerFactory.kt new file mode 100644 index 0000000..e6b6180 --- /dev/null +++ b/app/src/main/java/com/dano/test1/questionnaire/QuestionHandlerFactory.kt @@ -0,0 +1,73 @@ +package com.dano.test1.questionnaire + +import android.content.Context +import com.dano.test1.questionnaire.handlers.HandlerClientCoachCode +import com.dano.test1.questionnaire.handlers.HandlerDateSpinner +import com.dano.test1.questionnaire.handlers.HandlerFreeText +import com.dano.test1.questionnaire.handlers.HandlerGlassScaleQuestion +import com.dano.test1.questionnaire.handlers.HandlerLastPage +import com.dano.test1.questionnaire.handlers.HandlerMultiCheckboxQuestion +import com.dano.test1.questionnaire.handlers.HandlerRadioQuestion +import com.dano.test1.questionnaire.handlers.HandlerSliderQuestion +import com.dano.test1.questionnaire.handlers.HandlerStringSpinner +import com.dano.test1.questionnaire.handlers.HandlerValueSpinner + +enum class NavigationMode { Step, Embedded } + +data class HandlerDependencies( + val context: Context, + val answers: MutableMap, + val languageID: String, + val questionnaireMetaId: String, + val goToNext: () -> Unit, + val goToPrev: () -> Unit, + val goToById: (String) -> Unit, + val showToast: (String) -> Unit, + val saveAnswers: suspend (Map) -> Unit = {}, + val onAnswerChanged: () -> Unit = {}, +) + +object QuestionHandlerFactory { + + fun create(question: QuestionItem, mode: NavigationMode, deps: HandlerDependencies): QuestionHandler? { + val next = if (mode == NavigationMode.Embedded) ({}) else deps.goToNext + val prev = if (mode == NavigationMode.Embedded) ({}) else deps.goToPrev + val byId: (String) -> Unit = if (mode == NavigationMode.Embedded) ({ _ -> }) else deps.goToById + + return when (question) { + is QuestionItem.RadioQuestion -> HandlerRadioQuestion( + deps.context, deps.answers, deps.languageID, + next, prev, byId, deps.showToast, deps.questionnaireMetaId, + ) + is QuestionItem.ClientCoachCodeQuestion -> HandlerClientCoachCode( + deps.answers, deps.languageID, next, prev, byId, deps.showToast, + ) + is QuestionItem.DateSpinnerQuestion -> HandlerDateSpinner( + deps.context, deps.answers, deps.languageID, next, prev, byId, deps.showToast, deps.questionnaireMetaId, + ) + is QuestionItem.ValueSpinnerQuestion -> HandlerValueSpinner( + deps.context, deps.answers, deps.languageID, next, prev, byId, deps.showToast, deps.questionnaireMetaId, + ) + is QuestionItem.SliderQuestion -> HandlerSliderQuestion( + deps.context, deps.answers, deps.languageID, next, prev, byId, deps.showToast, deps.onAnswerChanged, + ) + is QuestionItem.GlassScaleQuestion -> HandlerGlassScaleQuestion( + deps.context, deps.answers, deps.languageID, next, prev, byId, deps.showToast, deps.questionnaireMetaId, + onAnswerChanged = deps.onAnswerChanged, + ) + is QuestionItem.StringSpinnerQuestion -> HandlerStringSpinner( + deps.context, deps.answers, deps.languageID, next, prev, byId, deps.showToast, deps.questionnaireMetaId, + ) + is QuestionItem.MultiCheckboxQuestion -> HandlerMultiCheckboxQuestion( + deps.context, deps.answers, deps.languageID, next, prev, byId, deps.showToast, deps.questionnaireMetaId, + ) + is QuestionItem.FreeTextQuestion -> HandlerFreeText( + deps.context, deps.answers, deps.languageID, next, prev, byId, deps.showToast, deps.questionnaireMetaId, + ) + is QuestionItem.LastPage -> HandlerLastPage( + deps.answers, deps.languageID, next, prev, deps.saveAnswers, + ) + else -> null + } + } +} diff --git a/app/src/main/java/com/dano/test1/questionnaire/QuestionNotesHelper.kt b/app/src/main/java/com/dano/test1/questionnaire/QuestionNotesHelper.kt new file mode 100644 index 0000000..de29648 --- /dev/null +++ b/app/src/main/java/com/dano/test1/questionnaire/QuestionNotesHelper.kt @@ -0,0 +1,36 @@ +package com.dano.test1.questionnaire + +import android.view.View +import android.widget.TextView +import com.dano.test1.LanguageManager +import com.dano.test1.R +import com.dano.test1.utils.ViewUtils + +/** Renders optional noteBeforeKey / noteAfterKey above and below the main question text. */ +object QuestionNotesHelper { + + fun bind(layout: View, languageId: String, noteBeforeKey: String?, noteAfterKey: String?) { + val before = layout.findViewById(R.id.note_before) + val after = layout.findViewById(R.id.note_after) + bindView(before, languageId, noteBeforeKey) + bindView(after, languageId, noteAfterKey) + } + + private fun bindView(view: TextView?, languageId: String, key: String?) { + if (view == null) return + val text = key?.takeIf { it.isNotBlank() }?.let { LanguageManager.getText(languageId, it) }.orEmpty() + if (text.isBlank()) { + view.visibility = View.GONE + view.text = "" + } else { + view.visibility = View.VISIBLE + view.text = text + ViewUtils.setTextSizePercentOfScreenHeight(view, 0.025f) + } + } +} + +interface QuestionWithNotes { + val noteBeforeKey: String? + val noteAfterKey: String? +} diff --git a/app/src/main/java/com/dano/test1/questionnaire/QuestionnaireAllInOne.kt b/app/src/main/java/com/dano/test1/questionnaire/QuestionnaireAllInOne.kt new file mode 100644 index 0000000..24c2a72 --- /dev/null +++ b/app/src/main/java/com/dano/test1/questionnaire/QuestionnaireAllInOne.kt @@ -0,0 +1,1192 @@ +package com.dano.test1.questionnaire + +import android.graphics.Color +import android.text.Editable +import android.text.TextWatcher +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.* +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.ContextCompat +import com.dano.test1.LanguageManager +import com.dano.test1.MainActivity +import com.dano.test1.R +import com.dano.test1.network.TokenStore +import com.dano.test1.questionnaire.handlers.HandlerStringSpinner +import com.dano.test1.questionnaire.handlers.HandlerValueSpinner +import com.dano.test1.ui.AppLanguageStore +import com.dano.test1.ui.ReviewScoresHandler +import com.dano.test1.utils.DateAnswerFormat +import com.dano.test1.utils.DatePrecision +import com.dano.test1.utils.ScreenLoading +import com.dano.test1.utils.TextInputSanitizer +import com.dano.test1.utils.ViewUtils +import com.google.android.material.button.MaterialButton +import com.google.android.material.card.MaterialCardView +import kotlinx.coroutines.* +import java.util.Calendar + +class QuestionnaireAllInOne(private val questionnaireFileName: String) : QuestionnaireBase() { + + private companion object { + /** First frame inflates one section for fast reveal; subsequent batches use [sectionsPerFrame]. */ + const val INITIAL_SECTIONS_PER_FRAME = 1 + } + + private data class Section( + val index: Int, + val question: QuestionItem, + val card: MaterialCardView, + val sectionView: View, + val handler: QuestionHandler? + ) + + private val sections = mutableListOf
() + private lateinit var container: LinearLayout + private lateinit var btnSave: MaterialButton + private lateinit var btnBack: MaterialButton + private lateinit var contentLoadingOverlay: View + private lateinit var scrollContainer: View + private var setupComplete = false + private var initialSetupComplete = false + private var uiRevealed = false + private var suppressSpinnerEvents = false + /** Answers as loaded from storage when editing started; not reset during branch reinflation. */ + private var persistedBaselineAnswers: Map = emptyMap() + private var initialVisibleIndices: Set = emptySet() + private var uiChangedSinceBaseline = false + private var answerLoadJob: Job? = null + private var inflationGeneration = 0 + private var questionsLoading = false + private val pendingInflationQueue = mutableListOf() + private val listenerInstalledIndices = mutableSetOf() + + override fun startQuestionnaire() { + val (meta, questionsList) = loadQuestionnaireFromJson(questionnaireFileName) + questionnaireMeta = meta + questions = questionsList + currentIndex = 0 + ViewUtils.setTextSizeScale(ViewUtils.EMBEDDED_TEXT_SCALE) + + val clientCode = GlobalValues.LOADED_CLIENT_CODE?.trim().orEmpty() + .ifEmpty { GlobalValues.LAST_CLIENT_CODE?.trim().orEmpty() } + answerLoadJob = CoroutineScope(Dispatchers.Main).launch { + if (clientCode.isNotEmpty()) { + 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 } + } + persistedBaselineAnswers = snapshotAnswerKeys(answers) + } + buildAllInOneUi() + } + + override fun showCurrentQuestion() { + // Not used in all-in-one mode + } + + private fun buildAllInOneUi() { + sections.clear() + setupComplete = false + initialSetupComplete = false + initialVisibleIndices = emptySet() + uiRevealed = false + questionsLoading = true + pendingInflationQueue.clear() + listenerInstalledIndices.clear() + ViewUtils.setTextSizeScale(ViewUtils.EMBEDDED_TEXT_SCALE) + + context.setContentView(R.layout.questionnaire_all_in_one) + ViewUtils.suppressAutofillForSubtree(context.findViewById(android.R.id.content)) + + container = context.findViewById(R.id.questionContainer) + btnSave = context.findViewById(R.id.btnSave) + btnBack = context.findViewById(R.id.btnBack) + val progressBar = context.findViewById(R.id.progressBar) + contentLoadingOverlay = context.findViewById(R.id.questionnaireSaveLoading) + scrollContainer = context.findViewById(R.id.scrollContainer) + ViewUtils.suppressAutofillForSubtree(container) + + btnSave.text = LanguageManager.getText(languageID, "save") + ViewUtils.setTextSizePercentOfScreenHeight(btnSave, 0.025f) + + btnSave.isEnabled = false + btnSave.alpha = 0.5f + + btnBack.setOnClickListener { + (context as? MainActivity)?.finishQuestionnaire() + } + + setupLanguageSpinner(context.findViewById(R.id.langSpinner)) + + ScreenLoading.setVisible(contentLoadingOverlay, true, scrollContainer, btnSave, btnBack) + + container.post { + CoroutineScope(Dispatchers.Main).launch { + answerLoadJob?.join() + if (!isQuestionnaireActive()) return@launch + beginStaggeredInflation() + } + } + + btnSave.setOnClickListener { + handleSave(progressBar, contentLoadingOverlay, scrollContainer) + } + } + + private fun isQuestionnaireActive(): Boolean = + context.findViewById(R.id.questionContainer) != null + + private fun beginStaggeredInflation() { + val generation = ++inflationGeneration + questionsLoading = true + updateSaveButtonState() + sections.clear() + container.removeAllViews() + pendingInflationQueue.clear() + pendingInflationQueue.addAll(initialQuestionIndicesToInflate()) + inflateQuestionSectionsBatch(generation) + } + + private fun initialQuestionIndicesToInflate(): List = + QuestionFlowResolver.visibleQuestionIndices(questions, ::branchAnswerFromAnswers).sorted() + + private fun inflateQuestionSectionsBatch(generation: Int) { + if (generation != inflationGeneration || !isQuestionnaireActive()) return + + var inflatedThisFrame = 0 + + while (pendingInflationQueue.isNotEmpty() && inflatedThisFrame < sectionsPerFrame()) { + val idx = pendingInflationQueue.removeAt(0) + inflateSingleSection(idx) + inflatedThisFrame++ + } + + if (!uiRevealed && sections.isNotEmpty()) { + revealQuestionnaireUi() + } + + if (pendingInflationQueue.isNotEmpty()) { + container.post { inflateQuestionSectionsBatch(generation) } + } else if (initialSetupComplete) { + finishDynamicInflation(generation) + } else { + finishInitialInflationSetup(generation) + } + } + + private fun sectionsPerFrame(): Int = if (uiRevealed) 3 else 1 + + private fun inflateSingleSection(idx: Int): Section? { + if (sections.any { it.index == idx }) { + return sections.find { it.index == idx } + } + + val question = questions.getOrNull(idx) ?: return null + if (question is QuestionItem.LastPage) return null + + val layoutResId = getLayoutResId(question.layout ?: "default_layout") + if (layoutResId == 0) return null + + val inflater = LayoutInflater.from(context) + val sectionView = inflater.inflate(layoutResId, container, false) + adaptLayoutForEmbedding(sectionView) + + val card = wrapInCard(sectionView) + val insertAt = sections.indexOfFirst { it.index > idx }.let { if (it == -1) sections.size else it } + container.addView(card, insertAt) + + val handler = QuestionHandlerFactory.create( + question, + NavigationMode.Embedded, + handlerDependencies().copy( + onAnswerChanged = { + notifyAnswerChanged(question) + }, + ), + ) + suppressSpinnerEvents = true + try { + handler?.bind(sectionView, question) + } finally { + suppressSpinnerEvents = false + } + + val section = Section(idx, question, card, sectionView, handler) + sections.add(insertAt, section) + + if (setupComplete) { + installChangeListenersForSection(section) + } + + return section + } + + private fun revealQuestionnaireUi() { + uiRevealed = true + ScreenLoading.setVisible(contentLoadingOverlay, false, scrollContainer, btnSave, btnBack) + } + + private fun finishInitialInflationSetup(generation: Int) { + if (generation != inflationGeneration || !isQuestionnaireActive()) return + installChangeListeners() + recalculateVisibility() + setupComplete = true + if (pendingInflationQueue.isNotEmpty()) { + container.post { inflateQuestionSectionsBatch(generation) } + return + } + completeInitialSetup(generation) + } + + private fun completeInitialSetup(generation: Int) { + container.post { + if (generation != inflationGeneration || !isQuestionnaireActive()) return@post + if (!uiRevealed) revealQuestionnaireUi() + initialVisibleIndices = currentVisibleIndices() + uiChangedSinceBaseline = false + initialSetupComplete = true + questionsLoading = false + updateSaveButtonState() + } + } + + private fun finishDynamicInflation(generation: Int) { + if (generation != inflationGeneration || !isQuestionnaireActive()) return + installChangeListeners() + recalculateVisibility() + container.post { + if (generation != inflationGeneration || !isQuestionnaireActive()) return@post + questionsLoading = false + updateSaveButtonState() + } + } + + private fun currentVisibleIndices(): Set = + 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<*>?) {} + } + } + + private fun onLanguageChanged(newLang: String) { + for (section in sections) { + if (section.card.visibility == View.VISIBLE) { + try { section.handler?.saveAnswer() } catch (_: Exception) {} + } + } + languageID = newLang + com.dano.test1.ui.AppLanguageStore.set(context, newLang) + persistedBaselineAnswers = snapshotAnswerKeys(answers) + uiChangedSinceBaseline = false + buildAllInOneUi() + } + + private fun adaptLayoutForEmbedding(view: View) { + if (view is ConstraintLayout) { + view.layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + // Ensure the last field in the card is never clipped at the bottom + view.setPadding( + view.paddingLeft, view.paddingTop, + view.paddingRight, ViewUtils.dp(context, 12) + ) + } + + view.findViewById(R.id.Qprev)?.visibility = View.GONE + view.findViewById(R.id.Qnext)?.visibility = View.GONE + + view.findViewById(R.id.gTop)?.let { guideline -> + if (guideline is androidx.constraintlayout.widget.Guideline) { + val params = guideline.layoutParams as? ConstraintLayout.LayoutParams + params?.guideBegin = ViewUtils.dp(context, 8) + guideline.layoutParams = params + } + } + + adaptInnerScrollView(view, R.id.radioScroll) + adaptInnerScrollView(view, R.id.scrollView) + adaptInnerScrollView(view, R.id.glassScroll) + + view.findViewById(R.id.client_code)?.let { et -> + ViewUtils.styleFormEditText(et) + val params = et.layoutParams as? ConstraintLayout.LayoutParams ?: return@let + if (params.matchConstraintPercentHeight > 0f) { + params.matchConstraintPercentHeight = 0f + params.height = ViewUtils.dp(context, 64) + et.layoutParams = params + } + } + view.findViewById(R.id.coach_code)?.let { et -> + ViewUtils.styleFormEditText(et) + val params = et.layoutParams as? ConstraintLayout.LayoutParams ?: return@let + if (params.matchConstraintPercentHeight > 0f) { + params.matchConstraintPercentHeight = 0f + params.height = ViewUtils.dp(context, 64) + et.layoutParams = params + } + } + view.findViewById(R.id.free_text_input)?.let { et -> + ViewUtils.styleFormEditText(et) + val params = et.layoutParams as? ConstraintLayout.LayoutParams ?: return@let + if (params.matchConstraintPercentHeight > 0f) { + params.matchConstraintPercentHeight = 0f + params.height = ViewUtils.dp(context, 120) + et.layoutParams = params + } + } + } + + private fun adaptInnerScrollView(root: View, scrollViewId: Int) { + val sv = root.findViewById(scrollViewId) ?: return + val params = sv.layoutParams + if (params is ConstraintLayout.LayoutParams) { + params.height = ConstraintLayout.LayoutParams.WRAP_CONTENT + params.bottomToTop = ConstraintLayout.LayoutParams.UNSET + params.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID + sv.layoutParams = params + } + sv.isNestedScrollingEnabled = false + } + + private fun wrapInCard(sectionView: View): MaterialCardView { + val card = MaterialCardView(context).apply { + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { + val margin = ViewUtils.dp(context, 6) + setMargins(0, margin, 0, margin) + } + radius = ViewUtils.dp(context, 16).toFloat() + cardElevation = ViewUtils.dp(context, 2).toFloat() + setCardBackgroundColor(ContextCompat.getColor(context, R.color.brand_surface)) + strokeColor = ContextCompat.getColor(context, R.color.brand_stroke) + strokeWidth = ViewUtils.dp(context, 1) + setContentPadding( + ViewUtils.dp(context, 4), + ViewUtils.dp(context, 8), + ViewUtils.dp(context, 4), + ViewUtils.dp(context, 12) + ) + } + card.addView(sectionView) + return card + } + + // ---- Visibility walk algorithm ---- + + private fun recalculateVisibility() { + val visibleIndices = QuestionFlowResolver.visibleQuestionIndices(questions, ::branchAnswerForFlow) + + val missing = visibleIndices.filter { idx -> sections.none { it.index == idx } }.sorted() + if (missing.isNotEmpty()) { + if (setupComplete) { + questionsLoading = true + updateSaveButtonState() + for (idx in missing) { + if (idx !in pendingInflationQueue) pendingInflationQueue.add(idx) + } + pendingInflationQueue.sort() + container.post { inflateQuestionSectionsBatch(inflationGeneration) } + } else { + missing.forEach { inflateSingleSection(it) } + } + } + + for (section in sections) { + val shouldShow = visibleIndices.contains(section.index) + section.card.visibility = if (shouldShow) View.VISIBLE else View.GONE + } + } + + private fun branchAnswerForFlow(q: QuestionItem): QuestionFlowResolver.BranchAnswer? { + val section = sections.find { it.question === q } + if (section != null) { + if (!QuestionFlowResolver.hasBranchingOptions(q)) { + return QuestionFlowResolver.BranchAnswer(languageID) + } + if (hasAnswerInUi(q, section)) { + return branchAnswerFromUi(q, section) + } + } + return branchAnswerFromAnswers(q) + } + + private fun branchAnswerFromAnswers(q: QuestionItem): QuestionFlowResolver.BranchAnswer? { + if (QuestionFlowResolver.hasBranchingOptions(q) && !hasAnswerInMap(q)) return null + return when (q) { + is QuestionItem.RadioQuestion -> { + val key = answerString(q.question, q.id) ?: return null + QuestionFlowResolver.BranchAnswer(languageID, selectedKey = key) + } + is QuestionItem.ValueSpinnerQuestion -> { + val saved = AnswerLookup.valueSpinnerValue(answers, q) ?: return null + if (SpinnerAnswerRules.isUnselected(languageID, saved)) return null + QuestionFlowResolver.BranchAnswer( + languageID, + selectedInt = saved.toIntOrNull(), + ) + } + is QuestionItem.StringSpinnerQuestion -> { + val saved = AnswerLookup.stringValue(answers, q) ?: return null + if (SpinnerAnswerRules.isUnselected(languageID, saved)) return null + QuestionFlowResolver.BranchAnswer(languageID, selectedDisplay = saved) + } + is QuestionItem.MultiCheckboxQuestion -> { + val keys = selectedKeysFromAnswers(q) ?: return null + QuestionFlowResolver.BranchAnswer(languageID, selectedKeys = keys) + } + else -> QuestionFlowResolver.BranchAnswer(languageID) + } + } + + private fun selectedKeysFromAnswers(q: QuestionItem.MultiCheckboxQuestion): Set? { + val key = q.question?.takeIf { it.isNotBlank() } ?: q.id.takeIf { it.isNotBlank() } ?: return null + val keys = when (val stored = answers[key]) { + is Collection<*> -> stored.mapNotNull { it?.toString() }.filter { it.isNotBlank() }.toSet() + is String -> if (stored.isNotBlank()) parseMultiCheckboxAnswer(stored) else emptySet() + else -> emptySet() + } + return keys.takeIf { it.size >= q.minSelection } + } + + private fun parseMultiCheckboxAnswer(raw: String): Set { + val trimmed = raw.trim() + if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + val inner = trimmed.substring(1, trimmed.length - 1) + if (inner.isBlank()) return emptySet() + return inner.split(",") + .map { it.trim().trim('"', '\'') } + .filter { it.isNotEmpty() } + .toSet() + } + val separator = when { + trimmed.contains(",") -> "," + trimmed.contains(";") -> ";" + else -> null + } + return if (separator != null) { + trimmed.split(separator) + .map { it.trim().trim('"', '\'') } + .filter { it.isNotEmpty() } + .toSet() + } else { + setOf(trimmed.trim().trim('"', '\'')) + } + } + + private fun answerString(vararg keys: String?): String? { + for (key in keys) { + if (key.isNullOrBlank()) continue + val text = answers[key]?.toString()?.trim() + if (!text.isNullOrEmpty()) return text + } + return null + } + + private fun hasAnswerInMap(q: QuestionItem): Boolean = when (q) { + is QuestionItem.RadioQuestion -> answerString(q.question, q.id) != null + is QuestionItem.ValueSpinnerQuestion -> { + val saved = AnswerLookup.valueSpinnerValue(answers, q) + !SpinnerAnswerRules.isUnselected(languageID, saved) + } + is QuestionItem.StringSpinnerQuestion -> { + val saved = AnswerLookup.stringValue(answers, q) + !SpinnerAnswerRules.isUnselected(languageID, saved) + } + is QuestionItem.FreeTextQuestion -> { + val key = q.question ?: q.id ?: return false + TextInputSanitizer.isUsableAfterSanitize( + answers[key]?.toString().orEmpty(), + q.maxLength.coerceIn(1, 10_000), + ) + } + is QuestionItem.DateSpinnerQuestion -> { + val key = q.question ?: q.id ?: return false + DateAnswerFormat.parseStored(answers[key]?.toString()) != null + } + is QuestionItem.SliderQuestion -> answerString(q.question, q.id) != null + is QuestionItem.MultiCheckboxQuestion -> selectedKeysFromAnswers(q) != null + is QuestionItem.GlassScaleQuestion -> + q.symptoms.isNotEmpty() && q.symptoms.all { answerString(it) != null } + is QuestionItem.ClientCoachCodeQuestion -> + answerString("client_code") != null && answerString("coach_code") != null + else -> true + } + + private fun branchAnswerFromUi(q: QuestionItem, section: Section): QuestionFlowResolver.BranchAnswer? { + if (QuestionFlowResolver.hasBranchingOptions(q) && !hasAnswerInUi(q, section)) return null + val view = section.sectionView + return when (q) { + is QuestionItem.RadioQuestion -> { + val radioGroup = view.findViewById(R.id.RadioGroup) ?: return null + val checkedId = radioGroup.checkedRadioButtonId + if (checkedId == -1) return null + val key = radioGroup.findViewById(checkedId)?.tag?.toString() ?: return null + QuestionFlowResolver.BranchAnswer(languageID, selectedKey = key) + } + is QuestionItem.ValueSpinnerQuestion -> { + val spinner = view.findViewById(R.id.value_spinner) ?: return null + val selected = spinner.selectedItem?.toString() ?: return null + if (SpinnerAnswerRules.isUnselected(languageID, selected)) return null + QuestionFlowResolver.BranchAnswer( + languageID, + selectedInt = selected.toIntOrNull(), + ) + } + is QuestionItem.StringSpinnerQuestion -> { + val spinner = view.findViewById(R.id.string_spinner) ?: return null + val selected = spinner.selectedItem?.toString() ?: return null + if (SpinnerAnswerRules.isUnselected(languageID, selected)) return null + QuestionFlowResolver.BranchAnswer(languageID, selectedDisplay = selected) + } + is QuestionItem.MultiCheckboxQuestion -> { + val keys = selectedKeysFromUi(view, q) ?: return null + QuestionFlowResolver.BranchAnswer(languageID, selectedKeys = keys) + } + else -> QuestionFlowResolver.BranchAnswer(languageID) + } + } + + private fun selectedKeysFromUi(view: View, q: QuestionItem.MultiCheckboxQuestion): Set? { + val container = view.findViewById(R.id.CheckboxContainer) ?: return null + val keys = mutableSetOf() + for (i in 0 until container.childCount) { + val cb = container.getChildAt(i) as? CheckBox ?: continue + if (cb.isChecked) keys += cb.tag.toString() + } + return keys.takeIf { it.size >= q.minSelection } + } + + private fun hasAnswerInUi(q: QuestionItem, section: Section): Boolean { + val view = section.sectionView + + return when (q) { + is QuestionItem.RadioQuestion -> { + val rg = view.findViewById(R.id.RadioGroup) ?: return false + rg.checkedRadioButtonId != -1 + } + is QuestionItem.ValueSpinnerQuestion -> { + val spinner = view.findViewById(R.id.value_spinner) ?: return false + !SpinnerAnswerRules.isUnselected(languageID, spinner.selectedItem?.toString()) + } + is QuestionItem.StringSpinnerQuestion -> { + val spinner = view.findViewById(R.id.string_spinner) ?: return false + !SpinnerAnswerRules.isUnselected(languageID, spinner.selectedItem?.toString()) + } + is QuestionItem.FreeTextQuestion -> { + val field = view.findViewById(R.id.free_text_input) ?: return false + val maxLen = q.maxLength.coerceIn(1, 10_000) + TextInputSanitizer.isUsableAfterSanitize( + field.text?.toString().orEmpty(), + maxLen, + ) + } + is QuestionItem.DateSpinnerQuestion -> + isDateSectionValidSilently(view, q, emptyMap()) + is QuestionItem.SliderQuestion -> true + is QuestionItem.MultiCheckboxQuestion -> selectedKeysFromUi(view, q) != null + is QuestionItem.GlassScaleQuestion -> { + val table = view.findViewById(R.id.glass_table) ?: return false + for (i in 0 until table.childCount) { + val row = table.getChildAt(i) as? TableRow ?: return false + val radioGroup = row.getChildAt(1) as? RadioGroup ?: return false + var anyChecked = false + for (j in 0 until radioGroup.childCount) { + val rb = getRadioFromChild(radioGroup.getChildAt(j)) ?: continue + if (rb.isChecked) { + anyChecked = true + break + } + } + if (!anyChecked) return false + } + true + } + is QuestionItem.ClientCoachCodeQuestion -> { + val client = view.findViewById(R.id.client_code)?.text?.toString().orEmpty() + val coach = view.findViewById(R.id.coach_code)?.text?.toString().orEmpty() + client.isNotBlank() && coach.isNotBlank() + } + else -> true + } + } + + // ---- Change listeners ---- + + private fun installChangeListeners() { + for (section in sections) { + installChangeListenersForSection(section) + } + } + + private fun installChangeListenersForSection(section: Section) { + if (!listenerInstalledIndices.add(section.index)) return + val view = section.sectionView + + when (section.question) { + is QuestionItem.RadioQuestion -> { + view.findViewById(R.id.RadioGroup) + ?.setOnCheckedChangeListener { _, checkedId -> + if (!setupComplete || checkedId == -1) return@setOnCheckedChangeListener + notifyAnswerChanged(section.question) + } + } + is QuestionItem.ValueSpinnerQuestion -> { + installSpinnerChangeListener(view, R.id.value_spinner, section.question) + } + // Slider changes are reported by HandlerSliderQuestion via HandlerDependencies.onAnswerChanged. + is QuestionItem.StringSpinnerQuestion -> { + installSpinnerChangeListener(view, R.id.string_spinner, section.question) + } + is QuestionItem.DateSpinnerQuestion -> { + val listener = createBranchingSpinnerListener(section.question) + view.findViewById(R.id.spinner_value_day)?.onItemSelectedListener = listener + view.findViewById(R.id.spinner_value_month)?.onItemSelectedListener = listener + view.findViewById(R.id.spinner_value_year)?.onItemSelectedListener = listener + } + is QuestionItem.GlassScaleQuestion -> { + installGlassScaleClickListeners(view, section.question) + } + is QuestionItem.MultiCheckboxQuestion -> { + installCheckboxClickListeners(view, section.question) + } + is QuestionItem.ClientCoachCodeQuestion -> { + installEditTextWatcher(view, R.id.client_code, section.question) + installEditTextWatcher(view, R.id.coach_code, section.question) + } + is QuestionItem.FreeTextQuestion -> { + installEditTextWatcher(view, R.id.free_text_input, section.question) + } + else -> {} + } + } + + private fun installSpinnerChangeListener(view: View, spinnerId: Int, question: QuestionItem) { + view.findViewById(spinnerId)?.onItemSelectedListener = + object : AdapterView.OnItemSelectedListener { + override fun onItemSelected( + parent: AdapterView<*>?, v: View?, pos: Int, id: Long + ) { + if (!setupComplete || suppressSpinnerEvents) return + notifyAnswerChanged(question) + } + override fun onNothingSelected(parent: AdapterView<*>?) {} + } + } + + private fun createBranchingSpinnerListener(question: QuestionItem): AdapterView.OnItemSelectedListener { + return object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, v: View?, pos: Int, id: Long) { + if (!setupComplete || suppressSpinnerEvents) return + notifyAnswerChanged(question) + } + override fun onNothingSelected(parent: AdapterView<*>?) {} + } + } + + private fun installGlassScaleClickListeners(view: View, question: QuestionItem) { + val table = view.findViewById(R.id.glass_table) ?: return + for (r in 0 until table.childCount) { + val row = table.getChildAt(r) as? TableRow ?: continue + val radioGroup = row.getChildAt(1) as? RadioGroup ?: continue + for (c in 0 until radioGroup.childCount) { + val rb = getRadioFromChild(radioGroup.getChildAt(c)) ?: continue + rb.setOnClickListener { + if (setupComplete) notifyAnswerChanged(question) + } + } + } + } + + private fun getRadioFromChild(child: View): RadioButton? = when (child) { + is RadioButton -> child + is FrameLayout -> child.getChildAt(0) as? RadioButton + else -> null + } + + private fun installCheckboxClickListeners(view: View, question: QuestionItem) { + val cont = view.findViewById(R.id.CheckboxContainer) ?: return + for (i in 0 until cont.childCount) { + val cb = cont.getChildAt(i) as? CheckBox ?: continue + cb.setOnClickListener { + if (setupComplete) notifyAnswerChanged(question) + } + } + } + + private fun installEditTextWatcher(view: View, editTextId: Int, question: QuestionItem) { + view.findViewById(editTextId)?.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + override fun afterTextChanged(s: Editable?) { + if (!setupComplete) return + notifyAnswerChanged(question) + } + }) + } + + private fun notifyAnswerChanged(question: QuestionItem) { + if (!setupComplete) return + if (QuestionFlowResolver.hasBranchingOptions(question)) { + recalculateVisibility() + } + markUiChanged() + } + + private fun markUiChanged() { + if (!setupComplete) return + uiChangedSinceBaseline = true + updateSaveButtonState() + } + + // ---- Save button state ---- + + private fun answerKeysFor(question: QuestionItem): List = when (question) { + is QuestionItem.StringSpinnerQuestion -> listOf(question.question.ifBlank { question.id }) + is QuestionItem.ValueSpinnerQuestion -> listOf(question.question.ifBlank { question.id }) + is QuestionItem.RadioQuestion -> listOf(question.question ?: question.id) + is QuestionItem.FreeTextQuestion -> listOf(question.question.ifBlank { question.id }) + is QuestionItem.DateSpinnerQuestion -> listOf(question.question.ifBlank { question.id }) + is QuestionItem.SliderQuestion -> listOf(question.question.ifBlank { question.id }) + is QuestionItem.MultiCheckboxQuestion -> listOf(question.question.ifBlank { question.id }) + is QuestionItem.GlassScaleQuestion -> question.symptoms + is QuestionItem.ClientCoachCodeQuestion -> listOf("client_code", "coach_code") + else -> emptyList() + } + + private fun removableAnswerKeysFor(question: QuestionItem): Set = + (answerKeysFor(question) + question.id).filter { it.isNotBlank() }.toSet() + + private fun snapshotAnswerKeys(source: Map): Map { + val snap = sortedMapOf() + for (q in questions) { + for (key in answerKeysFor(q)) { + if (key.isBlank()) continue + val raw = source[key] ?: continue + snap[key] = canonicalAnswerValue(raw) + } + } + return snap + } + + private fun canonicalAnswerValue(value: Any?): String = when (value) { + null -> "" + is List<*> -> value.mapNotNull { it?.toString()?.trim() }.filter { it.isNotEmpty() }.sorted() + .joinToString("|") + else -> value.toString().trim() + } + + private fun snapshotVisibleUiAnswers(): Map { + val snap = sortedMapOf() + for (section in sections.filter { it.card.visibility == View.VISIBLE }) { + addUiAnswerToSnapshot(snap, section.question, section.sectionView) + } + return snap + } + + private fun addUiAnswerToSnapshot( + snap: MutableMap, + question: QuestionItem, + view: View, + ) { + when (question) { + is QuestionItem.RadioQuestion -> { + val key = question.question ?: question.id + val radioGroup = view.findViewById(R.id.RadioGroup) ?: return + val checkedId = radioGroup.checkedRadioButtonId + if (key.isNotBlank() && checkedId != -1) { + radioGroup.findViewById(checkedId)?.tag?.toString()?.let { + snap[key] = canonicalAnswerValue(it) + } + } + } + is QuestionItem.StringSpinnerQuestion -> { + val key = question.question.ifBlank { question.id } + val selected = view.findViewById(R.id.string_spinner)?.selectedItem?.toString() + if (key.isNotBlank() && !SpinnerAnswerRules.isUnselected(languageID, selected)) { + snap[key] = canonicalAnswerValue(selected) + } + } + is QuestionItem.ValueSpinnerQuestion -> { + val key = question.question.ifBlank { question.id } + val selected = view.findViewById(R.id.value_spinner)?.selectedItem?.toString() + if (key.isNotBlank() && !SpinnerAnswerRules.isUnselected(languageID, selected)) { + snap[key] = canonicalAnswerValue(selected) + } + } + is QuestionItem.DateSpinnerQuestion -> { + val key = question.question.ifBlank { question.id } + selectedDateValue(view, question)?.let { if (key.isNotBlank()) snap[key] = it } + } + is QuestionItem.SliderQuestion -> { + val key = question.question.ifBlank { question.id } + val seekBar = view.findViewById(R.id.value_slider) ?: return + val min = question.range?.min ?: 0 + val step = (question.step ?: 1).coerceAtLeast(1) + if (key.isNotBlank()) snap[key] = (min + seekBar.progress * step).toString() + } + is QuestionItem.MultiCheckboxQuestion -> { + val key = question.question.ifBlank { question.id } + val container = view.findViewById(R.id.CheckboxContainer) ?: return + val selected = mutableListOf() + for (i in 0 until container.childCount) { + val cb = container.getChildAt(i) as? CheckBox ?: continue + if (cb.isChecked) selected += cb.tag.toString() + } + if (key.isNotBlank() && selected.isNotEmpty()) { + snap[key] = canonicalAnswerValue(selected) + } + } + is QuestionItem.GlassScaleQuestion -> { + val table = view.findViewById(R.id.glass_table) ?: return + for (i in 0 until table.childCount) { + val symptomKey = question.symptoms.getOrNull(i)?.takeIf { it.isNotBlank() } ?: continue + val row = table.getChildAt(i) as? TableRow ?: continue + val radioGroup = row.getChildAt(1) as? RadioGroup ?: continue + for (j in 0 until radioGroup.childCount) { + val rb = getRadioFromChild(radioGroup.getChildAt(j)) ?: continue + if (rb.isChecked) { + snap[symptomKey] = canonicalAnswerValue(rb.tag) + break + } + } + } + } + is QuestionItem.FreeTextQuestion -> { + val key = question.question.ifBlank { question.id } + val raw = view.findViewById(R.id.free_text_input)?.text?.toString().orEmpty() + val maxLen = question.maxLength.coerceIn(1, 10_000) + val clean = TextInputSanitizer.sanitize(raw, maxLen) + if (key.isNotBlank() && clean.isNotBlank()) snap[key] = canonicalAnswerValue(clean) + } + is QuestionItem.ClientCoachCodeQuestion -> { + view.findViewById(R.id.client_code)?.text?.toString()?.takeIf { it.isNotBlank() }?.let { + snap["client_code"] = canonicalAnswerValue(it) + } + view.findViewById(R.id.coach_code)?.text?.toString()?.takeIf { it.isNotBlank() }?.let { + snap["coach_code"] = canonicalAnswerValue(TokenStore.getUsername(context) ?: it) + } + } + else -> Unit + } + } + + private fun selectedDateValue(view: View, question: QuestionItem.DateSpinnerQuestion): String? { + val precision = DatePrecision.fromWire(question.precision) + val key = question.question.ifBlank { question.id } + val year = view.findViewById(R.id.spinner_value_year) + ?.selectedItem + ?.toString() + ?.toIntOrNull() + ?: return null + val month = if (precision == DatePrecision.YEAR) { + 1 + } else { + selectedMonthNumber(view) ?: return null + } + val day = if (precision == DatePrecision.FULL) { + view.findViewById(R.id.spinner_value_day) + ?.selectedItem + ?.toString() + ?.toIntOrNull() + ?: return null + } else { + 1 + } + val current = DateAnswerFormat.format(precision, year, month, day) + val stored = answers[key] as? String + if (storedSelectionMatchesUi(stored, year, month, day)) { + return stored?.trim() + } + return current + } + + private fun selectedMonthNumber(view: View): Int? { + val month = view.findViewById(R.id.spinner_value_month)?.selectedItem + val months = com.dano.test1.ui.Months.getAllMonths(languageID) + val idx = months.indexOf(month) + return if (idx >= 0) idx + 1 else null + } + + private fun storedSelectionMatchesUi(stored: String?, year: Int, month: Int, day: Int): Boolean { + val parsed = DateAnswerFormat.parseStored(stored) ?: return false + return parsed.year == year && parsed.month == month && parsed.day == day + } + + private fun isSectionValidSilently(section: Section, uiSnapshot: Map): Boolean { + val view = section.sectionView + return when (val question = section.question) { + is QuestionItem.RadioQuestion -> + view.findViewById(R.id.RadioGroup)?.checkedRadioButtonId != -1 + + is QuestionItem.StringSpinnerQuestion -> { + val selected = view.findViewById(R.id.string_spinner)?.selectedItem?.toString() + !SpinnerAnswerRules.isUnselected(languageID, selected) + } + + is QuestionItem.ValueSpinnerQuestion -> { + val selected = view.findViewById(R.id.value_spinner)?.selectedItem?.toString() + !SpinnerAnswerRules.isUnselected(languageID, selected) + } + + is QuestionItem.DateSpinnerQuestion -> + isDateSectionValidSilently(view, question, uiSnapshot) + + is QuestionItem.SliderQuestion -> true + + is QuestionItem.MultiCheckboxQuestion -> { + val container = view.findViewById(R.id.CheckboxContainer) ?: return false + var selectedCount = 0 + for (i in 0 until container.childCount) { + val cb = container.getChildAt(i) as? CheckBox ?: continue + if (cb.isChecked) selectedCount++ + } + selectedCount >= question.minSelection + } + + is QuestionItem.GlassScaleQuestion -> { + val table = view.findViewById(R.id.glass_table) ?: return false + for (i in 0 until table.childCount) { + val row = table.getChildAt(i) as? TableRow ?: return false + val radioGroup = row.getChildAt(1) as? RadioGroup ?: return false + var anyChecked = false + for (j in 0 until radioGroup.childCount) { + val rb = getRadioFromChild(radioGroup.getChildAt(j)) ?: continue + if (rb.isChecked) { + anyChecked = true + break + } + } + if (!anyChecked) return false + } + true + } + + is QuestionItem.FreeTextQuestion -> { + val raw = view.findViewById(R.id.free_text_input)?.text?.toString().orEmpty() + val maxLen = question.maxLength.coerceIn(1, 10_000) + TextInputSanitizer.isUsableAfterSanitize(raw, maxLen) + } + + is QuestionItem.ClientCoachCodeQuestion -> { + val clientCode = view.findViewById(R.id.client_code)?.text + val coachCode = view.findViewById(R.id.coach_code)?.text + !clientCode.isNullOrBlank() && !coachCode.isNullOrBlank() + } + + else -> true + } + } + + private fun isDateSectionValidSilently( + view: View, + question: QuestionItem.DateSpinnerQuestion, + uiSnapshot: Map, + ): Boolean { + val selected = selectedDateValue(view, question) ?: return false + if (DatePrecision.fromWire(question.precision) == DatePrecision.FULL && !isValidFullDate(view)) { + return false + } + + return true + } + + private fun isValidFullDate(view: View): Boolean { + val year = view.findViewById(R.id.spinner_value_year) + ?.selectedItem + ?.toString() + ?.toIntOrNull() + ?: return false + val month = selectedMonthNumber(view) ?: return false + val day = view.findViewById(R.id.spinner_value_day) + ?.selectedItem + ?.toString() + ?.toIntOrNull() + ?: return false + val cal = Calendar.getInstance() + cal.set(year, month - 1, 1) + return day <= cal.getActualMaximum(Calendar.DAY_OF_MONTH) + } + + private fun updateSaveButtonState() { + if (!::btnSave.isInitialized || !setupComplete) { + if (::btnSave.isInitialized) { + btnSave.isEnabled = false + btnSave.alpha = 0.5f + } + return + } + if (questionsLoading || pendingInflationQueue.isNotEmpty()) { + btnSave.isEnabled = false + btnSave.alpha = 0.5f + return + } + val uiSnapshot = snapshotVisibleUiAnswers() + val hasChanges = uiChangedSinceBaseline || + answersDifferFromPersisted(uiSnapshot) || + currentVisibleIndices() != initialVisibleIndices + val allValid = sections + .filter { it.card.visibility == View.VISIBLE } + .all { isSectionValidSilently(it, uiSnapshot) } + val enabled = hasChanges && allValid + btnSave.isEnabled = enabled + btnSave.alpha = if (enabled) 1f else 0.5f + } + + private fun answersDifferFromPersisted(uiSnapshot: Map): Boolean { + for ((key, value) in uiSnapshot) { + if (persistedBaselineAnswers[key] != value) return true + } + return false + } + + // ---- Save flow ---- + + private fun handleSave(progressBar: ProgressBar, saveOverlay: View, scrollContent: View) { + val visibleSections = sections.filter { it.card.visibility == View.VISIBLE } + val uiSnapshot = snapshotVisibleUiAnswers() + + for (section in visibleSections) { + if (!isSectionValidSilently(section, uiSnapshot)) { + showToast(LanguageManager.getText(languageID, "fill_all_fields")) + scrollToSection(section) + return + } + } + + pruneHiddenAnswers(visibleSections) + + for (section in visibleSections) { + if (section.question is QuestionItem.ClientCoachCodeQuestion) { + saveClientCoachCodeFromUi(section.sectionView) + } + section.handler?.saveAnswer() + } + + val completedClientCode = (answers["client_code"] as? String)?.trim().orEmpty() + showLoading(progressBar, saveOverlay, scrollContent, true) + + CoroutineScope(Dispatchers.IO).launch { + QuestionnaireCompletionService.completeAndFinish( + context = context, + answers = answers, + questionnaireId = questionnaireMeta.id, + languageId = languageID, + questions = questions, + minLoadingMs = 1500L, + onCompleted = { + openPostCompletionScoreReview( + clientCode = completedClientCode, + progressBar = progressBar, + saveOverlay = saveOverlay, + scrollContent = scrollContent, + ) + }, + ) + } + } + + private fun openPostCompletionScoreReview( + clientCode: String, + progressBar: ProgressBar, + saveOverlay: View, + scrollContent: View, + ) { + val activity = context as? MainActivity + val token = activity?.let { TokenStore.getToken(it) } + val validToken = token?.takeIf { it.isNotBlank() } + if (activity == null || clientCode.isBlank() || validToken == null || !TokenStore.isSessionValid(activity)) { + showLoading(progressBar, saveOverlay, scrollContent, false) + activity?.finishQuestionnaire() + return + } + + CoroutineScope(Dispatchers.Main).launch { + val hasScores = withContext(Dispatchers.IO) { + ReviewScoresHandler.hasScoresForClient(activity, validToken, clientCode) + } + showLoading(progressBar, saveOverlay, scrollContent, false) + if (!hasScores) { + activity.finishQuestionnaire() + return@launch + } + + ReviewScoresHandler( + activity = activity, + token = validToken, + languageIDProvider = { languageID }, + clientCodeFilter = clientCode, + onLanguageChanged = { newLang -> + languageID = newLang + AppLanguageStore.set(activity, newLang) + }, + onClose = { activity.finishQuestionnaire() }, + ).show() + } + } + + private fun pruneHiddenAnswers(visibleSections: List
) { + val visibleIndices = visibleSections.map { it.index }.toSet() + for (section in sections) { + if (section.index in visibleIndices) continue + for (key in removableAnswerKeysFor(section.question)) { + answers.remove(key) + } + } + } + + private fun saveClientCoachCodeFromUi(view: View) { + val clientCode = view.findViewById(R.id.client_code)?.text?.toString() ?: "" + val coachCode = view.findViewById(R.id.coach_code)?.text?.toString() ?: "" + GlobalValues.LAST_CLIENT_CODE = clientCode + answers["client_code"] = clientCode + answers["coach_code"] = TokenStore.getUsername(context) ?: coachCode + } + + private fun showLoading( + progressBar: ProgressBar, + saveOverlay: View, + scrollContent: View, + show: Boolean, + ) { + progressBar.visibility = if (show) View.VISIBLE else View.GONE + ScreenLoading.setVisible(saveOverlay, show, scrollContent) + if (show) { + btnSave.isEnabled = false + btnSave.alpha = 0.5f + } else { + updateSaveButtonState() + } + } + + private fun scrollToSection(section: Section) { + val scrollView = context.findViewById(R.id.scrollContainer) + scrollView?.post { + scrollView.smoothScrollTo(0, section.card.top) + } + } +} diff --git a/app/src/main/java/com/dano/test1/questionnaire/QuestionnaireBase.kt b/app/src/main/java/com/dano/test1/questionnaire/QuestionnaireBase.kt new file mode 100644 index 0000000..27dadba --- /dev/null +++ b/app/src/main/java/com/dano/test1/questionnaire/QuestionnaireBase.kt @@ -0,0 +1,143 @@ +package com.dano.test1.questionnaire + +import android.R +import android.app.Activity +import android.util.Log +import android.view.View +import android.widget.* +import com.dano.test1.LanguageManager +import com.dano.test1.MainActivity +import kotlinx.coroutines.* +import java.io.File +import java.util.Calendar + +const val MAX_VALUE_AGE = 122 +val MAX_VALUE_YEAR = Calendar.getInstance().get(Calendar.YEAR) + +object GlobalValues { + var INTEGRATION_INDEX: Int = 0 + var LAST_CLIENT_CODE: String? = null + var LOADED_CLIENT_CODE: String? = null +} + +data class QuestionnaireMeta(val id: String) +data class QuestionnaireData(val meta: QuestionnaireMeta, val questions: List) + +abstract class QuestionnaireBase { + abstract fun startQuestionnaire() + abstract fun showCurrentQuestion() + + companion object { + val LANGUAGE_IDS: List get() = AppLanguages.ids + val LANGUAGE_LABELS: List get() = AppLanguages.shortLabels + } + + fun attach(activity: Activity, language: String) { + this.context = activity + this.languageID = language + } + + protected lateinit var questionnaireMeta: QuestionnaireMeta + protected var currentIndex = 0 + protected lateinit var questions: List + protected val answers = mutableMapOf() + protected var languageID: String = "GERMAN" + protected lateinit var context: Activity + private val navigationHistory = mutableListOf() + + protected fun showToast(message: String) { + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + + protected fun validateEditTexts(vararg fields: EditText): Boolean { + return fields.all { it.text.toString().trim().isNotEmpty() } + } + + protected fun updateFileContent(fileName: String, placeholders: Map) { + val file = File(context.filesDir, fileName) + if (file.exists()) { + var updatedContent = file.readText() + placeholders.forEach { (key, value) -> + updatedContent = updatedContent.replace("{$key}", value.toString()) + } + file.writeText(updatedContent) + } + } + + protected fun navigateTo(layoutResId: Int, setup: (View) -> Unit) { + context.setContentView(layoutResId) + val contentHost = context.findViewById(android.R.id.content) + val rootView = contentHost?.getChildAt(0) ?: contentHost ?: context.window.decorView + setup(rootView) + } + + protected fun setupPrevButton(buttonId: Int, action: () -> Unit) { + context.findViewById