initial copy from nat-as app
This commit is contained in:
31
.gitattributes.txt
Normal file
31
.gitattributes.txt
Normal 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
15
.gitignore
vendored
Normal 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
65
.gitignore.txt
Normal 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
3
.idea/.gitignore
generated
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
1
.idea/.name
generated
Normal file
1
.idea/.name
generated
Normal file
@ -0,0 +1 @@
|
|||||||
|
NAT-AS
|
||||||
6
.idea/AndroidProjectSystem.xml
generated
Normal file
6
.idea/AndroidProjectSystem.xml
generated
Normal 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
26
.idea/appInsightsSettings.xml
generated
Normal 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
123
.idea/codeStyles/Project.xml
generated
Normal 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
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal 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
6
.idea/compiler.xml
generated
Normal 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
18
.idea/deploymentTargetSelector.xml
generated
Normal 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
13
.idea/deviceManager.xml
generated
Normal 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
19
.idea/gradle.xml
generated
Normal 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
6
.idea/kotlinc.xml
generated
Normal 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
10
.idea/migrations.xml
generated
Normal 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
16
.idea/misc.xml
generated
Normal 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
6
.idea/render.experimental.xml
generated
Normal 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
17
.idea/runConfigurations.xml
generated
Normal 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
6
.idea/studiobot.xml
generated
Normal 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
6
.idea/vcs.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
4
.kotlin/errors/errors-1780606598936.log
Normal file
4
.kotlin/errors/errors-1780606598936.log
Normal 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
|
||||||
|
|
||||||
416
android-questionnaire-api.md
Normal file
416
android-questionnaire-api.md
Normal 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
1
app/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
89
app/build.gradle.kts
Normal file
89
app/build.gradle.kts
Normal 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
21
app/proguard-rules.pro
vendored
Normal 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
BIN
app/release/app-release.apk
Normal file
Binary file not shown.
BIN
app/release/baselineProfiles/0/app-release.dm
Normal file
BIN
app/release/baselineProfiles/0/app-release.dm
Normal file
Binary file not shown.
BIN
app/release/baselineProfiles/1/app-release.dm
Normal file
BIN
app/release/baselineProfiles/1/app-release.dm
Normal file
Binary file not shown.
37
app/release/output-metadata.json
Normal file
37
app/release/output-metadata.json
Normal 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
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/src/main/AndroidManifest.xml
Normal file
53
app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
506
app/src/main/java/com/dano/test1/MainActivity.kt
Normal file
506
app/src/main/java/com/dano/test1/MainActivity.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
app/src/main/java/com/dano/test1/MyApp.kt
Normal file
52
app/src/main/java/com/dano/test1/MyApp.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
71
app/src/main/java/com/dano/test1/data/AnswerRepository.kt
Normal file
71
app/src/main/java/com/dano/test1/data/AnswerRepository.kt
Normal 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),
|
||||||
|
)
|
||||||
|
}
|
||||||
38
app/src/main/java/com/dano/test1/data/AppDatabase.kt
Normal file
38
app/src/main/java/com/dano/test1/data/AppDatabase.kt
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
40
app/src/main/java/com/dano/test1/data/ClientAnswersCache.kt
Normal file
40
app/src/main/java/com/dano/test1/data/ClientAnswersCache.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/src/main/java/com/dano/test1/data/ClientRepository.kt
Normal file
45
app/src/main/java/com/dano/test1/data/ClientRepository.kt
Normal 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
191
app/src/main/java/com/dano/test1/data/Daos.kt
Normal file
191
app/src/main/java/com/dano/test1/data/Daos.kt
Normal 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)
|
||||||
|
}
|
||||||
139
app/src/main/java/com/dano/test1/data/Entities.kt
Normal file
139
app/src/main/java/com/dano/test1/data/Entities.kt
Normal 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
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
141
app/src/main/java/com/dano/test1/network/ApiModels.kt
Normal file
141
app/src/main/java/com/dano/test1/network/ApiModels.kt
Normal 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)
|
||||||
@ -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) }
|
||||||
|
}
|
||||||
221
app/src/main/java/com/dano/test1/network/DatabaseUploader.kt
Normal file
221
app/src/main/java/com/dano/test1/network/DatabaseUploader.kt
Normal 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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/src/main/java/com/dano/test1/network/HttpModule.kt
Normal file
22
app/src/main/java/com/dano/test1/network/HttpModule.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
86
app/src/main/java/com/dano/test1/network/LoginManager.kt
Normal file
86
app/src/main/java/com/dano/test1/network/LoginManager.kt
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/src/main/java/com/dano/test1/network/NetworkUtilis.kt
Normal file
33
app/src/main/java/com/dano/test1/network/NetworkUtilis.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
178
app/src/main/java/com/dano/test1/network/QuestionnaireCache.kt
Normal file
178
app/src/main/java/com/dano/test1/network/QuestionnaireCache.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
104
app/src/main/java/com/dano/test1/network/SyncCoordinator.kt
Normal file
104
app/src/main/java/com/dano/test1/network/SyncCoordinator.kt
Normal 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 -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/src/main/java/com/dano/test1/network/SyncLog.kt
Normal file
37
app/src/main/java/com/dano/test1/network/SyncLog.kt
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
18
app/src/main/java/com/dano/test1/network/SyncProgress.kt
Normal file
18
app/src/main/java/com/dano/test1/network/SyncProgress.kt
Normal 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
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
86
app/src/main/java/com/dano/test1/network/TokenStore.kt
Normal file
86
app/src/main/java/com/dano/test1/network/TokenStore.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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('-')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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",
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<*>?) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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('"', '\''))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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('"', '\''))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
130
app/src/main/java/com/dano/test1/security/SensitiveDataCrypto.kt
Normal file
130
app/src/main/java/com/dano/test1/security/SensitiveDataCrypto.kt
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
49
app/src/main/java/com/dano/test1/ui/AbTestSettingsStore.kt
Normal file
49
app/src/main/java/com/dano/test1/ui/AbTestSettingsStore.kt
Normal 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
Reference in New Issue
Block a user