initial copy from nat-as app

This commit is contained in:
2026-06-24 16:04:59 +02:00
commit db5757997f
257 changed files with 20329 additions and 0 deletions

31
.gitattributes.txt Normal file
View File

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

15
.gitignore vendored Normal file
View File

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

65
.gitignore.txt Normal file
View File

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

3
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

1
.idea/.name generated Normal file
View File

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

6
.idea/AndroidProjectSystem.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

26
.idea/appInsightsSettings.xml generated Normal file
View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AppInsightsSettings">
<option name="tabSettings">
<map>
<entry key="Firebase Crashlytics">
<value>
<InsightsFilterSettings>
<option name="connection">
<ConnectionSetting>
<option name="appId" value="PLACEHOLDER" />
<option name="mobileSdkAppId" value="" />
<option name="projectId" value="" />
<option name="projectNumber" value="" />
</ConnectionSetting>
</option>
<option name="signal" value="SIGNAL_UNSPECIFIED" />
<option name="timeIntervalDays" value="THIRTY_DAYS" />
<option name="visibilityType" value="ALL" />
</InsightsFilterSettings>
</value>
</entry>
</map>
</option>
</component>
</project>

123
.idea/codeStyles/Project.xml generated Normal file
View File

@ -0,0 +1,123 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

6
.idea/compiler.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>

18
.idea/deploymentTargetSelector.xml generated Normal file
View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-27T17:45:31.648786459Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=R52T605XE0L" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>

13
.idea/deviceManager.xml generated Normal file
View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

19
.idea/gradle.xml generated Normal file
View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

6
.idea/kotlinc.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="2.0.21" />
</component>
</project>

10
.idea/migrations.xml generated Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

16
.idea/misc.xml generated Normal file
View File

@ -0,0 +1,16 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
<component name="VisualizationToolProject">
<option name="state">
<ProjectState>
<option name="scale" value="0.1221923828125" />
</ProjectState>
</option>
</component>
</project>

6
.idea/render.experimental.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RenderSettings">
<option name="showDecorations" value="true" />
</component>
</project>

17
.idea/runConfigurations.xml generated Normal file
View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>

6
.idea/studiobot.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="StudioBotProjectSettings">
<option name="shareContext" value="OptedIn" />
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

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

View File

@ -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://<server-host>/api`
- JSON header: `Content-Type: application/json; charset=UTF-8`
- Auth header after login: `Authorization: Bearer <token>`
- 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 <token>
```
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=<questionnaireID>`
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=<questionnaireID>` 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=<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<T>(
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<String, Any?> = emptyMap()
)
data class QuestionnaireDetail(
val meta: QuestionnaireMeta,
val questions: List<Question>
)
data class QuestionnaireMeta(
val id: String
)
data class Question(
val id: String,
val layout: String,
val question: String,
val options: List<QuestionOption>? = null,
val pointsMap: Map<String, Int>? = 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<String>? = null,
val range: Map<String, Double>? = null,
val constraints: Map<String, Any?>? = null,
val minSelection: Int? = null
)
data class QuestionOption(
val key: String,
val nextQuestionId: String? = null
)
data class TranslationsResponse(
val translations: Map<String, Map<String, String>>
)
data class SubmitQuestionnaireRequest(
val questionnaireID: String,
val clientCode: String,
val startedAt: Long? = null,
val completedAt: Long? = null,
val answers: List<SubmitAnswer>
)
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<LoginResponse>
@GET("app_questionnaires")
suspend fun getQuestionnaires(
@Header("Authorization") bearerToken: String
): ApiEnvelope<List<QuestionnaireListItem>>
@GET("app_questionnaires")
suspend fun getQuestionnaire(
@Header("Authorization") bearerToken: String,
@Query("id") questionnaireId: String
): ApiEnvelope<QuestionnaireDetail>
@GET("app_questionnaires")
suspend fun getTranslations(
@Header("Authorization") bearerToken: String,
@Query("translations") translations: Int = 1
): ApiEnvelope<TranslationsResponse>
@POST("app_questionnaires")
suspend fun submitQuestionnaire(
@Header("Authorization") bearerToken: String,
@Body body: SubmitQuestionnaireRequest
): ApiEnvelope<SubmitQuestionnaireResponse>
}
```
Pass the token as `Bearer $token`.

1
app/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

89
app/build.gradle.kts Normal file
View File

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

21
app/proguard-rules.pro vendored Normal file
View File

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

BIN
app/release/app-release.apk Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.dano.test1">
<!-- Netzwerkberechtigungen -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Notifications -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<application
android:name=".MyApp"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Test1"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="false"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|keyboardHidden|screenSize|smallestScreenSize|screenLayout">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ui.AuthActivity"
android:exported="false"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".ui.DevSettingsActivity"
android:exported="false"
android:parentActivityName=".MainActivity" />
<receiver
android:name=".notification.UploadReminderReceiver"
android:exported="false" />
</application>
</manifest>

View File

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

View File

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

View File

@ -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<Answer> =
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<String>): Set<Pair<String, String>> {
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<Answer>): List<AnswerEntity> =
answers.map { it.toEntity() }
suspend fun deleteAnswersForClientAndQuestionnaires(
clientCode: String,
questionnaireIds: List<String>,
) {
if (questionnaireIds.isEmpty()) return
answerDao.deleteAnswersForClientAndQuestionnaires(key(clientCode), questionnaireIds)
}
suspend fun getAnswersForClientAndQuestionnaire(
clientCode: String,
questionnaireId: String,
): List<Answer> =
answerDao.getAnswersForClientAndQuestionnaire(key(clientCode), questionnaireId)
.map { it.toDomain(clientCode) }
suspend fun insertAnswers(answers: List<Answer>) {
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<Answer>, 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),
)
}

View File

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

View File

@ -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<String, List<Answer>>()
private var allAnswers: List<Answer>? = null
suspend fun answersForQuestionnaire(clientCode: String, questionnaireId: String): List<Answer> {
ensureClient(clientCode)
return byQuestionnaire.getOrPut(questionnaireId) {
AnswerRepository.getAnswersForClientAndQuestionnaire(clientCode, questionnaireId)
}
}
suspend fun allForClient(clientCode: String): List<Answer> {
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
}
}
}

View File

@ -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<String> =
dao.getAll().map { SensitiveDataCrypto.decrypt(it.clientCodeEnc) }
suspend fun deleteAllClients() {
dao.deleteAllClients()
}
suspend fun deleteClientsNotIn(clientCodes: List<String>) {
val keys = clientCodes.map { SensitiveDataCrypto.clientCodeKey(it) }.distinct()
if (keys.isEmpty()) {
dao.deleteAllClients()
} else {
dao.deleteClientsNotIn(keys)
}
}
suspend fun replaceClients(clientCodes: List<String>) {
val codes = clientCodes.distinct()
deleteClientsNotIn(codes)
codes.forEach { ensureClient(it) }
}
}

View File

@ -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<CoachScoringReview> =
dao.getAllForClient(key(clientCode)).map { it.toDomain(clientCode) }
suspend fun getAllPendingUploads(): List<CoachScoringReview> {
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,
)
}

View File

@ -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<CompletedQuestionnaire>) {
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<CompletedQuestionnaire> =
dao.getAllForClient(key(clientCode)).map { it.toDomain(clientCode) }
/** One query for many clients (bulk answer import prep). */
suspend fun getAllGroupedByClient(clientCodes: List<String>): Map<String, List<CompletedQuestionnaire>> {
if (clientCodes.isEmpty()) return emptyMap()
val keyToCode = clientCodes.associateBy { key(it) }
val grouped = mutableMapOf<String, MutableList<CompletedQuestionnaire>>()
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<String> =
dao.getCompletedQuestionnairesForClient(key(clientCode))
suspend fun getPendingUploadForClient(clientCode: String): List<CompletedQuestionnaire> =
dao.getPendingUploadForClient(key(clientCode)).map { it.toDomain(clientCode) }
suspend fun getAllPendingUploads(): List<CompletedQuestionnaire> {
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,
)
}

View File

@ -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<Client>
@Query("DELETE FROM clients")
suspend fun deleteAllClients()
@Query("DELETE FROM clients WHERE clientCodeKey NOT IN (:keep)")
suspend fun deleteClientsNotIn(keep: List<String>)
}
@Dao
interface QuestionnaireDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertQuestionnaire(questionnaire: Questionnaire)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertQuestionnaires(questionnaires: List<Questionnaire>)
@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<Question>)
@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<Question>
}
data class ClientQuestionnaireKey(
val clientCodeKey: String,
val questionnaireId: String,
)
@Dao
interface AnswerDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAnswers(answers: List<AnswerEntity>)
@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<AnswerEntity>
@Query("SELECT * FROM answers WHERE clientCodeKey = :clientCodeKey")
suspend fun getAnswersForClient(clientCodeKey: String): List<AnswerEntity>
@Query("SELECT * FROM answers WHERE clientCodeKey IN (:clientCodeKeys)")
suspend fun getAnswersForClients(clientCodeKeys: List<String>): List<AnswerEntity>
/** 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<String>,
): List<ClientQuestionnaireKey>
@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<String>,
)
}
@Dao
interface CompletedQuestionnaireDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(entry: CompletedQuestionnaireEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(entries: List<CompletedQuestionnaireEntity>)
@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<CompletedQuestionnaireEntity>
@Query("SELECT * FROM completed_questionnaires WHERE clientCodeKey IN (:clientCodeKeys)")
suspend fun getAllForClients(clientCodeKeys: List<String>): List<CompletedQuestionnaireEntity>
@Query("SELECT questionnaireId FROM completed_questionnaires WHERE clientCodeKey = :clientCodeKey")
suspend fun getCompletedQuestionnairesForClient(clientCodeKey: String): List<String>
@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<CompletedQuestionnaireEntity>
@Query(
"SELECT * FROM completed_questionnaires WHERE isDone = 1 " +
"AND (uploadedAt IS NULL OR uploadedAt < timestamp)",
)
suspend fun getAllPendingUploads(): List<CompletedQuestionnaireEntity>
@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<CoachScoringReviewEntity>
@Query(
"SELECT * FROM coach_scoring_reviews WHERE uploadedAt IS NULL OR uploadedAt < updatedAt",
)
suspend fun getAllPendingUploads(): List<CoachScoringReviewEntity>
@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)
}

View File

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

View File

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

View File

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

View File

@ -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<String> = 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<SubmitAnswer> = emptyList(),
)
data class ClientAnswersBundle(
val clientCode: String,
val questionnaires: List<ServerQuestionnaireAnswers> = 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<SubmitAnswer>
)
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<ServerCompletedQuestionnaire> = 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<ScoringProfileMember> = 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<ScoringReviewProfile> = 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)

View File

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

View File

@ -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<UploadError>,
val revisionNotices: List<String> = 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<UploadError>()
val revisionNotices = mutableListOf<String>()
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,
),
)
}
}
}
}

View File

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

View File

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

View File

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

View File

@ -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<String, Long>()
private data class PreparedBlockImport(
val questionnaireId: String,
val questions: List<Question>,
val answerEntities: List<AnswerEntity>,
val completion: CompletedQuestionnaire,
)
private data class PreparedClientImport(
val clientCode: String,
val blocks: List<PreparedBlockImport>,
)
private data class DetailCaches(
val parsed: Map<String, JsonObject>,
val rawJson: Map<String, String>,
)
/**
* 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<String>? = null,
force: Boolean = false,
prefetchedBundles: List<ClientAnswersBundle>? = 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<String>, force: Boolean): List<String> =
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<String>,
prefetchedBundles: List<ClientAnswersBundle>?,
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<String>,
bundles: List<ClientAnswersBundle>,
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<String>,
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<ServerQuestionnaireAnswers>)
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<String>,
blocksByCode: (String) -> List<ServerQuestionnaireAnswers>,
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<PreparedBlockImport>()
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<ServerQuestionnaireAnswers>,
) {
if (blocks.isEmpty()) return
importManyClients(
context = context,
codes = listOf(clientCode),
blocksByCode = { if (it == clientCode) blocks else emptyList() },
)
}
private fun loadDetailCaches(context: Context, questionnaireIds: Set<String>): DetailCaches {
val parsed = mutableMapOf<String, JsonObject>()
val rawJson = mutableMapOf<String, String>()
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<Pair<String, String>>,
completionByQn: Map<String, CompletedQuestionnaire>,
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<String, String>? {
val submitByQuestion = block.answers.groupBy { it.questionID }
val stored = mutableMapOf<String, String>()
val symptomKeys = mutableSetOf<String>()
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,
)
}

View File

@ -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<QuestionnaireListItem> = 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<AssignedClientInfo> = 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<ClientAnswersBundle> =
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<ClientAnswersBundle> {
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<ServerQuestionnaireAnswers> {
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<ScoringProfileDefinition> =
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<ClientScoringReview> =
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<ScoringProfileDefinition> {
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<ClientScoringReview> {
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<ScoringReviewProfile> {
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<String>()
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<String> {
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<String> {
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()
}
}

View File

@ -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<String, Map<String, String>> = 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<QuestionnaireListItem>) {
writeCacheText(questionnairesFile(context), gson.toJson(list))
}
fun getQuestionnaireList(context: Context): List<QuestionnaireListItem> {
val file = questionnairesFile(context)
if (!file.exists()) return emptyList()
return runCatching {
gson.fromJson(readCacheText(file), Array<QuestionnaireListItem>::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<String>) {
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<String>) {
val codes = clientCodes.distinct()
saveAssignedClients(context, codes)
ClientRepository.replaceClients(codes)
SyncLog.d("CACHE", "replaceAssignedClients: ${codes.size} clients")
}
fun saveScoringProfiles(context: Context, profiles: List<ScoringProfileDefinition>) {
writeCacheText(File(cacheDir(context), SCORING_PROFILES_FILE), gson.toJson(profiles))
}
fun getScoringProfiles(context: Context): List<ScoringProfileDefinition> {
val file = File(cacheDir(context), SCORING_PROFILES_FILE)
if (!file.exists()) return emptyList()
return runCatching {
gson.fromJson(readCacheText(file), Array<ScoringProfileDefinition>::class.java).toList()
}.getOrDefault(emptyList())
}
fun getAssignedClients(context: Context): List<String> {
val file = clientsFile(context)
if (!file.exists()) return emptyList()
return runCatching {
gson.fromJson(readCacheText(file), Array<String>::class.java).toList()
}.getOrDefault(emptyList())
}
private fun parseTranslations(root: JsonObject): Map<String, Map<String, String>> {
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
}
}

View File

@ -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<SubmitAnswer>()
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<String, String>, 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<String>
): List<SubmitAnswer> {
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<String> {
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<String> {
val trimmed = value.trim().removePrefix("[").removeSuffix("]")
return trimmed.split(",", ";")
.map { it.trim().trim('"', '\'') }
.filter { it.isNotBlank() }
}
}

View File

@ -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<String> = 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<String> = 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<String>,
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<String> = 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<String>,
clientCodesToSave: List<String>,
) {
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<AssignedClientInfo>) {
val toInsert = mutableListOf<CompletedQuestionnaire>()
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<QuestionnaireListItem>,
) = 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}")
}
}

View File

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

View File

@ -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=<blank>"
val digest = MessageDigest.getInstance("SHA-256")
.digest(normalized.uppercase().toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
.take(8)
return "clientHash=$digest"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<String, Any>, question: QuestionItem.StringSpinnerQuestion): String? =
firstString(answers, listOf(question.question, question.id))
fun valueSpinnerValue(answers: Map<String, Any>, question: QuestionItem.ValueSpinnerQuestion): String? =
firstString(answers, listOf(question.question, question.id))
private fun firstString(answers: Map<String, Any>, keys: List<String>): 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
}
}

View File

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

View File

@ -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<String> = listOf(
"GERMAN", "ENGLISH", "FRENCH", "ROMANIAN", "ARABIC",
"POLISH", "TURKISH", "UKRAINIAN", "RUSSIAN", "SPANISH",
)
/** Short labels shown on in-questionnaire language spinners. */
val shortLabels: List<String> = 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<String> =
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<String, String> = 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",
)
}

View File

@ -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<String, Any>): Boolean {
val clientCode = answers["client_code"] as? String ?: return false
return isAssigned(context, clientCode)
}
}

View File

@ -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<QuestionItem.Condition>()
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<String>()
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
}
}
}

View File

@ -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>,
): 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<QuestionItem>,
answerProvider: (QuestionItem) -> BranchAnswer?,
): Set<Int> {
val visible = mutableSetOf<Int>()
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<String>? = null,
)
}

View File

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

View File

@ -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<String, Any>,
val languageID: String,
val questionnaireMetaId: String,
val goToNext: () -> Unit,
val goToPrev: () -> Unit,
val goToById: (String) -> Unit,
val showToast: (String) -> Unit,
val saveAnswers: suspend (Map<String, Any>) -> 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
}
}
}

View File

@ -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<TextView>(R.id.note_before)
val after = layout.findViewById<TextView>(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?
}

File diff suppressed because it is too large Load Diff

View File

@ -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<QuestionItem>)
abstract class QuestionnaireBase<T> {
abstract fun startQuestionnaire()
abstract fun showCurrentQuestion()
companion object {
val LANGUAGE_IDS: List<String> get() = AppLanguages.ids
val LANGUAGE_LABELS: List<String> 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<QuestionItem>
protected val answers = mutableMapOf<String, Any>()
protected var languageID: String = "GERMAN"
protected lateinit var context: Activity
private val navigationHistory = mutableListOf<Int>()
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<String, Any?>) {
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.view.ViewGroup>(android.R.id.content)
val rootView = contentHost?.getChildAt(0) ?: contentHost ?: context.window.decorView
setup(rootView)
}
protected fun setupPrevButton(buttonId: Int, action: () -> Unit) {
context.findViewById<Button>(buttonId)?.setOnClickListener { action() }
}
protected fun showEmptyScreen() {
navigateTo(getLayoutResId("empty")) {
setupPrevButton(com.dano.test1.R.id.Qprev) { goToPreviousQuestion() }
}
}
protected fun getLayoutResId(layoutName: String?): Int {
return layoutName?.let {
context.resources.getIdentifier(it, "layout", context.packageName)
} ?: 0
}
protected fun goToPreviousQuestion() {
if (navigationHistory.isNotEmpty()) {
currentIndex = navigationHistory.removeAt(navigationHistory.size - 1)
showCurrentQuestion()
} else {
(context as? MainActivity)?.finishQuestionnaire()
}
}
protected fun goToNextQuestion() {
if (currentIndex < questions.size - 1) {
navigationHistory.add(currentIndex)
currentIndex++
showCurrentQuestion()
}
}
fun goToQuestionById(questionId: String) {
val index = questions.indexOfFirst { it.id == questionId }
if (index != -1) {
navigationHistory.add(currentIndex)
currentIndex = index
showCurrentQuestion()
}
}
protected fun loadQuestionnaireFromJson(filename: String): Pair<QuestionnaireMeta, List<QuestionItem>> =
QuestionnaireJsonLoader.load(context, filename)
protected fun handlerDependencies(): HandlerDependencies = HandlerDependencies(
context = context,
answers = answers,
languageID = languageID,
questionnaireMetaId = questionnaireMeta.id,
goToNext = ::goToNextQuestion,
goToPrev = ::goToPreviousQuestion,
goToById = ::goToQuestionById,
showToast = ::showToast,
saveAnswers = { map ->
saveAnswersToDatabase(map, questionnaireMeta.id)
},
)
protected open fun createHandlerForQuestion(question: QuestionItem): QuestionHandler? =
QuestionHandlerFactory.create(question, NavigationMode.Step, handlerDependencies())
suspend fun saveAnswersToDatabase(answers: Map<String, Any>, questionnaireId: String) {
QuestionnairePersistence.saveAnswers(context, answers, questionnaireId, languageID, questions)
}
fun endQuestionnaire() {
(context as? MainActivity)?.finishQuestionnaire()
}
}

View File

@ -0,0 +1,35 @@
package com.dano.test1.questionnaire
import android.content.Context
import com.dano.test1.MainActivity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
object QuestionnaireCompletionService {
suspend fun completeAndFinish(
context: Context,
answers: Map<String, Any>,
questionnaireId: String,
languageId: String,
questions: List<QuestionItem> = emptyList(),
minLoadingMs: Long = 2000L,
onCompleted: (() -> Unit)? = null,
) {
val start = System.currentTimeMillis()
QuestionnairePersistence.saveAnswers(context, answers, questionnaireId, languageId, questions)
(answers["client_code"] as? String)?.let { code ->
GlobalValues.LAST_CLIENT_CODE = code
GlobalValues.LOADED_CLIENT_CODE = code
}
val elapsed = System.currentTimeMillis() - start
if (elapsed < minLoadingMs) delay(minLoadingMs - elapsed)
withContext(Dispatchers.Main) {
onCompleted?.invoke() ?: (context as? MainActivity)?.finishQuestionnaire()
}
}
}

View File

@ -0,0 +1,79 @@
package com.dano.test1.questionnaire
import android.view.Gravity
import android.view.View
import android.widget.AdapterView
import android.widget.Button
import android.widget.FrameLayout
import android.widget.Spinner
import com.dano.test1.R
import com.dano.test1.utils.ViewUtils
open class QuestionnaireGeneric(private val questionnaireFileName: String) : QuestionnaireBase<Unit>() {
override fun startQuestionnaire() {
val (meta, questionsList) = loadQuestionnaireFromJson(questionnaireFileName)
questionnaireMeta = meta
questions = questionsList
currentIndex = 0
showCurrentQuestion()
}
override fun showCurrentQuestion() {
val question = questions[currentIndex]
val layoutResId = getLayoutResId(question.layout ?: "default_layout")
if (layoutResId == 0) {
showEmptyScreen()
return
}
navigateTo(layoutResId) { layout ->
layout.findViewById<Button>(R.id.Qprev)?.setOnClickListener {
goToPreviousQuestion()
}
val handler = createHandlerForQuestion(question)
if (handler != null) {
handler.bind(layout, question)
} else {
showEmptyScreen()
}
injectLanguageSpinner(layout)
}
}
private fun injectLanguageSpinner(layout: View) {
val container = layout as? FrameLayout ?: return
val spinner = Spinner(context)
val labels = AppLanguages.spinnerLabels(languageID)
val selected = labels.getOrNull(AppLanguages.indexOf(languageID))
ViewUtils.setupLanguagePickerSpinner(context, spinner, labels, selected)
spinner.setSelection(AppLanguages.indexOf(languageID))
val margin = ViewUtils.dp(context, 10)
container.addView(
spinner,
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
Gravity.TOP or Gravity.END
).apply { setMargins(0, margin, margin, 0) }
)
spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, v: View?, position: Int, id: Long) {
val newLang = AppLanguages.ids[position]
if (newLang == languageID) return
languageID = newLang
com.dano.test1.ui.AppLanguageStore.set(context, newLang)
spinner.post { showCurrentQuestion() }
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
}
}

View File

@ -0,0 +1,176 @@
package com.dano.test1.questionnaire
data class Option(
val key: String, // Must always be set
val nextQuestionId: String? = null
)
data class ValueOption(
val value: Int,
val nextQuestionId: String? = null
)
data class Range(
val min: Int,
val max: Int
)
data class GlassSymptomLabel(
val key: String = "",
val labelDe: String = "",
)
data class Constraints(
val notBefore: String? = null,
val notAfter: String? = null
)
sealed class QuestionItem {
abstract val layout: String?
abstract val id: String
data class ClientCoachCodeQuestion(
override val layout: String?,
override val id: String,
val question: String,
val hint1: String? = null,
val hint2: String? = null,
val nextQuestionId: String? = null,
) : QuestionItem()
data class RadioQuestion(
override val layout: String?,
override val id: String,
val textKey: String?,
val question: String?,
val options: List<Option> = emptyList(),
val pointsMap: Map<String, Int>? = null,
val noteBeforeKey: String? = null,
val noteAfterKey: String? = null,
) : QuestionItem()
data class GlassScaleQuestion(
override val layout: String?,
override val id: String,
val textKey: String?,
val question: String,
val symptomTextKeys: List<String> = emptyList(),
val glassSymptoms: List<GlassSymptomLabel> = emptyList(),
val pointsMap: Map<String, Int> = emptyMap(),
val symptoms: List<String> = emptyList(),
/** glass (default) | thermometer */
val scaleType: String? = null,
val nextQuestionId: String? = null,
) : QuestionItem()
data class MultiCheckboxQuestion(
override val id: String,
override val layout: String,
val textKey: String?,
val question: String,
val options: List<Option> = emptyList(),
val nextQuestionId: String? = null,
val otherOptionKey: String? = null,
val otherNextQuestionId: String? = null,
val pointsMap: Map<String, Int>? = null,
val minSelection: Int = 1
) : QuestionItem()
data class DateSpinnerQuestion(
override val layout: String?,
override val id: String,
val textKey: String?,
val question: String,
val nextQuestionId: String? = null,
val storeAsGlobalKey: String? = null,
val constraints: Constraints? = null,
/** year | year_month | full (default) */
val precision: String? = null,
override val noteBeforeKey: String? = null,
override val noteAfterKey: String? = null,
) : QuestionItem(), QuestionWithNotes
data class ValueSpinnerQuestion(
override val layout: String?,
override val id: String,
val textKey: String?,
val question: String,
val options: List<ValueOption> = emptyList(),
val range: Range? = null,
val nextQuestionId: String? = null,
val noteBeforeKey: String? = null,
val noteAfterKey: String? = null,
) : QuestionItem()
data class SliderQuestion(
override val layout: String?,
override val id: String,
val textKey: String?,
val question: String,
val range: Range? = null,
val step: Int? = null,
val unitLabel: String? = null,
val nextQuestionId: String? = null,
val noteBeforeKey: String? = null,
val noteAfterKey: String? = null,
) : QuestionItem()
data class StringSpinnerQuestion(
override val layout: String?,
override val id: String,
val textKey: String?,
val question: String,
val options: List<String> = emptyList(),
val nextQuestionId: String? = null,
val otherNextQuestionId: String? = null,
val otherOptionKey: String? = null,
) : QuestionItem()
data class FreeTextQuestion(
override val layout: String?,
override val id: String,
val textKey: String? = null,
val question: String,
val hint: String? = null,
val maxLength: Int = 500,
val nextQuestionId: String? = null,
val noteBeforeKey: String? = null,
val noteAfterKey: String? = null,
) : QuestionItem()
data class LastPage(
override val layout: String?,
override val id: String,
val textKey: String,
val question: String
) : QuestionItem()
data class QuestionnaireEntry(
val file: String,
val condition: Condition? = null,
val showPoints: Boolean = false,
val serverName: String? = null,
val categoryKey: String? = null,
/** Translation key shown when this questionnaire is locked (conditionJson.messageKey). */
val messageKey: String? = null,
)
// flexible Condition-Typen für die questionnaire_order.json
sealed class Condition {
object AlwaysAvailable : Condition()
data class RequiresCompleted(val required: List<String>) : Condition()
data class QuestionCondition(
val questionnaire: String,
val questionId: String,
val operator: String,
val value: String
) : Condition()
data class Combined(
val requiresCompleted: List<String>?,
val questionCheck: QuestionCondition?
) : Condition()
data class AnyOf(val conditions: List<Condition>) : Condition()
}
}

View File

@ -0,0 +1,93 @@
package com.dano.test1.questionnaire
import android.content.Context
import com.dano.test1.network.QuestionnaireCache
import com.google.gson.Gson
import com.google.gson.JsonObject
import com.google.gson.JsonParser
object QuestionnaireJsonLoader {
fun load(context: Context, filename: String): Pair<QuestionnaireMeta, List<QuestionItem>> {
val jsonString = QuestionnaireCache.loadQuestionnaireJson(context, filename)
?: throw IllegalStateException("No synced questionnaire for $filename — refresh from server first.")
QuestionnaireCache.applyQuestionnaireTranslationsFromJson(jsonString)
val gson = Gson()
val jsonObject = JsonParser.parseString(jsonString).asJsonObject
val meta = gson.fromJson(jsonObject.getAsJsonObject("meta"), QuestionnaireMeta::class.java)
val jsonArray = jsonObject.getAsJsonArray("questions")
val questionsList = jsonArray.mapNotNull { element ->
val obj = element.asJsonObject
val layoutType = obj.get("layout").asString
val clazz = questionTypeMap[layoutType] ?: return@mapNotNull null
gson.fromJson(normalizeQuestionObject(obj), clazz)
}
return Pair(meta, questionsList)
}
private val questionTypeMap = mapOf(
"client_coach_code_question" to QuestionItem.ClientCoachCodeQuestion::class.java,
"radio_question" to QuestionItem.RadioQuestion::class.java,
"date_spinner" to QuestionItem.DateSpinnerQuestion::class.java,
"value_spinner" to QuestionItem.ValueSpinnerQuestion::class.java,
"slider_question" to QuestionItem.SliderQuestion::class.java,
"glass_scale_question" to QuestionItem.GlassScaleQuestion::class.java,
"last_page" to QuestionItem.LastPage::class.java,
"string_spinner" to QuestionItem.StringSpinnerQuestion::class.java,
"multi_check_box_question" to QuestionItem.MultiCheckboxQuestion::class.java,
"free_text" to QuestionItem.FreeTextQuestion::class.java,
)
private fun normalizeQuestionObject(obj: JsonObject): JsonObject {
val copy = obj.deepCopy()
when (copy.get("layout")?.asString) {
"string_spinner" -> {
val options = copy.getAsJsonArray("options") ?: return copy
if (options.size() == 0) return copy
val normalized = com.google.gson.JsonArray()
options.forEach { option ->
when {
option.isJsonPrimitive -> {
val text = option.asString.trim()
if (text.isNotEmpty()) normalized.add(text)
}
option.isJsonObject -> {
val key = option.asJsonObject.get("key")?.asString?.trim().orEmpty()
if (key.isNotEmpty()) normalized.add(key)
}
}
}
if (normalized.size() > 0) copy.add("options", normalized)
}
"glass_scale_question" -> {
if (!copy.has("glassSymptoms") || copy.get("glassSymptoms").isJsonNull) {
copy.add("glassSymptoms", com.google.gson.JsonArray())
}
}
"value_spinner" -> {
val options = copy.getAsJsonArray("options")
if (options != null && options.any { it.isJsonObject && !it.asJsonObject.has("value") }) {
val normalized = com.google.gson.JsonArray()
options.forEach { option ->
if (option.isJsonObject) {
val key = option.asJsonObject.get("key")?.asString
val number = key?.toIntOrNull()
if (number != null) {
val valueObj = JsonObject()
valueObj.addProperty("value", number)
option.asJsonObject.get("nextQuestionId")?.let {
valueObj.add("nextQuestionId", it)
}
normalized.add(valueObj)
}
} else {
normalized.add(option)
}
}
copy.add("options", normalized)
}
}
}
return copy
}
}

View File

@ -0,0 +1,103 @@
package com.dano.test1.questionnaire
import android.content.Context
import com.dano.test1.LanguageManager
import com.dano.test1.MyApp
import com.dano.test1.data.Answer
import com.dano.test1.data.AnswerRepository
import com.dano.test1.data.ClientAnswersCache
import com.dano.test1.data.ClientRepository
import com.dano.test1.data.CompletedQuestionnaireRepository
import com.dano.test1.data.CompletedQuestionnaire
import com.dano.test1.data.Question
import com.dano.test1.data.Questionnaire
import com.dano.test1.network.SyncLog
import com.dano.test1.notification.UploadReminderScheduler
object QuestionnairePersistence {
suspend fun saveAnswers(
context: Context,
answers: Map<String, Any>,
questionnaireId: String,
languageId: String,
questions: List<QuestionItem> = emptyList(),
) {
val persistedAnswers = canonicalPersistedAnswers(answers, questions)
SyncLog.d("QUESTIONNAIRE", "Persisting ${persistedAnswers.size} answer field(s)")
if (!ClientAssignmentGuard.canPersistAnswers(context, answers)) {
val clientCode = answers["client_code"] as? String
SyncLog.w("QUESTIONNAIRE", "Skipping save for unassigned ${SyncLog.clientRef(clientCode)}")
return
}
val clientCode = answers["client_code"] as? String ?: return
val db = MyApp.database
ClientRepository.ensureClient(clientCode)
db.questionnaireDao().insertQuestionnaire(Questionnaire(id = questionnaireId))
AnswerRepository.deleteAnswersForClientAndQuestionnaire(clientCode, questionnaireId)
val questionEntities = persistedAnswers.keys
.filterNot { it.startsWith("client") }
.map { key ->
Question(
questionId = AnswerKeyUtils.roomQuestionId(questionnaireId, key),
questionnaireId = questionnaireId,
question = LanguageManager.getText(languageId, key),
)
}
db.questionDao().insertQuestions(questionEntities)
val answerEntities = persistedAnswers.entries
.filterNot { it.key.startsWith("client") }
.map { (key, value) ->
Answer(
clientCode = clientCode,
questionId = AnswerKeyUtils.roomQuestionId(questionnaireId, key),
answerValue = value.toString(),
)
}
AnswerRepository.insertAnswers(answerEntities)
ClientAnswersCache.invalidate(clientCode)
CompletedQuestionnaireRepository.insert(
CompletedQuestionnaire(
clientCode = clientCode,
questionnaireId = questionnaireId,
isDone = true,
sumPoints = null,
),
)
UploadReminderScheduler.schedule(context)
}
private fun canonicalPersistedAnswers(
answers: Map<String, Any>,
questions: List<QuestionItem>,
): Map<String, Any> {
if (questions.isEmpty()) return answers
val filtered = answers.toMutableMap()
for (question in questions) {
val canonicalKey = answerKeyFor(question) ?: continue
val shortId = question.id.takeIf { it.isNotBlank() && it != canonicalKey } ?: continue
filtered.remove(shortId)
}
return filtered
}
private fun answerKeyFor(question: QuestionItem): String? = when (question) {
is QuestionItem.RadioQuestion -> question.question ?: question.id
is QuestionItem.MultiCheckboxQuestion -> question.question.ifBlank { question.id }
is QuestionItem.StringSpinnerQuestion -> question.question.ifBlank { question.id }
is QuestionItem.ValueSpinnerQuestion -> question.question.ifBlank { question.id }
is QuestionItem.FreeTextQuestion -> question.question.ifBlank { question.id }
is QuestionItem.DateSpinnerQuestion -> question.question.ifBlank { question.id }
is QuestionItem.SliderQuestion -> question.question.ifBlank { question.id }
is QuestionItem.ClientCoachCodeQuestion -> null
is QuestionItem.GlassScaleQuestion -> null
is QuestionItem.LastPage -> null
}
}

View File

@ -0,0 +1,9 @@
package com.dano.test1.questionnaire
object QuestionnaireProgressiveCallbacks {
@JvmStatic
fun maybeTrim(questionId: String?) {
// Intentional no-op: handlers call this during user interaction,
// but trimming is handled by each questionnaire variant itself.
}
}

View File

@ -0,0 +1,14 @@
package com.dano.test1.questionnaire
import android.content.Context
import com.dano.test1.network.QuestionnaireCache
/** Applies per-questionnaire translation tables from synced JSON cache. */
object QuestionnaireTranslationsLoader {
fun applyFor(context: Context, questionnaireId: String): Boolean {
val json = QuestionnaireCache.loadQuestionnaireJson(context, questionnaireId) ?: return false
QuestionnaireCache.applyQuestionnaireTranslationsFromJson(json)
return true
}
}

View File

@ -0,0 +1,65 @@
package com.dano.test1.questionnaire
import android.app.Activity
import android.util.Log
import android.widget.Toast
import com.dano.test1.LanguageManager
import com.dano.test1.MainActivity
/**
* Validates questionnaire JSON before the coach opens a flow.
* Returns an app UI string key when configuration is unusable.
*/
object QuestionnaireValidation {
private const val TAG = "QN_VALIDATION"
/** @return app UI string key, or null if the questionnaire can be opened */
fun configErrorKeyForFile(activity: Activity, filename: String): String? {
return try {
val questions = QuestionnaireJsonLoader.load(activity, filename).second
configErrorKey(questions, filename)
} catch (e: Exception) {
Log.e(TAG, "validate file failed: $filename", e)
"error_invalid_data"
}
}
fun configErrorKey(questions: List<QuestionItem>, questionnaireId: String = ""): String? {
for (q in questions) {
val reason = when (q) {
is QuestionItem.StringSpinnerQuestion -> {
val usable = (q.options ?: emptyList()).map { it.trim() }.filter { it.isNotEmpty() }
if (usable.isEmpty()) "string_spinner id=${q.id} question=${q.question}" else null
}
is QuestionItem.RadioQuestion -> {
val usable = (q.options ?: emptyList()).count { it.key.isNotBlank() }
if (usable == 0) "radio id=${q.id}" else null
}
is QuestionItem.ValueSpinnerQuestion -> {
if (!hasValueSpinnerChoices(q)) "value_spinner id=${q.id}" else null
}
is QuestionItem.MultiCheckboxQuestion -> {
if ((q.options ?: emptyList()).isEmpty()) "multi_checkbox id=${q.id}" else null
}
else -> null
}
if (reason != null) {
Log.w(TAG, "missing options in $questionnaireId: $reason")
return "questionnaire_missing_options"
}
}
return null
}
/** Matches [HandlerValueSpinner]: fixed options or numeric range. */
private fun hasValueSpinnerChoices(q: QuestionItem.ValueSpinnerQuestion): Boolean {
if ((q.options ?: emptyList()).isNotEmpty()) return true
val range = q.range ?: return false
return range.max >= range.min
}
fun showConfigError(activity: MainActivity, languageId: String, errorKey: String) {
val message = LanguageManager.getText(languageId, errorKey)
Toast.makeText(activity, message, Toast.LENGTH_LONG).show()
}
}

View File

@ -0,0 +1,119 @@
package com.dano.test1.questionnaire
import com.dano.test1.data.ClientAnswersCache
/**
* Loads prior answers from Room (or session cache) into the in-memory map before UI bind,
* with types handlers expect (e.g. multi-checkbox as [List], not raw JSON strings).
*/
object SavedAnswerLoader {
suspend fun loadInto(
clientCode: String,
questionnaireId: String,
questions: List<QuestionItem>,
answers: MutableMap<String, Any>,
clearFirst: Boolean = true,
): Boolean {
val code = clientCode.trim()
if (code.isEmpty()) return false
if (clearFirst) {
answers.clear()
}
val rows = ClientAnswersCache.answersForQuestionnaire(code, questionnaireId)
val allForClient = ClientAnswersCache.allForClient(code)
var loaded = false
for (row in rows) {
if (row.answerValue.isBlank()) continue
val key = AnswerKeyUtils.lookupKey(questionnaireId, row.questionId)
answers[key] = row.answerValue
loaded = true
}
for (question in questions) {
if (question !is QuestionItem.GlassScaleQuestion) continue
for (symptomKey in question.symptoms) {
if (symptomKey.isBlank() || answers.containsKey(symptomKey)) continue
val row = allForClient.find {
it.questionId == symptomKey ||
it.questionId == AnswerKeyUtils.roomQuestionId(questionnaireId, symptomKey)
} ?: continue
if (row.answerValue.isBlank()) continue
answers[symptomKey] = row.answerValue
loaded = true
}
}
normalizeForHandlers(questions, answers)
aliasAnswersByQuestionId(questions, answers)
return loaded
}
/** Ensures handlers can resolve answers by [QuestionItem.id] as well as by question key. */
private fun aliasAnswersByQuestionId(questions: List<QuestionItem>, answers: MutableMap<String, Any>) {
for (question in questions) {
val key = answerKeyFor(question) ?: continue
val value = answers[key] ?: continue
if (question.id.isNotBlank() && question.id != key && !answers.containsKey(question.id)) {
answers[question.id] = value
}
}
}
/** Converts stored strings into types bind() reads without another DB round-trip. */
fun normalizeForHandlers(questions: List<QuestionItem>, answers: MutableMap<String, Any>) {
for (question in questions) {
val key = answerKeyFor(question) ?: continue
when (question) {
is QuestionItem.MultiCheckboxQuestion -> {
val raw = answers[key] ?: continue
when (raw) {
is List<*> -> answers[key] = raw.mapNotNull { it?.toString() }.filter { it.isNotBlank() }
is String -> if (raw.isNotBlank()) {
answers[key] = parseMultiValue(raw)
}
else -> Unit
}
}
else -> Unit
}
}
}
private fun answerKeyFor(question: QuestionItem): String? = when (question) {
is QuestionItem.RadioQuestion -> question.question ?: question.id
is QuestionItem.MultiCheckboxQuestion -> question.question ?: question.id
is QuestionItem.StringSpinnerQuestion -> question.question ?: question.id
is QuestionItem.ValueSpinnerQuestion -> question.question ?: question.id
is QuestionItem.FreeTextQuestion -> question.question ?: question.id
is QuestionItem.DateSpinnerQuestion -> question.question ?: question.id
is QuestionItem.SliderQuestion -> question.question ?: question.id
else -> null
}
fun parseMultiValue(value: String): List<String> {
val trimmed = value.trim()
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
val inner = trimmed.substring(1, trimmed.length - 1)
if (inner.isBlank()) return emptyList()
return inner.split(",")
.map { it.trim().trim('"', '\'') }
.filter { it.isNotEmpty() }
}
val separator = when {
trimmed.contains(",") -> ","
trimmed.contains(";") -> ";"
else -> null
}
return if (separator != null) {
trimmed.split(separator)
.map { it.trim().trim('"', '\'') }
.filter { it.isNotEmpty() }
} else {
listOf(trimmed.trim().trim('"', '\''))
}
}
}

View File

@ -0,0 +1,14 @@
package com.dano.test1.questionnaire
import com.dano.test1.LanguageManager
object SpinnerAnswerRules {
fun chooseAnswerPrompt(languageId: String): String =
LanguageManager.getText(languageId, "choose_answer")
fun isUnselected(languageId: String, selected: String?): Boolean {
val prompt = chooseAnswerPrompt(languageId)
return selected.isNullOrEmpty() || selected == prompt
}
}

View File

@ -0,0 +1,64 @@
package com.dano.test1.questionnaire
import com.dano.test1.LanguageManager
import com.dano.test1.ui.localization.UiStrings
/**
* Resolves stored answer values to spinner indices (keys, localized labels, or free text).
*/
object SpinnerSelectionHelper {
/** Label shown in the spinner for one option (literal text or translated key). */
fun optionDisplayLabel(languageId: String, option: String): String {
val trimmed = option.trim()
if (trimmed.isEmpty()) return trimmed
val translated = LanguageManager.getText(languageId, trimmed).trim()
if (translated.isEmpty()) return trimmed
if (translated.equals(trimmed, ignoreCase = true)) return trimmed
if (UiStrings.stripBrackets(translated).equals(trimmed, ignoreCase = true)) return trimmed
return translated
}
fun buildStringSpinnerItems(languageId: String, optionKeys: List<String>): List<String> {
val prompt = SpinnerAnswerRules.chooseAnswerPrompt(languageId)
return listOf(prompt) + optionKeys.map { optionDisplayLabel(languageId, it) }
}
fun indexForSavedValue(
languageId: String,
displayItems: List<String>,
saved: String?,
optionKeys: List<String> = emptyList(),
): Int {
if (saved.isNullOrBlank() || displayItems.isEmpty()) return -1
val trimmed = saved.trim()
val prompt = SpinnerAnswerRules.chooseAnswerPrompt(languageId)
displayItems.indexOfFirst { it.equals(trimmed, ignoreCase = true) }
.takeIf { it >= 0 }
?.let { return it }
for ((i, item) in displayItems.withIndex()) {
if (item == prompt) continue
if (item.equals(trimmed, ignoreCase = true)) return i
}
for (key in optionKeys) {
val raw = key.trim()
val label = optionDisplayLabel(languageId, raw)
if (trimmed.equals(raw, ignoreCase = true) || trimmed.equals(label, ignoreCase = true)) {
displayItems.indexOfFirst { it.equals(label, ignoreCase = true) }
.takeIf { it >= 0 }
?.let { return it }
}
}
for ((i, item) in displayItems.withIndex()) {
if (item == prompt) continue
if (UiStrings.stripBrackets(item).equals(UiStrings.stripBrackets(trimmed), ignoreCase = true)) {
return i
}
}
return -1
}
}

View File

@ -0,0 +1,149 @@
package com.dano.test1.questionnaire.handlers
import android.view.View
import android.widget.*
import com.dano.test1.questionnaire.GlobalValues
import com.dano.test1.LanguageManager
import com.dano.test1.data.ClientRepository
import com.dano.test1.questionnaire.QuestionFlowResolver
import com.dano.test1.questionnaire.QuestionHandler
import com.dano.test1.questionnaire.QuestionItem
import com.dano.test1.R
import com.dano.test1.network.QuestionnaireCache
import com.dano.test1.network.TokenStore
import com.dano.test1.utils.ViewUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/*
Zweck :
- Steuert die Eingabeseite für „Client Code“ und „Coach Code“ innerhalb des Fragebogen-Flows.
*/
class HandlerClientCoachCode(
private val answers: MutableMap<String, Any>,
private val languageID: String,
private val goToNextQuestion: () -> Unit,
private val goToPreviousQuestion: () -> Unit,
private val goToQuestionById: (String) -> Unit,
private val showToast: (String) -> Unit,
) : QuestionHandler {
private lateinit var question: QuestionItem.ClientCoachCodeQuestion
private lateinit var layout: View
override fun bind(layout: View, question: QuestionItem) {
if (question !is QuestionItem.ClientCoachCodeQuestion) return
this.layout = layout
this.question = question
val clientCodeField = layout.findViewById<EditText>(R.id.client_code)
val coachCodeField = layout.findViewById<EditText>(R.id.coach_code)
ViewUtils.styleFormEditText(clientCodeField)
ViewUtils.styleFormEditText(coachCodeField)
val questionTextView = layout.findViewById<TextView>(R.id.question)
val titleTextView = layout.findViewById<TextView>(R.id.textView)
questionTextView.text = question.question?.let { LanguageManager.getText(languageID, it) } ?: ""
ViewUtils.setTextSizePercentOfScreenHeight(titleTextView, 0.03f)
ViewUtils.setTextSizePercentOfScreenHeight(questionTextView, 0.03f)
ViewUtils.setTextSizePercentOfScreenHeight(clientCodeField, 0.025f)
ViewUtils.setTextSizePercentOfScreenHeight(coachCodeField, 0.025f)
// Client-Code: nur verwenden, wenn bereits geladen
val loadedClientCode = GlobalValues.LOADED_CLIENT_CODE
if (!loadedClientCode.isNullOrBlank()) {
clientCodeField.setText(loadedClientCode)
clientCodeField.isEnabled = false
} else {
clientCodeField.setText("")
clientCodeField.isEnabled = true
}
// Coach-Code immer aus dem Login (TokenStore) setzen und sperren
val coachFromLogin = TokenStore.getUsername(layout.context)
if (!coachFromLogin.isNullOrBlank()) {
coachCodeField.setText(coachFromLogin)
ViewUtils.lockEditField(coachCodeField) // optisch & technisch gesperrt
} else {
// Falls (theoretisch) kein Login-Username vorhanden ist, verhalten wie bisher
coachCodeField.setText(answers["coach_code"] as? String ?: "")
coachCodeField.isEnabled = true
}
layout.findViewById<Button>(R.id.Qnext).setOnClickListener {
onNextClicked(clientCodeField, coachCodeField)
}
layout.findViewById<Button>(R.id.Qprev).setOnClickListener {
onPreviousClicked(clientCodeField, coachCodeField)
}
}
private fun onNextClicked(clientCodeField: EditText, coachCodeField: EditText) {
val loadedClientCode = GlobalValues.LOADED_CLIENT_CODE
if (!validate()) {
val message = LanguageManager.getText(languageID, "fill_both_fields")
showToast(message)
return
}
val clientCode = clientCodeField.text.toString()
val assignedClients = QuestionnaireCache.getAssignedClients(layout.context)
if (assignedClients.isEmpty() || assignedClients.none { it.equals(clientCode, ignoreCase = true) }) {
showToast(LanguageManager.getText(languageID, "no_profile"))
return
}
// Erzwinge Coach-Code aus Login (falls vorhanden)
val coachCode = TokenStore.getUsername(layout.context) ?: coachCodeField.text.toString()
CoroutineScope(Dispatchers.IO).launch {
val existingClient = ClientRepository.exists(clientCode)
withContext(Dispatchers.Main) {
if (existingClient && clientCodeField.isEnabled) {
val message = LanguageManager.getText(languageID, "client_code_exists")
showToast(message)
} else {
saveAnswers(clientCode, coachCode)
QuestionFlowResolver.navigateAfterAnswer(
question.nextQuestionId,
goToQuestionById,
goToNextQuestion,
)
}
}
}
}
private fun onPreviousClicked(clientCodeField: EditText, coachCodeField: EditText) {
val clientCode = clientCodeField.text.toString()
val coachCode = TokenStore.getUsername(layout.context) ?: coachCodeField.text.toString()
saveAnswers(clientCode, coachCode)
goToPreviousQuestion()
}
override fun validate(): Boolean {
val clientCode = layout.findViewById<EditText>(R.id.client_code).text
val coachText = layout.findViewById<EditText>(R.id.coach_code).text
return clientCode.isNotBlank() && coachText.isNotBlank()
}
private fun saveAnswers(clientCode: String, coachCode: String) {
GlobalValues.LAST_CLIENT_CODE = clientCode
answers["client_code"] = clientCode
// Speichere garantierten Coach-Code aus Login bevorzugt
val loginCoach = TokenStore.getUsername(layout.context)
answers["coach_code"] = loginCoach ?: coachCode
}
override fun saveAnswer() {
// Not used
}
}

View File

@ -0,0 +1,236 @@
package com.dano.test1.questionnaire.handlers
import android.content.Context
import android.view.View
import android.widget.Button
import android.widget.Spinner
import android.widget.TextView
import com.dano.test1.LanguageManager
import com.dano.test1.data.AnswerRepository
import com.dano.test1.R
import com.dano.test1.questionnaire.GlobalValues
import com.dano.test1.questionnaire.MAX_VALUE_YEAR
import com.dano.test1.questionnaire.QuestionFlowResolver
import com.dano.test1.questionnaire.QuestionHandler
import com.dano.test1.questionnaire.QuestionItem
import com.dano.test1.questionnaire.QuestionNotesHelper
import com.dano.test1.ui.Month
import com.dano.test1.ui.Months
import com.dano.test1.utils.DateAnswerFormat
import com.dano.test1.utils.DatePrecision
import com.dano.test1.utils.ViewUtils
import java.util.Calendar
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class HandlerDateSpinner(
private val context: Context,
private val answers: MutableMap<String, Any>,
private val languageID: String,
private val goToNextQuestion: () -> Unit,
private val goToPreviousQuestion: () -> Unit,
private val goToQuestionById: (String) -> Unit,
private val showToast: (String) -> Unit,
private val questionnaireMeta: String,
) : QuestionHandler {
private companion object {
private var cachedYearItems: List<Int>? = null
private fun yearItems(maxYear: Int): List<Int> =
cachedYearItems ?: (1900..maxYear + 1).reversed().toList().also { cachedYearItems = it }
}
private lateinit var question: QuestionItem.DateSpinnerQuestion
private lateinit var layout: View
private lateinit var spinnerDay: Spinner
private lateinit var spinnerMonth: Spinner
private lateinit var spinnerYear: Spinner
private lateinit var labelDay: TextView
private lateinit var labelMonth: TextView
private lateinit var labelYear: TextView
private lateinit var precision: DatePrecision
override fun bind(layout: View, question: QuestionItem) {
if (question !is QuestionItem.DateSpinnerQuestion) return
this.layout = layout
this.question = question
precision = DatePrecision.fromWire(question.precision)
initViews()
val questionTextView = layout.findViewById<TextView>(R.id.question)
val textView = layout.findViewById<TextView>(R.id.textView)
questionTextView.text = question.question?.let { LanguageManager.getText(languageID, it) } ?: ""
textView.text = question.textKey?.let { LanguageManager.getText(languageID, it) } ?: ""
QuestionNotesHelper.bind(layout, languageID, question.noteBeforeKey, question.noteAfterKey)
ViewUtils.setTextSizePercentOfScreenHeight(textView, 0.03f)
ViewUtils.setTextSizePercentOfScreenHeight(questionTextView, 0.03f)
ViewUtils.setTextSizePercentOfScreenHeight(labelDay, 0.025f)
ViewUtils.setTextSizePercentOfScreenHeight(labelMonth, 0.025f)
ViewUtils.setTextSizePercentOfScreenHeight(labelYear, 0.025f)
applyPrecisionVisibility()
val saved = question.question?.let { answers[it] as? String }
val parsed = DateAnswerFormat.parseStored(saved)
val prompt = LanguageManager.getText(languageID, "choose_answer")
val days = listOf<Any>(prompt) + (1..31).toList()
val months = listOf<Any>(prompt) + Months.getAllMonths(languageID)
val years = listOf<Any>(prompt) + yearItems(MAX_VALUE_YEAR)
val defaultDay = parsed?.day
val defaultMonth = parsed?.month?.takeIf { it in 1..12 }?.let { months[it] }
val defaultYear = parsed?.year
if (precision != DatePrecision.YEAR) {
ViewUtils.setupResponsiveSpinner(context, spinnerMonth, months, defaultMonth)
}
if (precision == DatePrecision.FULL) {
ViewUtils.setupResponsiveSpinner(context, spinnerDay, days, defaultDay, compact = true)
}
ViewUtils.setupResponsiveSpinner(context, spinnerYear, years, defaultYear, compact = true)
restoreFromDatabaseIfNeeded(months, years, days)
layout.findViewById<Button>(R.id.Qnext).setOnClickListener {
if (validate()) {
saveAnswer()
QuestionFlowResolver.navigateAfterAnswer(
question.nextQuestionId,
goToQuestionById,
goToNextQuestion,
)
}
}
layout.findViewById<Button>(R.id.Qprev).setOnClickListener {
goToPreviousQuestion()
}
}
private fun applyPrecisionVisibility() {
val showDay = precision == DatePrecision.FULL
val showMonth = precision != DatePrecision.YEAR
labelDay.visibility = if (showDay) View.VISIBLE else View.GONE
spinnerDay.visibility = if (showDay) View.VISIBLE else View.GONE
labelMonth.visibility = if (showMonth) View.VISIBLE else View.GONE
spinnerMonth.visibility = if (showMonth) View.VISIBLE else View.GONE
}
private fun restoreFromDatabaseIfNeeded(
months: List<Any>,
years: List<Any>,
days: List<Any>,
) {
val answerMapKey = question.question ?: (question.id ?: "")
if (answerMapKey.isBlank() || answers.containsKey(answerMapKey)) return
CoroutineScope(Dispatchers.IO).launch {
try {
val clientCode = GlobalValues.LAST_CLIENT_CODE ?: return@launch
val myQuestionId = "$questionnaireMeta-$answerMapKey"
val dbAnswer = AnswerRepository.getAnswersForClient(clientCode)
.find { it.questionId == myQuestionId }
?.answerValue
if (!dbAnswer.isNullOrBlank()) {
withContext(Dispatchers.Main) {
answers[answerMapKey] = dbAnswer
val parsed = DateAnswerFormat.parseStored(dbAnswer) ?: return@withContext
years.indexOf(parsed.year).takeIf { it >= 0 }?.let {
spinnerYear.setSelection(it)
}
if (precision != DatePrecision.YEAR) {
val monthIdx = parsed.month - 1
val monthItemIndex = monthIdx + 1
if (monthItemIndex in months.indices) {
spinnerMonth.setSelection(monthItemIndex)
}
}
if (precision == DatePrecision.FULL) {
days.indexOf(parsed.day).takeIf { it >= 0 }?.let {
spinnerDay.setSelection(it)
}
}
}
}
} catch (_: Exception) {
}
}
}
private fun initViews() {
spinnerDay = layout.findViewById(R.id.spinner_value_day)
spinnerMonth = layout.findViewById(R.id.spinner_value_month)
spinnerYear = layout.findViewById(R.id.spinner_value_year)
labelDay = layout.findViewById(R.id.date_spinner_day)
labelMonth = layout.findViewById(R.id.date_spinner_month)
labelYear = layout.findViewById(R.id.date_spinner_year)
}
override fun validate(): Boolean {
buildSelectedValue() ?: return false
if (precision == DatePrecision.FULL && !isValidSelectedDay()) {
showToast(LanguageManager.getText(languageID, "error_invalid_data"))
return false
}
return true
}
override fun saveAnswer() {
question.question?.let { key ->
buildSelectedValue()?.let { built ->
val stored = answers[key] as? String
answers[key] = if (storedSelectionMatchesUi(stored)) stored?.trim().orEmpty() else built
}
}
}
private fun buildSelectedValue(): String? {
val year = spinnerYear.selectedItem?.toString()?.toIntOrNull() ?: return null
return when (precision) {
DatePrecision.YEAR -> DateAnswerFormat.format(precision, year, 1, 1)
DatePrecision.YEAR_MONTH -> DateAnswerFormat.format(
precision, year, selectedMonthNumber() ?: return null, 1,
)
DatePrecision.FULL -> DateAnswerFormat.format(
precision,
year,
selectedMonthNumber() ?: return null,
spinnerDay.selectedItem?.toString()?.toIntOrNull() ?: return null,
)
}
}
private fun selectedMonthNumber(): Int? {
val month = spinnerMonth.selectedItem as? Month ?: return null
val idx = Months.getAllMonths(languageID).indexOf(month)
return if (idx >= 0) idx + 1 else null
}
private fun storedSelectionMatchesUi(stored: String?): Boolean {
val parsed = DateAnswerFormat.parseStored(stored) ?: return false
val year = spinnerYear.selectedItem?.toString()?.toIntOrNull() ?: return false
val month = selectedMonthNumber() ?: 1
val day = spinnerDay.selectedItem?.toString()?.toIntOrNull() ?: 1
return parsed.year == year && parsed.month == month && parsed.day == day
}
private fun isValidSelectedDay(): Boolean {
val year = spinnerYear.selectedItem?.toString()?.toIntOrNull() ?: return false
val month = selectedMonthNumber() ?: return false
val day = spinnerDay.selectedItem?.toString()?.toIntOrNull() ?: return false
val cal = Calendar.getInstance()
cal.set(year, month - 1, 1)
return day <= cal.getActualMaximum(Calendar.DAY_OF_MONTH)
}
}

View File

@ -0,0 +1,133 @@
package com.dano.test1.questionnaire.handlers
import android.content.Context
import android.text.InputFilter
import android.view.View
import android.widget.Button
import android.widget.EditText
import android.widget.TextView
import com.dano.test1.LanguageManager
import com.dano.test1.data.AnswerRepository
import com.dano.test1.R
import com.dano.test1.questionnaire.GlobalValues
import com.dano.test1.questionnaire.QuestionFlowResolver
import com.dano.test1.questionnaire.QuestionHandler
import com.dano.test1.questionnaire.QuestionItem
import com.dano.test1.questionnaire.QuestionNotesHelper
import com.dano.test1.utils.TextInputSanitizer
import com.dano.test1.utils.ViewUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class HandlerFreeText(
private val context: Context,
private val answers: MutableMap<String, Any>,
private val languageID: String,
private val goToNextQuestion: () -> Unit,
private val goToPreviousQuestion: () -> Unit,
private val goToQuestionById: (String) -> Unit,
private val showToast: (String) -> Unit,
private val questionnaireMeta: String,
) : QuestionHandler {
private lateinit var layout: View
private lateinit var question: QuestionItem.FreeTextQuestion
private lateinit var inputField: EditText
override fun bind(layout: View, question: QuestionItem) {
if (question !is QuestionItem.FreeTextQuestion) return
this.layout = layout
this.question = question
inputField = layout.findViewById(R.id.free_text_input)
ViewUtils.styleFormEditText(inputField)
val questionTextView = layout.findViewById<TextView>(R.id.question)
val titleTextView = layout.findViewById<TextView>(R.id.textView)
QuestionNotesHelper.bind(layout, languageID, question.noteBeforeKey, question.noteAfterKey)
questionTextView.text =
question.question.let { LanguageManager.getText(languageID, it) }
titleTextView.text =
question.textKey?.let { LanguageManager.getText(languageID, it) } ?: ""
ViewUtils.setTextSizePercentOfScreenHeight(titleTextView, 0.03f)
ViewUtils.setTextSizePercentOfScreenHeight(questionTextView, 0.03f)
ViewUtils.setTextSizePercentOfScreenHeight(inputField, 0.025f)
val maxLen = question.maxLength.coerceIn(1, 10_000)
inputField.filters = arrayOf(InputFilter.LengthFilter(maxLen))
inputField.inputType =
android.text.InputType.TYPE_CLASS_TEXT or
android.text.InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
inputField.maxLines = 1
question.hint?.let { hintKey ->
inputField.hint = LanguageManager.getText(languageID, hintKey)
}
val answerKey = question.question ?: question.id
inputField.setText(answers[answerKey] as? String ?: "")
if (answerKey.isNotBlank() && !answers.containsKey(answerKey)) {
CoroutineScope(Dispatchers.IO).launch {
try {
val clientCode = GlobalValues.LAST_CLIENT_CODE ?: return@launch
val dbQuestionId = "$questionnaireMeta-$answerKey"
val dbAnswer = AnswerRepository.getAnswersForClient(clientCode)
.find { it.questionId == dbQuestionId }
?.answerValue
if (!dbAnswer.isNullOrBlank()) {
withContext(Dispatchers.Main) {
answers[answerKey] = dbAnswer
inputField.setText(dbAnswer)
}
}
} catch (_: Exception) {
}
}
}
layout.findViewById<Button>(R.id.Qnext).setOnClickListener {
if (validate()) {
saveAnswer()
QuestionFlowResolver.navigateAfterAnswer(
question.nextQuestionId,
goToQuestionById,
goToNextQuestion,
)
}
}
layout.findViewById<Button>(R.id.Qprev).setOnClickListener {
saveAnswer()
goToPreviousQuestion()
}
}
override fun validate(): Boolean {
val raw = inputField.text?.toString().orEmpty()
val maxLen = question.maxLength.coerceIn(1, 10_000)
if (!TextInputSanitizer.isUsableAfterSanitize(raw, maxLen)) {
val key = if (raw.isBlank()) "enter_text_to_continue" else "error_invalid_data"
showToast(LanguageManager.getText(languageID, key))
return false
}
return true
}
override fun saveAnswer() {
val answerKey = question.question ?: question.id
if (answerKey.isBlank()) return
val maxLen = question.maxLength.coerceIn(1, 10_000)
val clean = TextInputSanitizer.sanitize(inputField.text?.toString().orEmpty(), maxLen)
if (clean.isNotBlank()) {
answers[answerKey] = clean
if (inputField.text?.toString() != clean) {
inputField.setText(clean)
}
}
}
}

View File

@ -0,0 +1,301 @@
package com.dano.test1.questionnaire.handlers
import android.content.Context
import android.view.Gravity
import android.view.View
import android.widget.*
import com.dano.test1.questionnaire.GlobalValues
import com.dano.test1.LanguageManager
import com.dano.test1.data.AnswerRepository
import com.dano.test1.questionnaire.QuestionFlowResolver
import com.dano.test1.questionnaire.QuestionHandler
import com.dano.test1.questionnaire.QuestionItem
import com.dano.test1.R
import com.dano.test1.utils.ViewUtils
import kotlinx.coroutines.*
/*
Zweck:
- „Glas-Skala“-Frage: pro Symptom (Zeile) genau eine von fünf Stufen.
- Stufen als RadioButtons (clickbar) + Icon-Header.
- FIX: Pro Zeile wird Single-Select erzwungen (auch im Bearbeiten-Modus / Restore).
*/
class HandlerGlassScaleQuestion(
private val context: Context,
private val answers: MutableMap<String, Any>,
private val languageID: String,
private val goToNextQuestion: () -> Unit,
private val goToPreviousQuestion: () -> Unit,
private val goToQuestionById: (String) -> Unit,
private val showToast: (String) -> Unit,
private val questionnaireMeta: String,
private val onAnswerChanged: () -> Unit = {},
) : QuestionHandler {
private lateinit var layout: View
private lateinit var question: QuestionItem.GlassScaleQuestion
private val scaleLabels = listOf(
"never_glass",
"little_glass",
"moderate_glass",
"much_glass",
"extreme_glass"
)
private fun symptomDisplayLabel(symptomKey: String): String {
val translated = LanguageManager.getText(languageID, symptomKey)
if (translated != symptomKey) return translated
return (question.glassSymptoms ?: emptyList())
.firstOrNull { it.key == symptomKey }
?.labelDe
?.takeIf { it.isNotBlank() }
?: symptomKey
}
private val glassIconForLabel = mapOf(
"never_glass" to R.drawable.ic_glass_0,
"little_glass" to R.drawable.ic_glass_1,
"moderate_glass" to R.drawable.ic_glass_2,
"much_glass" to R.drawable.ic_glass_3,
"extreme_glass" to R.drawable.ic_glass_4,
)
private val thermometerIconForLabel = mapOf(
"never_glass" to R.drawable.ic_thermometer_0,
"little_glass" to R.drawable.ic_thermometer_1,
"moderate_glass" to R.drawable.ic_thermometer_2,
"much_glass" to R.drawable.ic_thermometer_3,
"extreme_glass" to R.drawable.ic_thermometer_4,
)
private fun iconForLabel(labelKey: String): Int {
val useThermometer = question.scaleType?.equals("thermometer", ignoreCase = true) == true
val map = if (useThermometer) thermometerIconForLabel else glassIconForLabel
return map[labelKey] ?: if (useThermometer) R.drawable.ic_thermometer_0 else R.drawable.ic_glass_0
}
// Damit wir beim programmatic-check keine Endlosschleifen triggern
private var suppressRowListener: Boolean = false
override fun bind(layout: View, question: QuestionItem) {
if (question !is QuestionItem.GlassScaleQuestion) return
this.layout = layout
this.question = question
val titleTv = layout.findViewById<TextView>(R.id.textView)
val questionTv = layout.findViewById<TextView>(R.id.question)
titleTv.text = question.textKey?.let { LanguageManager.getText(languageID, it) } ?: ""
questionTv.text = question.question?.let { LanguageManager.getText(languageID, it) } ?: ""
ViewUtils.setTextSizePercentOfScreenHeight(titleTv, 0.03f)
ViewUtils.setTextSizePercentOfScreenHeight(questionTv, 0.03f)
// Header Icons
val header = layout.findViewById<LinearLayout>(R.id.glass_header)
header.removeAllViews()
header.addView(Space(context).apply {
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 4f)
})
val iconSizePx = (context.resources.displayMetrics.density * 36).toInt()
scaleLabels.forEach { labelKey ->
val cell = FrameLayout(context).apply {
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
val img = ImageView(context).apply {
setImageResource(iconForLabel(labelKey))
layoutParams = FrameLayout.LayoutParams(iconSizePx, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.CENTER)
adjustViewBounds = true
scaleType = ImageView.ScaleType.FIT_CENTER
}
cell.addView(img)
header.addView(cell)
}
val tableLayout = layout.findViewById<TableLayout>(R.id.glass_table)
tableLayout.removeAllViews()
addSymptomRows(tableLayout)
// Restore aus DB (nur wenn noch nicht im answers)
val anySymptomNeedsRestore = question.symptoms.any { !answers.containsKey(it) }
if (anySymptomNeedsRestore) {
CoroutineScope(Dispatchers.IO).launch {
try {
val clientCode = GlobalValues.LAST_CLIENT_CODE ?: return@launch
val allAnswersForClient = AnswerRepository.getAnswersForClient(clientCode)
val answerMap = allAnswersForClient.associateBy({ it.questionId }, { it.answerValue })
withContext(Dispatchers.Main) {
var anyRestored = false
for ((index, symptomKey) in question.symptoms.withIndex()) {
val answerMapKey = "$questionnaireMeta-$symptomKey"
val dbAnswer = answerMap[answerMapKey]?.takeIf { it.isNotBlank() }?.trim()
if (!answers.containsKey(symptomKey) && !dbAnswer.isNullOrBlank()) {
if (index < tableLayout.childCount) {
val row = tableLayout.getChildAt(index) as? TableRow ?: continue
val radioGroup = row.getChildAt(1) as? RadioGroup ?: continue
setSingleSelection(radioGroup, dbAnswer)
answers[symptomKey] = dbAnswer
anyRestored = true
}
}
}
if (anyRestored) onAnswerChanged()
}
} catch (_: Exception) { /* ignore */ }
}
}
layout.findViewById<Button>(R.id.Qnext).setOnClickListener {
if (validate()) {
saveAnswer()
QuestionFlowResolver.navigateAfterAnswer(
question.nextQuestionId,
goToQuestionById,
goToNextQuestion,
)
} else {
showToast(LanguageManager.getText(languageID, "select_one_answer_per_row"))
}
}
layout.findViewById<Button>(R.id.Qprev).setOnClickListener { goToPreviousQuestion() }
}
private fun addSymptomRows(table: TableLayout) {
question.symptoms.forEach { symptomKey ->
val savedLabel = answers[symptomKey] as? String
val row = TableRow(context).apply {
layoutParams = TableRow.LayoutParams(
TableRow.LayoutParams.MATCH_PARENT,
TableRow.LayoutParams.WRAP_CONTENT
)
}
val symptomText = TextView(context).apply {
text = symptomDisplayLabel(symptomKey)
layoutParams = TableRow.LayoutParams(0, TableRow.LayoutParams.WRAP_CONTENT, 4f)
setPadding(4, 16, 4, 16)
ViewUtils.setTextSizePercentOfScreenHeight(this, 0.022f)
}
row.addView(symptomText)
val radioGroup = RadioGroup(context).apply {
orientation = RadioGroup.HORIZONTAL
layoutParams = TableRow.LayoutParams(0, TableRow.LayoutParams.WRAP_CONTENT, 5f)
setPadding(0, 0, 0, 0)
}
// Build buttons
scaleLabels.forEach { labelKey ->
val cell = FrameLayout(context).apply {
layoutParams = RadioGroup.LayoutParams(0, RadioGroup.LayoutParams.WRAP_CONTENT, 1f)
}
val rb = RadioButton(context).apply {
tag = labelKey
id = View.generateViewId()
isChecked = false
setPadding(0, 0, 0, 0)
}
rb.layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
Gravity.CENTER
)
// <<< FIX: erzwinge pro Zeile genau eine Auswahl
rb.setOnCheckedChangeListener { buttonView, isChecked ->
if (suppressRowListener) return@setOnCheckedChangeListener
if (!isChecked) return@setOnCheckedChangeListener
val selectedLabel = buttonView.tag as? String ?: return@setOnCheckedChangeListener
// wenn einer checked -> alle anderen in dieser Row unchecken
suppressRowListener = true
try {
for (i in 0 until radioGroup.childCount) {
val other = getRadioFromChild(radioGroup.getChildAt(i)) ?: continue
if (other != buttonView) other.isChecked = false
}
} finally {
suppressRowListener = false
}
// Optional (wenn du willst): sofort im answers setzen (Edit-Mode fühlt sich dann "direkt" an)
answers[symptomKey] = selectedLabel
}
cell.addView(rb)
radioGroup.addView(cell)
}
// Restore aus answers (falls vorhanden)
if (!savedLabel.isNullOrBlank()) {
setSingleSelection(radioGroup, savedLabel)
}
row.addView(radioGroup)
table.addView(row)
}
}
/**
* Setzt in diesem Row-RadioGroup genau einen Wert aktiv und alle anderen aus.
*/
private fun setSingleSelection(radioGroup: RadioGroup, labelKey: String) {
suppressRowListener = true
try {
for (i in 0 until radioGroup.childCount) {
val rb = getRadioFromChild(radioGroup.getChildAt(i)) ?: continue
val tag = (rb.tag as? String)?.trim()
rb.isChecked = (tag == labelKey.trim())
}
} finally {
suppressRowListener = false
}
}
override fun validate(): Boolean {
val table = layout.findViewById<TableLayout>(R.id.glass_table)
for (i in 0 until table.childCount) {
val row = table.getChildAt(i) as TableRow
val radioGroup = row.getChildAt(1) as RadioGroup
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
}
return true
}
override fun saveAnswer() {
val table = layout.findViewById<TableLayout>(R.id.glass_table)
for (i in 0 until table.childCount) {
val row = table.getChildAt(i) as TableRow
val symptomKey = question.symptoms[i]
val radioGroup = row.getChildAt(1) as RadioGroup
for (j in 0 until radioGroup.childCount) {
val rb = getRadioFromChild(radioGroup.getChildAt(j)) ?: continue
if (rb.isChecked) {
answers[symptomKey] = rb.tag as String
break
}
}
}
}
private fun getRadioFromChild(child: View): RadioButton? =
when (child) {
is RadioButton -> child
is FrameLayout -> child.getChildAt(0) as? RadioButton
else -> null
}
}

View File

@ -0,0 +1,175 @@
package com.dano.test1.questionnaire.handlers
import android.util.TypedValue
import android.view.View
import android.widget.*
import android.text.Html
import androidx.core.widget.TextViewCompat
import kotlinx.coroutines.*
import com.dano.test1.questionnaire.GlobalValues
import com.dano.test1.LanguageManager
import com.dano.test1.MainActivity
import com.dano.test1.network.TokenStore
import com.dano.test1.questionnaire.QuestionHandler
import com.dano.test1.questionnaire.QuestionItem
import com.dano.test1.R
import com.dano.test1.ui.AppLanguageStore
import com.dano.test1.ui.ReviewScoresHandler
import com.dano.test1.utils.ViewUtils
import com.google.android.material.button.MaterialButton
/*
Zweck:
- Steuert die letzte Seite eines Fragebogens.
- Zeigt Abschlusstexte an, speichert alle gesammelten Antworten in die lokale DB und beendet anschließend den Fragebogen und kehrt zur übergeordneten Ansicht zurück.
Beim Klick auf „Speichern“:
- Ladezustand anzeigen (ProgressBar), Buttons deaktivieren.
- Antworten asynchron in Room-DB persistieren (über `saveAnswersToDatabase`).
- Punktsumme ermitteln und in `GlobalValues.INTEGRATION_INDEX` schreiben.
- `client_code` (falls vorhanden) als `GlobalValues.LAST_CLIENT_CODE` merken.
- Mindestens 2 Sekunden „Loading“-Dauer sicherstellen (ruhiges UX).
- Zurück auf den Main-Thread wechseln, UI entsperren und Fragebogen schließen.
*/
class HandlerLastPage(
private val answers: Map<String, Any>,
private val languageID: String,
private val goToNextQuestion: () -> Unit,
private val goToPreviousQuestion: () -> Unit,
private val saveAnswersToDatabase: suspend (Map<String, Any>) -> Unit
) : QuestionHandler {
private lateinit var currentQuestion: QuestionItem.LastPage
private lateinit var layout: View
private val minLoadingTimeMs = 2000L
override fun bind(layout: View, question: QuestionItem) {
this.layout = layout
currentQuestion = question as QuestionItem.LastPage
val titleTv = layout.findViewById<TextView>(R.id.textView)
val questionTv = layout.findViewById<TextView>(R.id.question)
val prevBtn = layout.findViewById<MaterialButton>(R.id.Qprev)
val finishBtn = layout.findViewById<MaterialButton>(R.id.Qfinish)
// Texte setzen
titleTv.text = LanguageManager.getText(languageID, currentQuestion.textKey)
questionTv.text = Html.fromHtml(
LanguageManager.getText(languageID, currentQuestion.question),
Html.FROM_HTML_MODE_LEGACY
)
// Finish-Button: Text + responsive Schrift
finishBtn.text = LanguageManager.getText(languageID, "save")
finishBtn.isAllCaps = false
applyResponsiveTextSizing(finishBtn)
// Überschriften responsiv skalieren (wie zuvor)
ViewUtils.setTextSizePercentOfScreenHeight(titleTv, 0.03f)
ViewUtils.setTextSizePercentOfScreenHeight(questionTv, 0.03f)
// Buttons
prevBtn.setOnClickListener { goToPreviousQuestion() }
finishBtn.setOnClickListener {
showLoading(true)
CoroutineScope(Dispatchers.IO).launch {
val startTime = System.currentTimeMillis()
// Antworten speichern
saveAnswersToDatabase(answers)
val clientCode = (answers["client_code"] as? String)?.trim().orEmpty()
if (clientCode.isNotBlank()) {
GlobalValues.LAST_CLIENT_CODE = clientCode
GlobalValues.LOADED_CLIENT_CODE = clientCode // <— zusätzlich setzen
}
val activity = layout.context as? MainActivity
val token = activity?.let { TokenStore.getToken(it) }
val validToken = token?.takeIf { it.isNotBlank() }
val reviewRequest = if (
activity != null &&
clientCode.isNotBlank() &&
validToken != null &&
TokenStore.isSessionValid(activity) &&
runCatching {
ReviewScoresHandler.hasScoresForClient(activity, validToken, clientCode)
}.getOrDefault(false)
) {
activity to validToken
} else {
null
}
// min. Ladezeit einhalten (ruhiges UX)
val elapsedTime = System.currentTimeMillis() - startTime
if (elapsedTime < minLoadingTimeMs) delay(minLoadingTimeMs - elapsedTime)
withContext(Dispatchers.Main) {
showLoading(false)
if (reviewRequest != null) {
ReviewScoresHandler(
activity = reviewRequest.first,
token = reviewRequest.second,
languageIDProvider = { languageID },
clientCodeFilter = clientCode,
onLanguageChanged = { newLang ->
AppLanguageStore.set(reviewRequest.first, newLang)
},
onClose = { reviewRequest.first.finishQuestionnaire() },
).show()
} else {
// Zurück zum Opening Screen der lädt dann automatisch (siehe Änderung 2)
activity?.finishQuestionnaire() ?: goToNextQuestion()
}
}
}
}
}
override fun validate(): Boolean = true
override fun saveAnswer() {}
private fun applyResponsiveTextSizing(btn: MaterialButton) {
// Max-/Min-Sp anhand der Bildschirmhöhe (in sp) berechnen
val dm = btn.resources.displayMetrics
val scale = ViewUtils.spScale(dm)
val maxSp = (dm.heightPixels * 0.028f) / scale // ~2.8% der Höhe
val minSp = (dm.heightPixels * 0.018f) / scale // ~1.8% der Höhe
// AutoSize aktivieren (schrumpft/expandiert den Text innerhalb des Buttons)
TextViewCompat.setAutoSizeTextTypeUniformWithConfiguration(
btn,
minSp.toInt(),
maxSp.toInt(),
1,
TypedValue.COMPLEX_UNIT_SP
)
btn.setSingleLine(true)
btn.maxLines = 1
btn.isAllCaps = false
// Padding nach Layout proportional zur Button-Höhe setzen (wirkt auf Lesbarkeit)
btn.post {
val padH = (btn.height * 0.18f).toInt()
val padV = (btn.height * 0.12f).toInt()
btn.setPadding(padH, padV, padH, padV)
}
}
// ----------------------------------------------------------------
private fun showLoading(show: Boolean) {
val progressBar = layout.findViewById<ProgressBar>(R.id.progressBar)
val finishButton = layout.findViewById<Button>(R.id.Qfinish)
val prevButton = layout.findViewById<Button>(R.id.Qprev)
progressBar?.visibility = if (show) View.VISIBLE else View.GONE
finishButton?.isEnabled = !show
prevButton?.isEnabled = !show
}
}

View File

@ -0,0 +1,209 @@
package com.dano.test1.questionnaire.handlers
import android.content.Context
import android.util.TypedValue
import android.view.View
import android.widget.*
import androidx.core.widget.TextViewCompat
import kotlinx.coroutines.*
import com.dano.test1.questionnaire.GlobalValues
import com.dano.test1.LanguageManager
import com.dano.test1.data.AnswerRepository
import com.dano.test1.questionnaire.QuestionFlowResolver
import com.dano.test1.questionnaire.QuestionHandler
import com.dano.test1.questionnaire.QuestionItem
import com.dano.test1.R
import com.dano.test1.utils.ViewUtils
/*
Zweck:
- Steuert eine Frage mit mehreren auswählbaren Antwortoptionen (Checkboxen).
*/
class HandlerMultiCheckboxQuestion(
private val context: Context,
private val answers: MutableMap<String, Any>,
private val languageID: String,
private val goToNextQuestion: () -> Unit,
private val goToPreviousQuestion: () -> Unit,
private val goToQuestionById: (String) -> Unit,
private val showToast: (String) -> Unit,
private val questionnaireMeta: String //
) : QuestionHandler {
private lateinit var layout: View
private lateinit var question: QuestionItem.MultiCheckboxQuestion
override fun bind(layout: View, question: QuestionItem) {
this.layout = layout
this.question = question as QuestionItem.MultiCheckboxQuestion
val container = layout.findViewById<LinearLayout>(R.id.CheckboxContainer)
val questionTitle = layout.findViewById<TextView>(R.id.question)
val questionTextView = layout.findViewById<TextView>(R.id.textView)
questionTextView.text = this.question.textKey?.let { LanguageManager.getText(languageID, it) } ?: ""
questionTitle.text = this.question.question?.let { LanguageManager.getText(languageID, it) } ?: ""
// Textgrößen pro Bildschirmhöhe (wie bei deinen anderen Handlern)
ViewUtils.setTextSizePercentOfScreenHeight(questionTextView, 0.03f)
ViewUtils.setTextSizePercentOfScreenHeight(questionTitle, 0.03f)
container.removeAllViews()
val answerMapKey = question.question ?: (question.id ?: "")
val selectedKeys = selectedKeysFromAnswers(answerMapKey)
// Checkbox-Schrift & Zeilenhöhe dynamisch ableiten (kein Abschneiden)
val dm = layout.resources.displayMetrics
val scale = ViewUtils.spScale(dm)
val cbTextSp = (dm.heightPixels * ViewUtils.scaledScreenHeightPercent(0.025f)) / scale
val cbTextPx = cbTextSp * scale
val cbPadV = (cbTextPx * 0.40f).toInt()
val cbMinH = (cbTextPx * 1.60f + 2 * cbPadV).toInt()
(this.question.options ?: emptyList()).forEach { option ->
val checkBox = CheckBox(context).apply {
text = LanguageManager.getText(languageID, option.key)
tag = option.key
isChecked = selectedKeys.contains(option.key)
// Textgröße prozentual & Zeilenhöhe/Padding für Lesbarkeit
TextViewCompat.setAutoSizeTextTypeWithDefaults(this, TextViewCompat.AUTO_SIZE_TEXT_TYPE_NONE)
setTextSize(TypedValue.COMPLEX_UNIT_SP, cbTextSp)
includeFontPadding = true
setPadding(paddingLeft, cbPadV, paddingRight, cbPadV)
minHeight = cbMinH
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
val scale = context.resources.displayMetrics.density
val marginBottom = (16 * scale + 0.5f).toInt()
setMargins(0, 0, 0, marginBottom)
}
}
container.addView(checkBox)
}
if (answerMapKey.isNotBlank() && !answers.containsKey(answerMapKey)) {
CoroutineScope(Dispatchers.IO).launch {
try {
val clientCode = GlobalValues.LAST_CLIENT_CODE
if (clientCode.isNullOrBlank()) return@launch
val allAnswersForClient = AnswerRepository.getAnswersForClient(clientCode)
val myQuestionId = questionnaireMeta + "-" + question.question
val dbAnswer = allAnswersForClient.find { it.questionId == myQuestionId }?.answerValue
if (!dbAnswer.isNullOrBlank()) {
val parsed = parseMultiAnswer(dbAnswer)
withContext(Dispatchers.Main) {
// UI: Checkboxen setzen
for (i in 0 until container.childCount) {
val cb = container.getChildAt(i) as? CheckBox ?: continue
cb.isChecked = parsed.contains(cb.tag.toString())
}
// answers-Map aktualisieren
answers[answerMapKey] = parsed.toList()
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
layout.findViewById<Button>(R.id.Qnext).setOnClickListener {
if (validate()) {
saveAnswer()
val selected = collectSelectedKeys()
val nextId = QuestionFlowResolver.nextQuestionIdForMultiCheckbox(question, selected)
if (!nextId.isNullOrEmpty()) {
goToQuestionById(nextId)
} else {
goToNextQuestion()
}
} else {
val msgKey = if (question.minSelection == 1) {
"select_at_least_one_answer"
} else {
"select_at_least_minimum"
}
val errorMessage = LanguageManager.getTextFormatted(languageID, msgKey, "choose_more_elements")
showToast(errorMessage)
}
}
layout.findViewById<Button>(R.id.Qprev).setOnClickListener {
goToPreviousQuestion()
}
}
override fun validate(): Boolean {
val container = layout.findViewById<LinearLayout>(R.id.CheckboxContainer)
var selectedCount = 0
for (i in 0 until container.childCount) {
val checkBox = container.getChildAt(i) as? CheckBox ?: continue
if (checkBox.isChecked) selectedCount++
}
return selectedCount >= question.minSelection
}
private fun collectSelectedKeys(): Set<String> {
val container = layout.findViewById<LinearLayout>(R.id.CheckboxContainer)
val selectedKeys = mutableSetOf<String>()
for (i in 0 until container.childCount) {
val checkBox = container.getChildAt(i) as? CheckBox ?: continue
if (checkBox.isChecked) selectedKeys.add(checkBox.tag.toString())
}
return selectedKeys
}
override fun saveAnswer() {
val selectedKeys = collectSelectedKeys().toList()
question.question?.let { questionKey ->
answers[questionKey] = selectedKeys
}
}
private fun selectedKeysFromAnswers(answerMapKey: String): Set<String> {
if (answerMapKey.isBlank()) return emptySet()
return when (val stored = answers[answerMapKey]) {
is Collection<*> -> stored.mapNotNull { it?.toString() }.filter { it.isNotBlank() }.toSet()
is String -> if (stored.isNotBlank()) parseMultiAnswer(stored) else emptySet()
else -> emptySet()
}
}
private fun parseMultiAnswer(dbAnswer: String): Set<String> {
val trimmed = dbAnswer.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('"', '\''))
}
}
}

View File

@ -0,0 +1,151 @@
package com.dano.test1.questionnaire.handlers
import android.content.Context
import android.view.View
import android.text.Html
import android.widget.*
import kotlinx.coroutines.*
import com.dano.test1.questionnaire.AnswerRestoreHelper
import com.dano.test1.questionnaire.GlobalValues
import com.dano.test1.LanguageManager
import com.dano.test1.questionnaire.QuestionFlowResolver
import com.dano.test1.questionnaire.QuestionHandler
import com.dano.test1.questionnaire.QuestionItem
import com.dano.test1.R
import com.dano.test1.utils.ViewUtils
/*
Zweck:
- Steuert eine Einzelfrage mit genau einer auswählbaren Antwort (RadioButtons).
*/
class HandlerRadioQuestion(
private val context: Context,
private val answers: MutableMap<String, Any>,
private val languageID: String,
private val goToNextQuestion: () -> Unit,
private val goToPreviousQuestion: () -> Unit,
private val goToQuestionById: (String) -> Unit,
private val showToast: (String) -> Unit,
private val questionnaireMeta: String
) : QuestionHandler {
private lateinit var layout: View
private lateinit var question: QuestionItem.RadioQuestion
override fun bind(layout: View, question: QuestionItem) {
this.layout = layout
this.question = question as QuestionItem.RadioQuestion
val radioGroup = layout.findViewById<RadioGroup>(R.id.RadioGroup)
val questionTextView = layout.findViewById<TextView>(R.id.textView)
val questionTitle = layout.findViewById<TextView>(R.id.question)
questionTextView.text = question.textKey?.let { LanguageManager.getText(languageID, it) } ?: ""
questionTitle.text = question.question?.let {
Html.fromHtml(LanguageManager.getText(languageID, it), Html.FROM_HTML_MODE_LEGACY)
} ?: ""
ViewUtils.setTextSizePercentOfScreenHeight(questionTextView, 0.03f)
ViewUtils.setTextSizePercentOfScreenHeight(questionTitle, 0.03f)
radioGroup.removeAllViews()
(question.options ?: emptyList()).forEach { option ->
val radioButton = RadioButton(context).apply {
text = LanguageManager.getText(languageID, option.key)
tag = option.key
// RadioButton-Text analog zu EditTexts: 2.5% der Bildschirmhöhe
ViewUtils.setTextSizePercentOfScreenHeight(this, 0.025f)
layoutParams = RadioGroup.LayoutParams(
RadioGroup.LayoutParams.MATCH_PARENT,
RadioGroup.LayoutParams.WRAP_CONTENT
).apply {
val scale = context.resources.displayMetrics.density
val margin = (16 * scale + 0.5f).toInt()
setMargins(0, 0, 0, margin)
}
val padding = (12 * resources.displayMetrics.density).toInt()
setPadding(padding, padding, padding, padding)
}
radioGroup.addView(radioButton)
}
restorePreviousAnswer(radioGroup)
val answerMapKey = question.question ?: (question.id ?: "")
if (answerMapKey.isNotBlank() && !answers.containsKey(answerMapKey)) {
AnswerRestoreHelper.restoreIfMissing(
CoroutineScope(Dispatchers.Main),
questionnaireMeta,
answerMapKey,
) { dbAnswer ->
for (i in 0 until radioGroup.childCount) {
val radioButton = radioGroup.getChildAt(i) as RadioButton
if (radioButton.tag == dbAnswer) {
radioButton.isChecked = true
break
}
}
answers[answerMapKey] = dbAnswer
}
}
layout.findViewById<Button>(R.id.Qnext).setOnClickListener {
if (validate()) {
saveAnswer()
val selectedId = radioGroup.checkedRadioButtonId
val selectedRadioButton = layout.findViewById<RadioButton>(selectedId)
val selectedKey = selectedRadioButton.tag.toString()
val nextId = QuestionFlowResolver.nextQuestionIdForRadio(question, selectedKey)
if (!nextId.isNullOrEmpty()) {
goToQuestionById(nextId)
} else {
goToNextQuestion()
}
} else {
showToast(LanguageManager.getText(languageID, "select_one_answer"))
}
}
layout.findViewById<Button>(R.id.Qprev).setOnClickListener {
goToPreviousQuestion()
}
}
private fun restorePreviousAnswer(radioGroup: RadioGroup) {
question.question?.let { questionKey ->
val savedAnswer = answers[questionKey] as? String
savedAnswer?.let { saved ->
for (i in 0 until radioGroup.childCount) {
val radioButton = radioGroup.getChildAt(i) as RadioButton
if (radioButton.tag == saved) {
radioButton.isChecked = true
break
}
}
}
}
}
override fun validate(): Boolean {
return layout.findViewById<RadioGroup>(R.id.RadioGroup).checkedRadioButtonId != -1
}
override fun saveAnswer() {
val radioGroup = layout.findViewById<RadioGroup>(R.id.RadioGroup)
val selectedId = radioGroup.checkedRadioButtonId
val selectedRadioButton = layout.findViewById<RadioButton>(selectedId)
val answerKey = selectedRadioButton.tag.toString()
question.question?.let { questionKey ->
answers[questionKey] = answerKey
}
}
}

View File

@ -0,0 +1,133 @@
package com.dano.test1.questionnaire.handlers
import android.content.Context
import android.view.View
import android.widget.Button
import android.widget.SeekBar
import android.widget.TextView
import com.dano.test1.LanguageManager
import com.dano.test1.R
import com.dano.test1.questionnaire.QuestionFlowResolver
import com.dano.test1.questionnaire.QuestionHandler
import com.dano.test1.questionnaire.QuestionItem
import com.dano.test1.questionnaire.QuestionNotesHelper
import com.dano.test1.utils.ViewUtils
class HandlerSliderQuestion(
private val context: Context,
private val answers: MutableMap<String, Any>,
private val languageID: String,
private val goToNextQuestion: () -> Unit,
private val goToPreviousQuestion: () -> Unit,
private val goToQuestionById: (String) -> Unit,
private val showToast: (String) -> Unit,
private val onAnswerChanged: () -> Unit = {},
) : QuestionHandler {
private lateinit var layout: View
private lateinit var question: QuestionItem.SliderQuestion
private lateinit var seekBar: SeekBar
private lateinit var valueLabel: TextView
private var minVal = 0
private var maxVal = 100
private var step = 1
override fun bind(layout: View, question: QuestionItem) {
if (question !is QuestionItem.SliderQuestion) return
this.layout = layout
this.question = question
val questionTextView = layout.findViewById<TextView>(R.id.question)
val titleTextView = layout.findViewById<TextView>(R.id.textView)
seekBar = layout.findViewById(R.id.value_slider)
valueLabel = layout.findViewById(R.id.slider_value)
val minLabel = layout.findViewById<TextView>(R.id.slider_min)
val maxLabel = layout.findViewById<TextView>(R.id.slider_max)
QuestionNotesHelper.bind(layout, languageID, question.noteBeforeKey, question.noteAfterKey)
val questionKey = question.question.trim()
questionTextView.text =
if (questionKey.isNotEmpty()) LanguageManager.getText(languageID, questionKey) else ""
questionTextView.visibility =
if (questionTextView.text.isNullOrBlank()) View.GONE else View.VISIBLE
val titleKey = question.textKey?.trim().orEmpty()
titleTextView.text =
if (titleKey.isNotEmpty()) LanguageManager.getText(languageID, titleKey) else ""
titleTextView.visibility =
if (titleTextView.text.isNullOrBlank()) View.GONE else View.VISIBLE
ViewUtils.setTextSizePercentOfScreenHeight(titleTextView, 0.03f)
ViewUtils.setTextSizePercentOfScreenHeight(questionTextView, 0.03f)
ViewUtils.setTextSizePercentOfScreenHeight(valueLabel, 0.028f)
val range = question.range
minVal = range?.min ?: 0
maxVal = range?.max ?: 100
step = (question.step ?: 1).coerceAtLeast(1)
minLabel.text = formatValue(minVal)
maxLabel.text = formatValue(maxVal)
ViewUtils.setTextSizePercentOfScreenHeight(minLabel, 0.022f)
ViewUtils.setTextSizePercentOfScreenHeight(maxLabel, 0.022f)
val span = ((maxVal - minVal) / step).coerceAtLeast(1)
seekBar.max = span
val saved = question.question.let { answers[it] as? String }?.toIntOrNull()
val initial = saved?.coerceIn(minVal, maxVal) ?: minVal
seekBar.progress = ((initial - minVal) / step).coerceIn(0, span)
updateValueLabel(currentValue())
seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(sb: SeekBar?, progress: Int, fromUser: Boolean) {
updateValueLabel(valueForProgress(progress))
if (fromUser) onAnswerChanged()
}
override fun onStartTrackingTouch(sb: SeekBar?) {}
override fun onStopTrackingTouch(sb: SeekBar?) {
sb?.let { updateValueLabel(valueForProgress(it.progress)) }
onAnswerChanged()
}
})
layout.findViewById<Button>(R.id.Qnext).setOnClickListener {
if (validate()) {
saveAnswer()
QuestionFlowResolver.navigateAfterAnswer(
question.nextQuestionId,
goToQuestionById,
goToNextQuestion,
)
}
}
layout.findViewById<Button>(R.id.Qprev).setOnClickListener {
saveAnswer()
goToPreviousQuestion()
}
}
private fun currentValue(): Int = valueForProgress(seekBar.progress)
private fun valueForProgress(progress: Int): Int = minVal + progress * step
private fun updateValueLabel(value: Int) {
valueLabel.text = formatValue(value)
}
private fun formatValue(value: Int): String {
val unit = question.unitLabel?.let { LanguageManager.getText(languageID, it) }?.trim().orEmpty()
return if (unit.isNotEmpty()) "$value $unit" else value.toString()
}
override fun validate(): Boolean = true
override fun saveAnswer() {
val key = question.question
if (key.isNotBlank()) {
answers[key] = currentValue().toString()
}
}
}

View File

@ -0,0 +1,148 @@
package com.dano.test1.questionnaire.handlers
import android.content.Context
import android.view.View
import android.widget.*
import kotlinx.coroutines.*
import com.dano.test1.questionnaire.AnswerLookup
import com.dano.test1.questionnaire.AnswerRestoreHelper
import com.dano.test1.questionnaire.GlobalValues
import com.dano.test1.questionnaire.QuestionFlowResolver
import com.dano.test1.LanguageManager
import com.dano.test1.questionnaire.QuestionHandler
import com.dano.test1.questionnaire.QuestionItem
import com.dano.test1.questionnaire.SpinnerAnswerRules
import com.dano.test1.questionnaire.SpinnerSelectionHelper
import com.dano.test1.R
import com.dano.test1.utils.ViewUtils
class HandlerStringSpinner(
private val context: Context,
private val answers: MutableMap<String, Any>,
private val languageID: String,
private val goToNextQuestion: () -> Unit,
private val goToPreviousQuestion: () -> Unit,
private val goToQuestionById: (String) -> Unit,
private val showToast: (String) -> Unit,
private val questionnaireMeta: String,
) : QuestionHandler {
private lateinit var layout: View
private lateinit var question: QuestionItem.StringSpinnerQuestion
private var optionKeys: List<String> = emptyList()
private var displayOptions: List<String> = emptyList()
private var spinnerAdapterReady = false
override fun bind(layout: View, question: QuestionItem) {
if (question !is QuestionItem.StringSpinnerQuestion) return
this.layout = layout
this.question = question
val questionTextView = layout.findViewById<TextView>(R.id.question)
val textView = layout.findViewById<TextView>(R.id.textView)
val spinner = layout.findViewById<Spinner>(R.id.string_spinner)
questionTextView.text = question.question.let { LanguageManager.getText(languageID, it) }
textView.text = question.textKey?.let { LanguageManager.getText(languageID, it) } ?: ""
ViewUtils.setTextSizePercentOfScreenHeight(textView, 0.03f)
ViewUtils.setTextSizePercentOfScreenHeight(questionTextView, 0.03f)
optionKeys = buildOptionKeys()
displayOptions = SpinnerSelectionHelper.buildStringSpinnerItems(languageID, optionKeys)
if (displayOptions.size <= 1) {
showToast(LanguageManager.getText(languageID, "questionnaire_missing_options"))
layout.findViewById<Button>(R.id.Qprev)?.setOnClickListener { goToPreviousQuestion() }
return
}
val answerMapKey = question.question
val savedSelection = AnswerLookup.stringValue(answers, question)
applySpinnerSelection(spinner, savedSelection)
if (savedSelection.isNullOrBlank()) {
AnswerRestoreHelper.restoreIfMissing(
CoroutineScope(Dispatchers.Main),
questionnaireMeta,
answerMapKey,
) { dbAnswer ->
answers[answerMapKey] = dbAnswer
answers[question.id] = dbAnswer
applySpinnerSelection(spinner, dbAnswer)
}
}
layout.findViewById<Button>(R.id.Qnext)?.setOnClickListener {
if (!validate()) return@setOnClickListener
saveAnswer()
val selected = layout.findViewById<Spinner>(R.id.string_spinner).selectedItem as? String ?: ""
val nextId = QuestionFlowResolver.nextQuestionIdForStringSpinner(question, languageID, selected)
if (!nextId.isNullOrEmpty()) {
goToQuestionById(nextId)
} else {
goToNextQuestion()
}
}
layout.findViewById<Button>(R.id.Qprev)?.setOnClickListener { goToPreviousQuestion() }
}
/** Re-apply selection from [answers] (e.g. after all-in-one layout pass). */
fun restoreSelectionFromAnswers() {
if (!::layout.isInitialized) return
val spinner = layout.findViewById<Spinner>(R.id.string_spinner) ?: return
applySpinnerSelection(spinner, AnswerLookup.stringValue(answers, question))
}
override fun validate(): Boolean {
val spinner = layout.findViewById<Spinner>(R.id.string_spinner)
val selected = spinner.selectedItem as? String
return if (SpinnerAnswerRules.isUnselected(languageID, selected)) {
showToast(LanguageManager.getText(languageID, "select_one_answer"))
false
} else {
true
}
}
override fun saveAnswer() {
val spinner = layout.findViewById<Spinner>(R.id.string_spinner)
val selected = spinner.selectedItem as? String
if (SpinnerAnswerRules.isUnselected(languageID, selected)) return
val stored = selected?.trim() ?: return
answers[question.question] = stored
answers[question.id] = stored
}
private fun applySpinnerSelection(spinner: Spinner, saved: String?) {
ensureSpinnerAdapter(spinner)
val index = SpinnerSelectionHelper.indexForSavedValue(
languageID,
displayOptions,
saved,
optionKeys,
)
if (index < 0) return
if ((spinner.adapter?.count ?: 0) > index) {
spinner.setSelection(index, false)
}
}
private fun ensureSpinnerAdapter(spinner: Spinner) {
if (spinnerAdapterReady && spinner.adapter != null) return
ViewUtils.setupResponsiveSpinner(context, spinner, displayOptions, null)
spinnerAdapterReady = true
}
private fun buildOptionKeys(): List<String> {
val base = (question.options ?: emptyList()).map { it.trim() }.filter { it.isNotEmpty() }
val otherKey = question.otherOptionKey?.trim()?.takeIf { it.isNotEmpty() }
return if (otherKey != null && !question.otherNextQuestionId.isNullOrBlank()) {
if (base.none { it.equals(otherKey, ignoreCase = true) }) base + otherKey else base
} else {
base
}
}
}

View File

@ -0,0 +1,141 @@
package com.dano.test1.questionnaire.handlers
import android.content.Context
import android.view.View
import android.widget.*
import kotlinx.coroutines.*
import com.dano.test1.questionnaire.AnswerLookup
import com.dano.test1.questionnaire.AnswerRestoreHelper
import com.dano.test1.questionnaire.QuestionFlowResolver
import com.dano.test1.questionnaire.SpinnerAnswerRules
import com.dano.test1.questionnaire.SpinnerSelectionHelper
import com.dano.test1.LanguageManager
import com.dano.test1.questionnaire.QuestionHandler
import com.dano.test1.questionnaire.QuestionItem
import com.dano.test1.questionnaire.QuestionNotesHelper
import com.dano.test1.R
import com.dano.test1.utils.ViewUtils
class HandlerValueSpinner(
private val context: Context,
private val answers: MutableMap<String, Any>,
private val languageID: String,
private val goToNextQuestion: () -> Unit,
private val goToPreviousQuestion: () -> Unit,
private val goToQuestionById: (String) -> Unit,
private val showToast: (String) -> Unit,
private val questionnaireMeta: String,
) : QuestionHandler {
private lateinit var layout: View
private lateinit var question: QuestionItem.ValueSpinnerQuestion
private lateinit var spinnerItems: List<String>
private var spinnerAdapterReady = false
override fun bind(layout: View, question: QuestionItem) {
if (question !is QuestionItem.ValueSpinnerQuestion) return
this.layout = layout
this.question = question
val questionTextView = layout.findViewById<TextView>(R.id.question)
val textView = layout.findViewById<TextView>(R.id.textView)
val spinner = layout.findViewById<Spinner>(R.id.value_spinner)
QuestionNotesHelper.bind(layout, languageID, question.noteBeforeKey, question.noteAfterKey)
questionTextView.text = LanguageManager.getText(languageID, question.question)
textView.text = question.textKey?.let { LanguageManager.getText(languageID, it) } ?: ""
ViewUtils.setTextSizePercentOfScreenHeight(textView, 0.03f)
ViewUtils.setTextSizePercentOfScreenHeight(questionTextView, 0.03f)
val prompt = LanguageManager.getText(languageID, "choose_answer")
spinnerItems = listOf(prompt) + if (question.range != null) {
(question.range.min..question.range.max).map { it.toString() }
} else {
(question.options ?: emptyList()).map { it.value.toString() }
}
val answerMapKey = question.question
val savedValue = AnswerLookup.valueSpinnerValue(answers, question)
applySpinnerSelection(spinner, savedValue)
if (savedValue.isNullOrBlank()) {
AnswerRestoreHelper.restoreIfMissing(
CoroutineScope(Dispatchers.Main),
questionnaireMeta,
answerMapKey,
) { dbAnswer ->
answers[answerMapKey] = dbAnswer
answers[question.id] = dbAnswer
applySpinnerSelection(spinner, dbAnswer)
}
}
layout.findViewById<Button>(R.id.Qnext)?.setOnClickListener {
if (validate()) {
saveAnswer()
val selectedValue = (spinner.selectedItem as? String)?.toIntOrNull()
val nextId = QuestionFlowResolver.nextQuestionIdForValueSpinner(question, selectedValue)
if (!nextId.isNullOrEmpty()) {
goToQuestionById(nextId)
} else {
goToNextQuestion()
}
}
}
layout.findViewById<Button>(R.id.Qprev)?.setOnClickListener { goToPreviousQuestion() }
}
fun restoreSelectionFromAnswers() {
if (!::layout.isInitialized) return
val spinner = layout.findViewById<Spinner>(R.id.value_spinner) ?: return
applySpinnerSelection(spinner, AnswerLookup.valueSpinnerValue(answers, question))
}
override fun validate(): Boolean {
val spinner = layout.findViewById<Spinner>(R.id.value_spinner)
val selected = spinner.selectedItem as? String
return if (SpinnerAnswerRules.isUnselected(languageID, selected)) {
showToast(LanguageManager.getText(languageID, "select_one_answer"))
false
} else {
true
}
}
private fun applySpinnerSelection(spinner: Spinner, saved: String?) {
ensureSpinnerAdapter(spinner)
val index = selectionIndexForValue(saved) ?: return
if ((spinner.adapter?.count ?: 0) > index) {
spinner.setSelection(index, false)
}
}
private fun ensureSpinnerAdapter(spinner: Spinner) {
if (spinnerAdapterReady && spinner.adapter != null) return
ViewUtils.setupResponsiveSpinner(context, spinner, spinnerItems, null)
spinnerAdapterReady = true
}
private fun selectionIndexForValue(saved: String?): Int? {
SpinnerSelectionHelper.indexForSavedValue(languageID, spinnerItems, saved)
.takeIf { it >= 0 }
?.let { return it }
val numeric = saved?.trim()?.toDoubleOrNull()?.toInt() ?: return null
val idx = spinnerItems.indexOfFirst { it.trim().toIntOrNull() == numeric }
return idx.takeIf { it >= 0 }
}
override fun saveAnswer() {
val spinner = layout.findViewById<Spinner>(R.id.value_spinner)
val selected = spinner.selectedItem as? String ?: return
if (SpinnerAnswerRules.isUnselected(languageID, selected)) return
val stored = selected.trim()
answers[question.question] = stored
answers[question.id] = stored
}
}

View File

@ -0,0 +1,73 @@
package com.dano.test1.scoring
import android.content.Context
import com.dano.test1.data.AnswerRepository
import com.dano.test1.data.CompletedQuestionnaireRepository
import com.dano.test1.network.ClientScoringReview
import com.dano.test1.network.QuestionnaireCache
import com.dano.test1.network.ScoringProfileDefinition
import com.dano.test1.network.ScoringReviewProfile
import com.dano.test1.questionnaire.AnswerKeyUtils
import com.google.gson.JsonParser
object LocalScoringReviewBuilder {
suspend fun build(
context: Context,
clientCodes: List<String>,
profiles: List<ScoringProfileDefinition>,
): List<ClientScoringReview> {
if (profiles.isEmpty() || clientCodes.isEmpty()) return emptyList()
val activeProfiles = profiles.filter { it.isActive }
if (activeProfiles.isEmpty()) return emptyList()
val questionnaireIds = activeProfiles
.flatMap { it.questionnaires.map { m -> m.questionnaireID } }
.distinct()
return clientCodes.map { clientCode ->
val completions = CompletedQuestionnaireRepository.getAllForClient(clientCode)
.filter { it.isDone }
val completedIds = completions.map { it.questionnaireId }.toSet()
val pointsByQuestionnaire = mutableMapOf<String, Int>()
for (qnId in questionnaireIds) {
if (!completedIds.contains(qnId)) continue
val detailJson = QuestionnaireCache.loadQuestionnaireJson(context, qnId) ?: continue
val root = runCatching { JsonParser.parseString(detailJson).asJsonObject }.getOrNull()
?: continue
val questions = root.getAsJsonArray("questions") ?: continue
val answers = AnswerRepository.getAnswersForClientAndQuestionnaire(clientCode, qnId)
.associateBy(
{ AnswerKeyUtils.lookupKey(qnId, it.questionId) },
{ it.answerValue },
)
pointsByQuestionnaire[qnId] = QuestionnaireScoreCalculator.compute(questions, answers)
}
val reviewProfiles = activeProfiles.mapNotNull { profile ->
val result = ProfileScoringEngine.computeProfile(
profile,
pointsByQuestionnaire,
completedIds,
) ?: return@mapNotNull null
ScoringReviewProfile(
profileID = result.profileID,
name = result.name,
weightedTotal = result.weightedTotal,
calculatedBand = result.calculatedBand,
greenMin = profile.greenMin,
greenMax = profile.greenMax,
yellowMin = profile.yellowMin,
yellowMax = profile.yellowMax,
redMin = profile.redMin,
coachBand = null,
pendingReview = true,
)
}
ClientScoringReview(clientCode = clientCode, profiles = reviewProfiles)
}
}
}

View File

@ -0,0 +1,57 @@
package com.dano.test1.scoring
import com.dano.test1.network.ScoringProfileDefinition
import kotlin.math.round
/**
* Mirrors profile weighted totals and [qdb_band_for_total] on the server.
*/
object ProfileScoringEngine {
data class ProfileResult(
val profileID: String,
val name: String,
val weightedTotal: Double,
val calculatedBand: String,
val complete: Boolean,
)
fun bandForTotal(total: Double, profile: ScoringProfileDefinition): String {
val greenMin = profile.greenMin
val greenMax = profile.greenMax
val yellowMin = profile.yellowMin
val yellowMax = profile.yellowMax
val redMin = profile.redMin
if (total >= greenMin && total <= greenMax) return "green"
if (total >= yellowMin && total <= yellowMax) return "yellow"
if (total >= redMin) return "red"
if (total < greenMin) return "green"
if (total < yellowMin) return "yellow"
return "red"
}
fun computeProfile(
profile: ScoringProfileDefinition,
questionnairePoints: Map<String, Int>,
completedQuestionnaireIds: Set<String>,
): ProfileResult? {
if (!profile.isActive) return null
var weightedTotal = 0.0
for (member in profile.questionnaires) {
if (!completedQuestionnaireIds.contains(member.questionnaireID)) {
return null
}
val sumPoints = questionnairePoints[member.questionnaireID] ?: 0
weightedTotal += sumPoints * member.weight
}
val rounded = round(weightedTotal * 10000.0) / 10000.0
return ProfileResult(
profileID = profile.profileID,
name = profile.name,
weightedTotal = rounded,
calculatedBand = bandForTotal(rounded, profile),
complete = true,
)
}
}

View File

@ -0,0 +1,127 @@
package com.dano.test1.scoring
import com.google.gson.JsonArray
import com.google.gson.JsonObject
/**
* Mirrors [qdb_compute_questionnaire_score] / [qdb_score_points_for_question] on the server.
*/
object QuestionnaireScoreCalculator {
private val defaultGlassPoints = mapOf(
"never_glass" to 0,
"little_glass" to 1,
"moderate_glass" to 2,
"much_glass" to 3,
"extreme_glass" to 4,
)
fun compute(questions: JsonArray, answers: Map<String, String>): Int {
var total = 0
for (element in questions) {
if (!element.isJsonObject) continue
total += scoreQuestion(element.asJsonObject, answers)
}
return total
}
private fun scoreQuestion(question: JsonObject, answers: Map<String, String>): Int {
val layout = question.get("layout")?.asString.orEmpty()
val symptoms = question.getAsJsonArray("symptoms")
if (layout == "glass_scale_question" && symptoms != null && symptoms.size() > 0) {
val ruleMap = parseScoreRuleMap(question)
var sum = 0
for (symptom in symptoms) {
val symptomKey = symptom.asString.trim()
if (symptomKey.isEmpty()) continue
val levelKey = answers[symptomKey]?.trim().orEmpty()
if (levelKey.isEmpty()) continue
sum += ruleMap[symptomKey]?.get(levelKey)
?: ruleMap[""]?.get(levelKey)
?: defaultGlassPoints[levelKey]
?: 0
}
return sum
}
val stored = resolveStoredAnswer(answers, question) ?: return 0
val pointsMap = parsePointsMap(question)
return when (layout) {
"radio_question", "string_spinner" -> pointsMap[stored] ?: 0
"multi_check_box_question" -> decodeMultiCheck(stored).sumOf { pointsMap[it] ?: 0 }
"slider_question", "value_spinner" -> {
val ruleMap = parseScoreRuleMap(question)
val levelKey = numericLevelKey(stored)
ruleMap[""]?.get(levelKey) ?: 0
}
else -> 0
}
}
private fun resolveStoredAnswer(answers: Map<String, String>, 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]?.trim()?.takeIf { it.isNotEmpty() }
}
return shortId?.let { answers[it]?.trim()?.takeIf { v -> v.isNotEmpty() } }
}
private fun parsePointsMap(question: JsonObject): Map<String, Int> {
val map = question.getAsJsonObject("pointsMap") ?: return emptyMap()
return map.entrySet().mapNotNull { (key, value) ->
if (!value.isJsonPrimitive) return@mapNotNull null
key to value.asInt
}.toMap()
}
private fun parseScoreRuleMap(question: JsonObject): Map<String, Map<String, Int>> {
val root = question.getAsJsonObject("scoreRuleMap") ?: return emptyMap()
val out = mutableMapOf<String, MutableMap<String, Int>>()
for ((scopeKey, levelsEl) in root.entrySet()) {
if (!levelsEl.isJsonObject) continue
val levels = mutableMapOf<String, Int>()
for ((levelKey, pointsEl) in levelsEl.asJsonObject.entrySet()) {
if (pointsEl.isJsonPrimitive) {
levels[levelKey] = pointsEl.asInt
}
}
out[scopeKey] = levels
}
return out
}
private fun numericLevelKey(raw: String): String {
val trimmed = raw.trim()
val asDouble = trimmed.toDoubleOrNull() ?: return trimmed
return kotlin.math.round(asDouble).toInt().toString()
}
/** Mirrors [qdb_decode_multi_check_values]. */
fun decodeMultiCheck(raw: String): List<String> {
val trimmed = raw.trim()
if (trimmed.isEmpty()) return emptyList()
if (trimmed.startsWith("[")) {
val decoded = runCatching {
com.google.gson.JsonParser.parseString(trimmed).asJsonArray
}.getOrNull()
if (decoded != null) {
return decoded.mapNotNull { el ->
el.takeIf { it.isJsonPrimitive }?.asString?.trim()?.takeIf { it.isNotEmpty() }
}
}
}
val sep = when {
trimmed.contains(',') -> ','
trimmed.contains(';') -> ';'
else -> null
}
if (sep != null) {
return trimmed.split(sep)
.map { it.trim().trim('"', '\'') }
.filter { it.isNotEmpty() }
}
return listOf(trimmed)
}
}

View File

@ -0,0 +1,130 @@
package com.dano.test1.security
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.nio.ByteBuffer
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.Mac
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* Field-level encryption for client codes and answer text at rest.
* Keys live in Android Keystore and never leave the device.
*/
object SensitiveDataCrypto {
private const val KEYSTORE = "AndroidKeyStore"
private const val ENC_KEY_ALIAS = "qdb_sensitive_enc_v1"
private const val HMAC_KEY_ALIAS = "qdb_sensitive_hmac_v1"
private const val PREFIX = "v1:"
private const val GCM_TAG_BITS = 128
@Volatile
private var appContext: Context? = null
@Volatile
private var cachedEncKey: SecretKey? = null
@Volatile
private var cachedHmacKey: SecretKey? = null
fun init(context: Context) {
appContext = context.applicationContext
ensureKeys()
cachedEncKey = null
cachedHmacKey = null
}
private fun ctx(): Context =
appContext ?: error("SensitiveDataCrypto.init() must run in Application.onCreate")
fun encrypt(plaintext: String): String {
if (plaintext.isEmpty()) return plaintext
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, encryptionKey())
val iv = cipher.iv
val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
val packed = ByteBuffer.allocate(iv.size + ciphertext.size)
.put(iv)
.put(ciphertext)
.array()
return PREFIX + Base64.encodeToString(packed, Base64.NO_WRAP)
}
fun decrypt(ciphertext: String): String {
if (ciphertext.isEmpty()) return ciphertext
if (!ciphertext.startsWith(PREFIX)) {
throw IllegalArgumentException("Unsupported ciphertext format")
}
val packed = Base64.decode(ciphertext.removePrefix(PREFIX), Base64.NO_WRAP)
val ivLen = 12
if (packed.size <= ivLen) throw IllegalArgumentException("Ciphertext too short")
val iv = packed.copyOfRange(0, ivLen)
val enc = packed.copyOfRange(ivLen, packed.size)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, encryptionKey(), GCMParameterSpec(GCM_TAG_BITS, iv))
return String(cipher.doFinal(enc), Charsets.UTF_8)
}
/** Stable lookup key for Room FKs without storing plaintext client codes. */
fun clientCodeKey(clientCode: String): String {
val normalized = clientCode.trim()
val mac = Mac.getInstance("HmacSHA256")
mac.init(hmacKey())
val digest = mac.doFinal(normalized.toByteArray(Charsets.UTF_8))
return Base64.encodeToString(digest, Base64.NO_WRAP)
}
private fun ensureKeys() {
val ks = KeyStore.getInstance(KEYSTORE).apply { load(null) }
if (!ks.containsAlias(ENC_KEY_ALIAS)) {
KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE).apply {
init(
KeyGenParameterSpec.Builder(
ENC_KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build(),
)
generateKey()
}
}
if (!ks.containsAlias(HMAC_KEY_ALIAS)) {
KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_HMAC_SHA256, KEYSTORE).apply {
init(
KeyGenParameterSpec.Builder(
HMAC_KEY_ALIAS,
KeyProperties.PURPOSE_SIGN,
)
.setDigests(KeyProperties.DIGEST_SHA256)
.build(),
)
generateKey()
}
}
}
private fun encryptionKey(): SecretKey =
cachedEncKey ?: synchronized(this) {
cachedEncKey ?: run {
val ks = KeyStore.getInstance(KEYSTORE).apply { load(null) }
(ks.getKey(ENC_KEY_ALIAS, null) as SecretKey).also { cachedEncKey = it }
}
}
private fun hmacKey(): SecretKey =
cachedHmacKey ?: synchronized(this) {
cachedHmacKey ?: run {
val ks = KeyStore.getInstance(KEYSTORE).apply { load(null) }
(ks.getKey(HMAC_KEY_ALIAS, null) as SecretKey).also { cachedHmacKey = it }
}
}
}

View File

@ -0,0 +1,135 @@
package com.dano.test1.security
import android.util.Base64
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.Mac
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* Session-token-derived envelope encryption for sensitive mobile API payloads.
*/
object SessionPayloadCrypto {
private const val HKDF_INFO = "qdb-aes"
private const val KEY_LEN = 32
private const val ALG_GCM = "A256GCM"
private const val GCM_TAG_BITS = 128
fun envelope(plainJson: String, tokenHex: String): JsonObject {
val encrypted = encryptGcm(plainJson.toByteArray(Charsets.UTF_8), sessionKey(tokenHex))
return JsonObject().apply {
addProperty("encrypted", true)
addProperty("alg", ALG_GCM)
addProperty("payload", Base64.encodeToString(encrypted, Base64.NO_WRAP))
}
}
fun decryptEnvelope(envelope: JsonObject, tokenHex: String): JsonObject {
if (!envelope.get("encrypted")?.asBoolean.orFalse()) {
throw IllegalArgumentException("Expected encrypted envelope")
}
val payloadB64 = envelope.get("payload")?.asString
?: throw IllegalArgumentException("Missing payload")
val packed = Base64.decode(payloadB64, Base64.NO_WRAP)
val plain = when (envelope.get("alg")?.asString) {
ALG_GCM -> decryptGcm(packed, sessionKey(tokenHex))
else -> decryptCbc(packed, sessionKey(tokenHex))
}
return JsonParser.parseString(String(plain, Charsets.UTF_8)).asJsonObject
}
fun decryptDataElement(data: JsonObject?, tokenHex: String): JsonObject {
if (data == null) return JsonObject()
return if (data.get("encrypted")?.asBoolean == true) {
decryptEnvelope(data, tokenHex)
} else {
data
}
}
private fun sessionKey(tokenHex: String): ByteArray {
val ikm = hexToBytes(tokenHex.trim())
return hkdfSha256(ikm, HKDF_INFO.toByteArray(Charsets.UTF_8), KEY_LEN)
}
private fun encryptGcm(plain: ByteArray, key: ByteArray): ByteArray {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val iv = ByteArray(12).also { SecureRandom().nextBytes(it) }
cipher.init(
Cipher.ENCRYPT_MODE,
SecretKeySpec(normalizeKey(key), "AES"),
GCMParameterSpec(GCM_TAG_BITS, iv),
)
val ct = cipher.doFinal(plain)
return iv + ct
}
private fun decryptGcm(data: ByteArray, key: ByteArray): ByteArray {
val ivLen = 12
if (data.size <= ivLen) throw IllegalArgumentException("Ciphertext too short")
val iv = data.copyOfRange(0, ivLen)
val ct = data.copyOfRange(ivLen, data.size)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(
Cipher.DECRYPT_MODE,
SecretKeySpec(normalizeKey(key), "AES"),
GCMParameterSpec(GCM_TAG_BITS, iv),
)
return cipher.doFinal(ct)
}
private fun decryptCbc(data: ByteArray, key: ByteArray): ByteArray {
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
if (data.size < 16) throw IllegalArgumentException("Ciphertext too short")
val iv = data.copyOfRange(0, 16)
val ct = data.copyOfRange(16, data.size)
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(normalizeKey(key), "AES"), IvParameterSpec(iv))
return cipher.doFinal(ct)
}
private fun normalizeKey(key: ByteArray): ByteArray {
val out = ByteArray(KEY_LEN)
val copyLen = minOf(key.size, KEY_LEN)
key.copyInto(out, 0, 0, copyLen)
return out
}
private fun hkdfSha256(ikm: ByteArray, info: ByteArray, length: Int): ByteArray {
val salt = ByteArray(32)
val prkMac = Mac.getInstance("HmacSHA256")
prkMac.init(SecretKeySpec(salt, "HmacSHA256"))
val prk = prkMac.doFinal(ikm)
val okm = ByteArray(length)
var t = ByteArray(0)
var offset = 0
var counter = 1
while (offset < length) {
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(prk, "HmacSHA256"))
mac.update(t)
mac.update(info)
mac.update(counter.toByte())
t = mac.doFinal()
val copy = minOf(t.size, length - offset)
t.copyInto(okm, offset, 0, copy)
offset += copy
counter++
}
return okm
}
private fun hexToBytes(hex: String): ByteArray {
val clean = hex.trim()
if (clean.length % 2 != 0) return clean.toByteArray(Charsets.UTF_8)
return ByteArray(clean.length / 2) { i ->
clean.substring(i * 2, i * 2 + 2).toInt(16).toByte()
}
}
private fun Boolean?.orFalse() = this == true
}

View File

@ -0,0 +1,49 @@
package com.dano.test1.ui
import android.content.Context
/**
* Persists dev-only A/B overrides. Not a security boundary; values are for local QA.
* Use [effectiveVariant] when branching questionnaire or remote-config logic.
*/
object AbTestSettingsStore {
private const val PREF = "dev_ab_settings"
private const val KEY_OVERRIDE = "override_enabled"
private const val KEY_VARIANT = "variant"
const val VARIANT_NONE = "NONE"
const val VARIANT_A = "A"
const val VARIANT_B = "B"
fun isOverrideEnabled(context: Context): Boolean =
context.getSharedPreferences(PREF, Context.MODE_PRIVATE).getBoolean(KEY_OVERRIDE, false)
fun setOverrideEnabled(context: Context, enabled: Boolean) {
context.getSharedPreferences(PREF, Context.MODE_PRIVATE).edit()
.putBoolean(KEY_OVERRIDE, enabled)
.apply()
}
fun getVariant(context: Context): String =
context.getSharedPreferences(PREF, Context.MODE_PRIVATE).getString(KEY_VARIANT, VARIANT_NONE)
?: VARIANT_NONE
fun setVariant(context: Context, variant: String) {
context.getSharedPreferences(PREF, Context.MODE_PRIVATE).edit()
.putString(KEY_VARIANT, variant)
.apply()
}
/**
* Returns "A" or "B" when dev override is on and a branch is selected; otherwise null.
* Production default is all-in-one (B) when this returns null.
*/
fun effectiveVariant(context: Context): String? {
if (!isOverrideEnabled(context)) return null
return when (getVariant(context)) {
VARIANT_A -> "A"
VARIANT_B -> "B"
else -> null
}
}
}

Some files were not shown because too many files have changed in this diff Show More