initial copy from nat-as app
This commit is contained in:
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
|
||||
}
|
||||
}
|
||||
}
|
||||
26
app/src/main/java/com/dano/test1/ui/AppLanguageStore.kt
Normal file
26
app/src/main/java/com/dano/test1/ui/AppLanguageStore.kt
Normal file
@ -0,0 +1,26 @@
|
||||
package com.dano.test1.ui
|
||||
|
||||
import android.content.Context
|
||||
import com.dano.test1.questionnaire.AppLanguages
|
||||
|
||||
/**
|
||||
* Persists the coach's chosen UI language across app restarts and screens.
|
||||
*/
|
||||
object AppLanguageStore {
|
||||
private const val PREF = "app_settings"
|
||||
private const val KEY_UI_LANGUAGE = "ui_language_id"
|
||||
|
||||
fun get(context: Context): String {
|
||||
val raw = context.getSharedPreferences(PREF, Context.MODE_PRIVATE)
|
||||
.getString(KEY_UI_LANGUAGE, AppLanguages.DEFAULT_ID)
|
||||
?: AppLanguages.DEFAULT_ID
|
||||
return raw.takeIf { AppLanguages.ids.contains(it) } ?: AppLanguages.DEFAULT_ID
|
||||
}
|
||||
|
||||
fun set(context: Context, languageId: String) {
|
||||
if (!AppLanguages.ids.contains(languageId)) return
|
||||
context.getSharedPreferences(PREF, Context.MODE_PRIVATE).edit()
|
||||
.putString(KEY_UI_LANGUAGE, languageId)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
298
app/src/main/java/com/dano/test1/ui/AuthActivity.kt
Normal file
298
app/src/main/java/com/dano/test1/ui/AuthActivity.kt
Normal file
@ -0,0 +1,298 @@
|
||||
package com.dano.test1.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.AdapterView
|
||||
import android.widget.Spinner
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.dano.test1.LanguageManager
|
||||
import com.dano.test1.R
|
||||
import com.dano.test1.network.LoginManager
|
||||
import com.dano.test1.network.QuestionnaireCache
|
||||
import com.dano.test1.network.SyncCoordinator
|
||||
import com.dano.test1.network.TokenStore
|
||||
import com.dano.test1.questionnaire.AppLanguages
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import com.dano.test1.utils.ScreenLoading
|
||||
import com.dano.test1.utils.ViewUtils
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Full-screen login and mandatory password-change flow.
|
||||
* Accounts are created on the web by administrators (no self-service signup).
|
||||
*/
|
||||
class AuthActivity : AppCompatActivity() {
|
||||
|
||||
private val languageId: String
|
||||
get() = AppLanguageStore.get(this)
|
||||
private fun t(key: String): String = LanguageManager.getText(languageId, key)
|
||||
|
||||
private lateinit var loginFieldsGroup: View
|
||||
private lateinit var changePasswordGroup: View
|
||||
private lateinit var tilUsername: TextInputLayout
|
||||
private lateinit var tilPassword: TextInputLayout
|
||||
private lateinit var tilNewPassword: TextInputLayout
|
||||
private lateinit var tilConfirmPassword: TextInputLayout
|
||||
private lateinit var etUsername: TextInputEditText
|
||||
private lateinit var etPassword: TextInputEditText
|
||||
private lateinit var etNewPassword: TextInputEditText
|
||||
private lateinit var etConfirmPassword: TextInputEditText
|
||||
private lateinit var btnPrimary: MaterialButton
|
||||
private lateinit var btnExit: MaterialButton
|
||||
private lateinit var authLangSpinner: Spinner
|
||||
private lateinit var authLoadingOverlay: View
|
||||
private lateinit var authCard: View
|
||||
|
||||
private var uiJob: Job? = null
|
||||
private var mode = Mode.LOGIN
|
||||
private var suppressLanguageCallback = false
|
||||
|
||||
private var pendingUsername: String = ""
|
||||
private var pendingOldPassword: String = ""
|
||||
private var pendingTempToken: String = ""
|
||||
|
||||
private enum class Mode { LOGIN, CHANGE_PASSWORD }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_auth)
|
||||
|
||||
SystemBarInsets.applyToContentRoot(this)
|
||||
|
||||
bindViews()
|
||||
SyncCoordinator.loadCachedTranslations(this)
|
||||
setupLanguageSpinner()
|
||||
applyTexts()
|
||||
refreshPublicTranslations()
|
||||
|
||||
intent.getStringExtra(EXTRA_PREFILL_USERNAME)?.takeIf { it.isNotBlank() }?.let {
|
||||
etUsername.setText(it)
|
||||
}
|
||||
|
||||
btnPrimary.setOnClickListener { onPrimaryClick() }
|
||||
btnExit.setOnClickListener { finishWithResult(canceled = true) }
|
||||
}
|
||||
|
||||
private fun bindViews() {
|
||||
loginFieldsGroup = findViewById(R.id.loginFieldsGroup)
|
||||
changePasswordGroup = findViewById(R.id.changePasswordGroup)
|
||||
tilUsername = findViewById(R.id.tilUsername)
|
||||
tilPassword = findViewById(R.id.tilPassword)
|
||||
tilNewPassword = findViewById(R.id.tilNewPassword)
|
||||
tilConfirmPassword = findViewById(R.id.tilConfirmPassword)
|
||||
etUsername = findViewById(R.id.etUsername)
|
||||
etPassword = findViewById(R.id.etPassword)
|
||||
etNewPassword = findViewById(R.id.etNewPassword)
|
||||
etConfirmPassword = findViewById(R.id.etConfirmPassword)
|
||||
btnPrimary = findViewById(R.id.btnPrimary)
|
||||
btnExit = findViewById(R.id.btnExit)
|
||||
authLangSpinner = findViewById(R.id.authLangSpinner)
|
||||
authLoadingOverlay = findViewById(R.id.authLoadingOverlay)
|
||||
authCard = findViewById(R.id.authCard)
|
||||
}
|
||||
|
||||
private fun refreshPublicTranslations() {
|
||||
uiJob?.cancel()
|
||||
uiJob = CoroutineScope(Dispatchers.Main).launch {
|
||||
val updated = withContext(Dispatchers.IO) {
|
||||
SyncCoordinator.refreshPublicTranslations(this@AuthActivity)
|
||||
}
|
||||
if (updated) applyTexts()
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyTexts() {
|
||||
findViewById<TextView>(R.id.authTitle).text = t("auth_title")
|
||||
findViewById<TextView>(R.id.authFooter).text = t("auth_footer_accounts")
|
||||
btnExit.text = t("exit_btn")
|
||||
tilUsername.hint = t("username_hint")
|
||||
tilPassword.hint = t("password_hint")
|
||||
tilNewPassword.hint = t("new_password_hint")
|
||||
tilConfirmPassword.hint = t("confirm_password_hint")
|
||||
updateModeUi()
|
||||
}
|
||||
|
||||
private fun setupLanguageSpinner() {
|
||||
fun bindAdapter() {
|
||||
val labels = AppLanguages.spinnerLabels(languageId)
|
||||
val selected = labels.getOrNull(AppLanguages.indexOf(languageId))
|
||||
ViewUtils.setupLanguagePickerSpinner(this, authLangSpinner, labels, selected)
|
||||
}
|
||||
bindAdapter()
|
||||
authLangSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
if (suppressLanguageCallback) return
|
||||
val newLang = AppLanguages.ids[position]
|
||||
if (newLang == languageId) return
|
||||
AppLanguageStore.set(this@AuthActivity, newLang)
|
||||
suppressLanguageCallback = true
|
||||
bindAdapter()
|
||||
authLangSpinner.setSelection(AppLanguages.indexOf(newLang), false)
|
||||
suppressLanguageCallback = false
|
||||
applyTexts()
|
||||
}
|
||||
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
||||
}
|
||||
suppressLanguageCallback = true
|
||||
authLangSpinner.setSelection(AppLanguages.indexOf(languageId), false)
|
||||
suppressLanguageCallback = false
|
||||
}
|
||||
|
||||
private fun updateModeUi() {
|
||||
when (mode) {
|
||||
Mode.LOGIN -> {
|
||||
loginFieldsGroup.visibility = View.VISIBLE
|
||||
changePasswordGroup.visibility = View.GONE
|
||||
findViewById<TextView>(R.id.authSubtitle).text = t("auth_subtitle_login")
|
||||
btnPrimary.text = t("login_btn")
|
||||
btnExit.visibility = View.VISIBLE
|
||||
}
|
||||
Mode.CHANGE_PASSWORD -> {
|
||||
loginFieldsGroup.visibility = View.GONE
|
||||
changePasswordGroup.visibility = View.VISIBLE
|
||||
findViewById<TextView>(R.id.authSubtitle).text = t("auth_subtitle_change_password")
|
||||
btnPrimary.text = t("save_password_btn")
|
||||
btnExit.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onPrimaryClick() {
|
||||
when (mode) {
|
||||
Mode.LOGIN -> performLogin()
|
||||
Mode.CHANGE_PASSWORD -> performChangePassword()
|
||||
}
|
||||
}
|
||||
|
||||
private fun performLogin() {
|
||||
tilUsername.error = null
|
||||
tilPassword.error = null
|
||||
|
||||
val user = etUsername.text?.toString()?.trim().orEmpty()
|
||||
val pass = etPassword.text?.toString().orEmpty()
|
||||
if (user.isEmpty() || pass.isEmpty()) {
|
||||
Toast.makeText(this, t("please_username_password"), Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
uiJob?.cancel()
|
||||
uiJob = CoroutineScope(Dispatchers.Main).launch {
|
||||
LoginManager.login(
|
||||
context = this@AuthActivity,
|
||||
username = user,
|
||||
password = pass,
|
||||
onMustChangePassword = { tempToken, username ->
|
||||
pendingUsername = username
|
||||
pendingOldPassword = pass
|
||||
pendingTempToken = tempToken
|
||||
mode = Mode.CHANGE_PASSWORD
|
||||
updateModeUi()
|
||||
setBusy(false)
|
||||
},
|
||||
onSuccess = { token, username, assignedClients ->
|
||||
withContext(Dispatchers.IO) {
|
||||
persistSession(token, username, assignedClients)
|
||||
}
|
||||
setBusy(false)
|
||||
finishWithResult(token = token)
|
||||
},
|
||||
onError = { msg ->
|
||||
setBusy(false)
|
||||
val txt = t("login_failed_with_reason").replace("{reason}", msg)
|
||||
Toast.makeText(this@AuthActivity, txt, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun performChangePassword() {
|
||||
tilNewPassword.error = null
|
||||
tilConfirmPassword.error = null
|
||||
|
||||
val newPass = etNewPassword.text?.toString().orEmpty()
|
||||
val confirm = etConfirmPassword.text?.toString().orEmpty()
|
||||
|
||||
when {
|
||||
newPass.length < 6 -> {
|
||||
tilNewPassword.error = t("password_too_short")
|
||||
return
|
||||
}
|
||||
newPass != confirm -> {
|
||||
tilConfirmPassword.error = t("passwords_dont_match")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
uiJob?.cancel()
|
||||
uiJob = CoroutineScope(Dispatchers.Main).launch {
|
||||
LoginManager.changePassword(
|
||||
context = this@AuthActivity,
|
||||
token = pendingTempToken,
|
||||
username = pendingUsername,
|
||||
oldPassword = pendingOldPassword,
|
||||
newPassword = newPass,
|
||||
onSuccess = { token, username, assignedClients ->
|
||||
withContext(Dispatchers.IO) {
|
||||
persistSession(token, username, assignedClients)
|
||||
}
|
||||
setBusy(false)
|
||||
finishWithResult(token = token)
|
||||
},
|
||||
onError = { msg ->
|
||||
setBusy(false)
|
||||
Toast.makeText(this@AuthActivity, msg, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun persistSession(token: String, username: String, assignedClients: List<String>) {
|
||||
QuestionnaireCache.clearAllClientData(this)
|
||||
TokenStore.save(this, token, username)
|
||||
QuestionnaireCache.replaceAssignedClients(this, assignedClients)
|
||||
}
|
||||
|
||||
private fun setBusy(busy: Boolean) {
|
||||
ScreenLoading.setVisible(authLoadingOverlay, busy, authCard)
|
||||
btnPrimary.isEnabled = !busy
|
||||
btnExit.isEnabled = !busy
|
||||
authLangSpinner.isEnabled = !busy
|
||||
etUsername.isEnabled = !busy
|
||||
etPassword.isEnabled = !busy
|
||||
etNewPassword.isEnabled = !busy
|
||||
etConfirmPassword.isEnabled = !busy
|
||||
}
|
||||
|
||||
private fun finishWithResult(token: String? = null, canceled: Boolean = false) {
|
||||
if (canceled) {
|
||||
setResult(RESULT_CANCELED)
|
||||
} else {
|
||||
setResult(RESULT_OK, Intent().putExtra(EXTRA_TOKEN, token))
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
uiJob?.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_PREFILL_USERNAME = "prefill_username"
|
||||
const val EXTRA_TOKEN = "token"
|
||||
}
|
||||
}
|
||||
157
app/src/main/java/com/dano/test1/ui/ClientPickerBottomSheet.kt
Normal file
157
app/src/main/java/com/dano/test1/ui/ClientPickerBottomSheet.kt
Normal file
@ -0,0 +1,157 @@
|
||||
package com.dano.test1.ui
|
||||
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.dano.test1.R
|
||||
import com.dano.test1.utils.ViewUtils
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
|
||||
/**
|
||||
* Searchable bottom sheet for choosing among many assigned client codes.
|
||||
*/
|
||||
class ClientPickerBottomSheet(
|
||||
private val activity: AppCompatActivity,
|
||||
private val allClients: List<String>,
|
||||
private val currentClient: String?,
|
||||
private val searchHint: String,
|
||||
private val onSelected: (String) -> Unit,
|
||||
) {
|
||||
private var dialog: BottomSheetDialog? = null
|
||||
|
||||
fun show() {
|
||||
val sheet = BottomSheetDialog(activity)
|
||||
dialog = sheet
|
||||
val root = LayoutInflater.from(activity).inflate(R.layout.bottom_sheet_client_picker, null)
|
||||
sheet.setContentView(root)
|
||||
|
||||
val searchLayout = root.findViewById<TextInputLayout>(R.id.clientSearchLayout)
|
||||
val searchInput = root.findViewById<TextInputEditText>(R.id.clientSearchInput)
|
||||
val recycler = root.findViewById<RecyclerView>(R.id.clientListRecycler)
|
||||
searchLayout.hint = searchHint
|
||||
|
||||
val recent = ClientRecentStore.getRecent(activity)
|
||||
.filter { code -> allClients.any { it.equals(code, ignoreCase = true) } }
|
||||
val sortedAll = allClients.sortedBy { it.lowercase() }
|
||||
|
||||
val adapter = ClientListAdapter { code ->
|
||||
sheet.dismiss()
|
||||
onSelected(code)
|
||||
}
|
||||
recycler.layoutManager = LinearLayoutManager(activity)
|
||||
recycler.adapter = adapter
|
||||
adapter.submit(buildSections(recent, sortedAll, query = ""))
|
||||
|
||||
searchInput.addTextChangedListener(object : TextWatcher {
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
adapter.submit(buildSections(recent, sortedAll, s?.toString().orEmpty()))
|
||||
}
|
||||
})
|
||||
|
||||
sheet.show()
|
||||
}
|
||||
|
||||
private fun buildSections(
|
||||
recent: List<String>,
|
||||
all: List<String>,
|
||||
query: String,
|
||||
): List<ClientListItem> {
|
||||
val q = query.trim().lowercase()
|
||||
fun matches(code: String) = q.isBlank() || code.lowercase().contains(q)
|
||||
|
||||
val items = mutableListOf<ClientListItem>()
|
||||
val recentFiltered = recent.filter { matches(it) }
|
||||
if (recentFiltered.isNotEmpty()) {
|
||||
items.add(ClientListItem.Header("__recent__"))
|
||||
recentFiltered.forEach { items.add(ClientListItem.Code(it)) }
|
||||
}
|
||||
val allFiltered = all.filter { matches(it) }
|
||||
if (allFiltered.isNotEmpty()) {
|
||||
if (items.isNotEmpty()) items.add(ClientListItem.Header("__all__"))
|
||||
allFiltered.forEach { items.add(ClientListItem.Code(it)) }
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
private sealed class ClientListItem {
|
||||
data class Header(val tag: String) : ClientListItem()
|
||||
data class Code(val value: String) : ClientListItem()
|
||||
}
|
||||
|
||||
private class ClientListAdapter(
|
||||
private val onPick: (String) -> Unit,
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private var items: List<ClientListItem> = emptyList()
|
||||
|
||||
fun submit(list: List<ClientListItem>) {
|
||||
items = list
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int = when (items[position]) {
|
||||
is ClientListItem.Header -> 0
|
||||
is ClientListItem.Code -> 1
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
return if (viewType == 0) {
|
||||
val tv = TextView(parent.context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
setPadding(
|
||||
ViewUtils.dp(parent.context, 16),
|
||||
ViewUtils.dp(parent.context, 12),
|
||||
ViewUtils.dp(parent.context, 16),
|
||||
ViewUtils.dp(parent.context, 4),
|
||||
)
|
||||
setTextColor(ContextCompat.getColor(parent.context, R.color.brand_text_muted))
|
||||
ViewUtils.setTextSizePercentOfScreenHeight(this, 0.022f)
|
||||
}
|
||||
HeaderHolder(tv)
|
||||
} else {
|
||||
val v = inflater.inflate(R.layout.item_client_code, parent, false)
|
||||
CodeHolder(v)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (val item = items[position]) {
|
||||
is ClientListItem.Header -> {
|
||||
val label = when (item.tag) {
|
||||
"__recent__" -> holder.itemView.context.getString(R.string.client_picker_recent)
|
||||
else -> holder.itemView.context.getString(R.string.client_picker_all)
|
||||
}
|
||||
(holder as HeaderHolder).text.text = label
|
||||
}
|
||||
is ClientListItem.Code -> {
|
||||
val h = holder as CodeHolder
|
||||
ViewUtils.setTextSizePercentOfScreenHeight(h.text, 0.028f)
|
||||
h.text.text = item.value
|
||||
h.itemView.setOnClickListener { onPick(item.value) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class HeaderHolder(val text: TextView) : RecyclerView.ViewHolder(text)
|
||||
private class CodeHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val text: TextView = view.findViewById(R.id.clientCodeText)
|
||||
}
|
||||
}
|
||||
}
|
||||
57
app/src/main/java/com/dano/test1/ui/ClientRecentStore.kt
Normal file
57
app/src/main/java/com/dano/test1/ui/ClientRecentStore.kt
Normal file
@ -0,0 +1,57 @@
|
||||
package com.dano.test1.ui
|
||||
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
|
||||
/** Persists the last few client codes chosen by the coach for quick re-selection. */
|
||||
object ClientRecentStore {
|
||||
private const val PREFS = "client_recent"
|
||||
private const val LEGACY_PREFS = "client_recent"
|
||||
private const val KEY_CODES = "codes"
|
||||
private const val MAX = 5
|
||||
private const val SEP = "\u0001"
|
||||
|
||||
fun record(context: Context, clientCode: String) {
|
||||
val code = clientCode.trim()
|
||||
if (code.isBlank()) return
|
||||
val prefs = prefs(context)
|
||||
val updated = listOf(code) + loadList(prefs).filter { !it.equals(code, ignoreCase = true) }
|
||||
prefs.edit().putString(KEY_CODES, updated.take(MAX).joinToString(SEP)).apply()
|
||||
clearLegacy(context)
|
||||
}
|
||||
|
||||
fun getRecent(context: Context): List<String> =
|
||||
loadList(prefs(context)).ifEmpty {
|
||||
val legacy = loadList(context.getSharedPreferences(LEGACY_PREFS, Context.MODE_PRIVATE))
|
||||
if (legacy.isNotEmpty()) {
|
||||
prefs(context).edit().putString(KEY_CODES, legacy.take(MAX).joinToString(SEP)).apply()
|
||||
clearLegacy(context)
|
||||
}
|
||||
legacy
|
||||
}
|
||||
|
||||
fun clear(context: Context) {
|
||||
prefs(context).edit().clear().apply()
|
||||
clearLegacy(context)
|
||||
}
|
||||
|
||||
private fun prefs(context: Context) = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"${PREFS}_secure",
|
||||
MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build(),
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
|
||||
private fun loadList(prefs: android.content.SharedPreferences): List<String> {
|
||||
val raw = prefs.getString(KEY_CODES, null) ?: return emptyList()
|
||||
return raw.split(SEP).map { it.trim() }.filter { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun clearLegacy(context: Context) {
|
||||
context.getSharedPreferences(LEGACY_PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
||||
}
|
||||
}
|
||||
12
app/src/main/java/com/dano/test1/ui/DevSettingsAccess.kt
Normal file
12
app/src/main/java/com/dano/test1/ui/DevSettingsAccess.kt
Normal file
@ -0,0 +1,12 @@
|
||||
package com.dano.test1.ui
|
||||
|
||||
import android.content.Context
|
||||
import com.dano.test1.network.QuestionnaireCache
|
||||
|
||||
object DevSettingsAccess {
|
||||
const val CLIENT_CODE = "DEVELOPER-SETTINGS"
|
||||
|
||||
fun isAssigned(context: Context): Boolean =
|
||||
QuestionnaireCache.getAssignedClients(context)
|
||||
.any { it.equals(CLIENT_CODE, ignoreCase = true) }
|
||||
}
|
||||
181
app/src/main/java/com/dano/test1/ui/DevSettingsActivity.kt
Normal file
181
app/src/main/java/com/dano/test1/ui/DevSettingsActivity.kt
Normal file
@ -0,0 +1,181 @@
|
||||
package com.dano.test1.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Button
|
||||
import android.widget.RadioButton
|
||||
import android.widget.RadioGroup
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.widget.SwitchCompat
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import com.dano.test1.MainActivity
|
||||
import com.dano.test1.R
|
||||
import com.dano.test1.network.QuestionnaireSyncService
|
||||
import com.dano.test1.network.TokenStore
|
||||
import com.dano.test1.utils.SyncProgressOverlay
|
||||
import com.dano.test1.utils.SyncProgressTexts
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.dano.test1.ui.AppLanguageStore
|
||||
import com.dano.test1.ui.LogoutDialogs
|
||||
import com.dano.test1.ui.SessionLogout
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Dev-only A/B settings. Requires DEVELOPER-SETTINGS in the coach's assigned client list.
|
||||
*/
|
||||
class DevSettingsActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var switchOverride: SwitchCompat
|
||||
private lateinit var radioGroup: RadioGroup
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_dev_settings)
|
||||
|
||||
SystemBarInsets.applyToContentRoot(this)
|
||||
|
||||
val toolbar = findViewById<Toolbar>(R.id.devSettingsToolbar)
|
||||
setSupportActionBar(toolbar)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
switchOverride = findViewById(R.id.switchAbOverride)
|
||||
radioGroup = findViewById(R.id.radioGroupVariant)
|
||||
|
||||
applyPrefsToUi()
|
||||
|
||||
switchOverride.setOnCheckedChangeListener { _, isChecked ->
|
||||
AbTestSettingsStore.setOverrideEnabled(this, isChecked)
|
||||
updateRadiosEnabled(isChecked)
|
||||
}
|
||||
|
||||
radioGroup.setOnCheckedChangeListener { _, checkedId ->
|
||||
val variant = when (checkedId) {
|
||||
R.id.radioVariantA -> AbTestSettingsStore.VARIANT_A
|
||||
R.id.radioVariantB -> AbTestSettingsStore.VARIANT_B
|
||||
else -> AbTestSettingsStore.VARIANT_NONE
|
||||
}
|
||||
AbTestSettingsStore.setVariant(this, variant)
|
||||
}
|
||||
|
||||
val refreshButton = findViewById<Button>(R.id.refreshDataButton)
|
||||
val refreshStatus = findViewById<TextView>(R.id.refreshStatusText)
|
||||
refreshButton.setOnClickListener {
|
||||
val token = TokenStore.getToken(this)
|
||||
if (token.isNullOrBlank()) {
|
||||
Toast.makeText(this, "No token – please log in first", Toast.LENGTH_LONG).show()
|
||||
return@setOnClickListener
|
||||
}
|
||||
refreshButton.isEnabled = false
|
||||
refreshStatus.visibility = View.GONE
|
||||
|
||||
val overlay = SyncProgressOverlay(this, SyncProgressTexts.Locale.GERMAN)
|
||||
overlay.show()
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val result = runCatching {
|
||||
QuestionnaireSyncService.refreshAll(
|
||||
this@DevSettingsActivity,
|
||||
token,
|
||||
pullAnswers = true,
|
||||
forceAnswerPull = true,
|
||||
onProgress = { progress ->
|
||||
runOnUiThread { overlay.update(progress) }
|
||||
},
|
||||
)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
overlay.hide()
|
||||
refreshButton.isEnabled = true
|
||||
if (result.isSuccess) {
|
||||
refreshStatus.text = "Refresh successful"
|
||||
Toast.makeText(this@DevSettingsActivity, "Data refreshed", Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
refreshStatus.text = "Refresh failed: ${result.exceptionOrNull()?.message}"
|
||||
Toast.makeText(this@DevSettingsActivity, "Refresh failed", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findViewById<MaterialButton>(R.id.logoutButton).setOnClickListener { attemptLogout() }
|
||||
}
|
||||
|
||||
private fun attemptLogout() {
|
||||
val languageId = AppLanguageStore.get(this)
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
val block = withContext(Dispatchers.IO) { SessionLogout.evaluate(this@DevSettingsActivity) }
|
||||
when (block) {
|
||||
null -> LogoutDialogs.showConfirm(this@DevSettingsActivity, languageId) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
withContext(Dispatchers.IO) { SessionLogout.wipeAllLocalData(this@DevSettingsActivity) }
|
||||
val intent = Intent(this@DevSettingsActivity, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
}
|
||||
startActivity(intent)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
SessionLogout.BlockReason.Offline -> LogoutDialogs.showBlocked(
|
||||
activity = this@DevSettingsActivity,
|
||||
languageId = languageId,
|
||||
title = SessionLogout.message(languageId, "logout_offline_title", "Cannot log out"),
|
||||
message = SessionLogout.message(
|
||||
languageId,
|
||||
"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(languageId, block.summary)
|
||||
val bodyTemplate = SessionLogout.message(
|
||||
languageId,
|
||||
"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@DevSettingsActivity,
|
||||
languageId = languageId,
|
||||
title = SessionLogout.message(languageId, "logout_pending_title", "Cannot log out"),
|
||||
message = message,
|
||||
details = details,
|
||||
tone = LogoutDialogs.Tone.Warning,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyPrefsToUi() {
|
||||
val overrideOn = AbTestSettingsStore.isOverrideEnabled(this)
|
||||
switchOverride.isChecked = overrideOn
|
||||
updateRadiosEnabled(overrideOn)
|
||||
|
||||
when (AbTestSettingsStore.getVariant(this)) {
|
||||
AbTestSettingsStore.VARIANT_A -> radioGroup.check(R.id.radioVariantA)
|
||||
AbTestSettingsStore.VARIANT_B -> radioGroup.check(R.id.radioVariantB)
|
||||
else -> radioGroup.check(R.id.radioVariantDefault)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateRadiosEnabled(enabled: Boolean) {
|
||||
for (i in 0 until radioGroup.childCount) {
|
||||
(radioGroup.getChildAt(i) as? RadioButton)?.isEnabled = enabled
|
||||
}
|
||||
radioGroup.alpha = if (enabled) 1f else 0.5f
|
||||
}
|
||||
|
||||
override fun onSupportNavigateUp(): Boolean {
|
||||
finish()
|
||||
return true
|
||||
}
|
||||
}
|
||||
1274
app/src/main/java/com/dano/test1/ui/HandlerOpeningScreen.kt
Normal file
1274
app/src/main/java/com/dano/test1/ui/HandlerOpeningScreen.kt
Normal file
File diff suppressed because it is too large
Load Diff
217
app/src/main/java/com/dano/test1/ui/LoadButtonHandler.kt
Normal file
217
app/src/main/java/com/dano/test1/ui/LoadButtonHandler.kt
Normal file
@ -0,0 +1,217 @@
|
||||
package com.dano.test1.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.widget.Button
|
||||
import android.widget.Toast
|
||||
import com.dano.test1.LanguageManager
|
||||
import com.dano.test1.MainActivity
|
||||
import com.dano.test1.data.ClientAnswersCache
|
||||
import com.dano.test1.data.ClientRepository
|
||||
import com.dano.test1.data.CompletedQuestionnaireRepository
|
||||
import kotlinx.coroutines.*
|
||||
import com.dano.test1.data.CompletedQuestionnaire
|
||||
import com.dano.test1.network.NetworkUtils
|
||||
import com.dano.test1.network.QuestionnaireAnswerSync
|
||||
import com.dano.test1.network.QuestionnaireCache
|
||||
import com.dano.test1.network.TokenStore
|
||||
import com.dano.test1.questionnaire.GlobalValues
|
||||
import com.dano.test1.questionnaire.QuestionItem
|
||||
|
||||
class LoadButtonHandler(
|
||||
private val activity: MainActivity,
|
||||
private val clientCodeProvider: () -> String,
|
||||
private val languageIDProvider: () -> String,
|
||||
private val questionnaireEntriesProvider: () -> List<QuestionItem.QuestionnaireEntry>,
|
||||
private val dynamicButtonsProvider: () -> List<Button>,
|
||||
private val onCompletedQuestionnaireIds: (Set<String>) -> Unit,
|
||||
private val updateButtonTexts: () -> Unit,
|
||||
private val setButtonsEnabled: (List<Button>) -> Unit,
|
||||
private val updateMainButtonsState: (Boolean) -> Unit,
|
||||
private val onPendingUploadIds: (Set<String>) -> Unit = {},
|
||||
private val onLoadStarted: () -> Unit = {},
|
||||
private val onLoadFinished: () -> Unit = {},
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val DEV_SETTINGS_SECRET = DevSettingsAccess.CLIENT_CODE
|
||||
}
|
||||
|
||||
private val loadScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
private var loadJob: Job? = null
|
||||
private var loadGeneration = 0
|
||||
|
||||
fun triggerLoad() {
|
||||
val inputText = clientCodeProvider().trim()
|
||||
if (inputText.isBlank()) {
|
||||
val message = LanguageManager.getText(languageIDProvider(), "please_client_code")
|
||||
Toast.makeText(activity, message, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
if (inputText.equals(DEV_SETTINGS_SECRET, ignoreCase = true)) {
|
||||
if (!DevSettingsAccess.isAssigned(activity)) {
|
||||
val message = LanguageManager.getText(languageIDProvider(), "no_profile")
|
||||
Toast.makeText(activity, message, Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
GlobalValues.LAST_CLIENT_CODE = null
|
||||
GlobalValues.LOADED_CLIENT_CODE = null
|
||||
activity.markOpeningScreenReinit()
|
||||
activity.startActivity(Intent(activity, DevSettingsActivity::class.java))
|
||||
return
|
||||
}
|
||||
|
||||
val sameClientAlreadyLoaded = inputText.equals(GlobalValues.LOADED_CLIENT_CODE, ignoreCase = true)
|
||||
if (!sameClientAlreadyLoaded) {
|
||||
onCompletedQuestionnaireIds(emptySet())
|
||||
setButtonsEnabled(emptyList())
|
||||
}
|
||||
|
||||
val clientCode = inputText
|
||||
GlobalValues.LAST_CLIENT_CODE = clientCode
|
||||
|
||||
val assignedClients = QuestionnaireCache.getAssignedClients(activity)
|
||||
if (assignedClients.isEmpty() || assignedClients.none { it.equals(clientCode, ignoreCase = true) }) {
|
||||
GlobalValues.LOADED_CLIENT_CODE = null
|
||||
val message = LanguageManager.getText(languageIDProvider(), "no_profile")
|
||||
Toast.makeText(activity, message, Toast.LENGTH_LONG).show()
|
||||
setButtonsEnabled(emptyList())
|
||||
updateMainButtonsState(false)
|
||||
updateButtonTexts()
|
||||
return
|
||||
}
|
||||
|
||||
val generation = ++loadGeneration
|
||||
loadJob?.cancel()
|
||||
loadJob = loadScope.launch {
|
||||
try {
|
||||
runLoad(clientCode, sameClientAlreadyLoaded)
|
||||
} finally {
|
||||
if (generation == loadGeneration && isActive) {
|
||||
withContext(Dispatchers.Main) { onLoadFinished() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runLoad(clientCode: String, sameClientAlreadyLoaded: Boolean) {
|
||||
val token = TokenStore.getToken(activity)
|
||||
val needsNetworkPull = !token.isNullOrBlank() && NetworkUtils.isOnline(activity) &&
|
||||
QuestionnaireAnswerSync.needsPull(activity, clientCode)
|
||||
|
||||
if (needsNetworkPull) {
|
||||
withContext(Dispatchers.Main) { onLoadStarted() }
|
||||
}
|
||||
|
||||
if (!sameClientAlreadyLoaded) {
|
||||
ClientAnswersCache.invalidate(clientCode)
|
||||
}
|
||||
ClientRepository.ensureClient(clientCode)
|
||||
ClientAnswersCache.allForClient(clientCode)
|
||||
|
||||
GlobalValues.LOADED_CLIENT_CODE = clientCode
|
||||
withContext(Dispatchers.Main) { updateMainButtonsState(true) }
|
||||
|
||||
if (needsNetworkPull) {
|
||||
QuestionnaireAnswerSync.pullForClient(activity, token!!, clientCode, force = true)
|
||||
ClientAnswersCache.invalidate(clientCode)
|
||||
ClientAnswersCache.allForClient(clientCode)
|
||||
}
|
||||
|
||||
handleNormalLoad(clientCode)
|
||||
}
|
||||
|
||||
|
||||
private suspend fun evaluateCondition(
|
||||
condition: QuestionItem.Condition?,
|
||||
clientCode: String,
|
||||
completedEntries: List<CompletedQuestionnaire>
|
||||
): Boolean {
|
||||
if (condition == null) return false
|
||||
return when (condition) {
|
||||
is QuestionItem.Condition.AlwaysAvailable -> true
|
||||
is QuestionItem.Condition.RequiresCompleted -> {
|
||||
val normalizedCompleted = completedEntries.map { normalizeQuestionnaireId(it.questionnaireId) }
|
||||
condition.required.all { req ->
|
||||
val nReq = normalizeQuestionnaireId(req)
|
||||
normalizedCompleted.any { it.contains(nReq) || nReq.contains(it) }
|
||||
}
|
||||
}
|
||||
is QuestionItem.Condition.QuestionCondition -> {
|
||||
val answers = ClientAnswersCache.answersForQuestionnaire(clientCode, condition.questionnaire)
|
||||
val relevant = answers.find { it.questionId.endsWith(condition.questionId, ignoreCase = true) }
|
||||
val answerValue = relevant?.answerValue ?: ""
|
||||
when (condition.operator) {
|
||||
"==" -> answerValue == condition.value
|
||||
"!=" -> answerValue != condition.value
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
is QuestionItem.Condition.Combined -> {
|
||||
val normalizedCompleted = completedEntries.map { normalizeQuestionnaireId(it.questionnaireId) }
|
||||
val reqOk = condition.requiresCompleted.isNullOrEmpty() || condition.requiresCompleted.all { req ->
|
||||
val nReq = normalizeQuestionnaireId(req)
|
||||
normalizedCompleted.any { it.contains(nReq) || nReq.contains(it) }
|
||||
}
|
||||
if (!reqOk) return false
|
||||
val q = condition.questionCheck ?: return true
|
||||
val answers = ClientAnswersCache.answersForQuestionnaire(clientCode, q.questionnaire)
|
||||
val relevant = answers.find { it.questionId.endsWith(q.questionId, ignoreCase = true) }
|
||||
val answerValue = relevant?.answerValue ?: ""
|
||||
when (q.operator) {
|
||||
"==" -> answerValue == q.value
|
||||
"!=" -> answerValue != q.value
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
is QuestionItem.Condition.AnyOf -> {
|
||||
condition.conditions.any { evaluateCondition(it, clientCode, completedEntries) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeQuestionnaireId(name: String): String =
|
||||
name.lowercase().removeSuffix(".json")
|
||||
|
||||
private suspend fun handleNormalLoad(clientCode: String) {
|
||||
val completedEntries = withContext(Dispatchers.IO) {
|
||||
CompletedQuestionnaireRepository.getAllForClient(clientCode)
|
||||
}
|
||||
|
||||
val pendingIds = completedEntries
|
||||
.filter { it.isDone && (it.uploadedAt == null || (it.uploadedAt ?: 0L) < it.timestamp) }
|
||||
.map { it.questionnaireId }
|
||||
.toSet()
|
||||
withContext(Dispatchers.Main) { onPendingUploadIds(pendingIds) }
|
||||
|
||||
val completedIds = completedEntries
|
||||
.filter { it.isDone }
|
||||
.map { it.questionnaireId }
|
||||
.toSet()
|
||||
withContext(Dispatchers.Main) { onCompletedQuestionnaireIds(completedIds) }
|
||||
|
||||
val enabledButtons = mutableListOf<Button>()
|
||||
val questionnaireEntries = questionnaireEntriesProvider()
|
||||
val dynamicButtons = dynamicButtonsProvider()
|
||||
|
||||
for ((idx, entry) in questionnaireEntries.withIndex()) {
|
||||
val button = dynamicButtons.getOrNull(idx) ?: continue
|
||||
|
||||
val isCompleted = completedEntries.any { completed ->
|
||||
normalizeQuestionnaireId(completed.questionnaireId).let { completedNorm ->
|
||||
val targetNorm = normalizeQuestionnaireId(entry.file)
|
||||
(completedNorm.contains(targetNorm) || targetNorm.contains(completedNorm)) && completed.isDone
|
||||
}
|
||||
}
|
||||
|
||||
val condMet = evaluateCondition(entry.condition, clientCode, completedEntries)
|
||||
if (condMet || isCompleted) enabledButtons.add(button)
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
setButtonsEnabled(enabledButtons)
|
||||
updateButtonTexts()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
210
app/src/main/java/com/dano/test1/ui/LogoutDialogs.kt
Normal file
210
app/src/main/java/com/dano/test1/ui/LogoutDialogs.kt
Normal file
@ -0,0 +1,210 @@
|
||||
package com.dano.test1.ui
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewTreeObserver
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.widget.NestedScrollView
|
||||
import com.dano.test1.R
|
||||
import com.google.android.material.button.MaterialButton
|
||||
|
||||
/**
|
||||
* Branded dialogs for logout: blocked (info/warning) and confirmation before sign-out.
|
||||
*/
|
||||
object LogoutDialogs {
|
||||
|
||||
enum class Tone { Info, Warning, Logout }
|
||||
|
||||
fun showBlocked(
|
||||
activity: AppCompatActivity,
|
||||
languageId: String,
|
||||
title: String,
|
||||
message: String,
|
||||
details: String? = null,
|
||||
tone: Tone = Tone.Warning,
|
||||
) {
|
||||
show(
|
||||
activity = activity,
|
||||
tone = tone,
|
||||
title = title,
|
||||
message = message,
|
||||
details = details,
|
||||
primaryLabel = msg(languageId, "ok", "OK"),
|
||||
secondaryLabel = null,
|
||||
onPrimary = null,
|
||||
)
|
||||
}
|
||||
|
||||
fun showConfirm(
|
||||
activity: AppCompatActivity,
|
||||
languageId: String,
|
||||
onConfirm: () -> Unit,
|
||||
) {
|
||||
show(
|
||||
activity = activity,
|
||||
tone = Tone.Logout,
|
||||
title = msg(languageId, "logout_confirm_title", "Log out?"),
|
||||
message = msg(
|
||||
languageId,
|
||||
"logout_confirm_message",
|
||||
"All data on this device will be removed. You will need to sign in again.",
|
||||
),
|
||||
details = null,
|
||||
primaryLabel = msg(languageId, "logout_confirm_action", msg(languageId, "logout", "Log out")),
|
||||
secondaryLabel = msg(languageId, "cancel", "Cancel"),
|
||||
onPrimary = onConfirm,
|
||||
)
|
||||
}
|
||||
|
||||
fun showAction(
|
||||
activity: AppCompatActivity,
|
||||
languageId: String,
|
||||
title: String,
|
||||
message: String,
|
||||
details: String? = null,
|
||||
tone: Tone = Tone.Info,
|
||||
primaryLabel: String,
|
||||
secondaryLabel: String,
|
||||
onPrimary: () -> Unit,
|
||||
) {
|
||||
show(
|
||||
activity = activity,
|
||||
tone = tone,
|
||||
title = title,
|
||||
message = message,
|
||||
details = details,
|
||||
primaryLabel = primaryLabel,
|
||||
secondaryLabel = secondaryLabel,
|
||||
onPrimary = onPrimary,
|
||||
)
|
||||
}
|
||||
|
||||
private fun show(
|
||||
activity: AppCompatActivity,
|
||||
tone: Tone,
|
||||
title: String,
|
||||
message: String,
|
||||
details: String?,
|
||||
primaryLabel: String,
|
||||
secondaryLabel: String?,
|
||||
onPrimary: (() -> Unit)?,
|
||||
) {
|
||||
val dialog = Dialog(activity)
|
||||
dialog.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
|
||||
|
||||
val root = LayoutInflater.from(activity).inflate(R.layout.dialog_app_message, null)
|
||||
val scroll = root.findViewById<NestedScrollView>(R.id.dialogScroll)
|
||||
val iconContainer = root.findViewById<View>(R.id.dialogIconContainer)
|
||||
val icon = root.findViewById<ImageView>(R.id.dialogIcon)
|
||||
val titleView = root.findViewById<TextView>(R.id.dialogTitle)
|
||||
val messageView = root.findViewById<TextView>(R.id.dialogMessage)
|
||||
val detailsScroll = root.findViewById<NestedScrollView>(R.id.dialogDetailsScroll)
|
||||
val detailsView = root.findViewById<TextView>(R.id.dialogDetails)
|
||||
val btnPrimary = root.findViewById<MaterialButton>(R.id.dialogBtnPrimary)
|
||||
val btnSecondary = root.findViewById<MaterialButton>(R.id.dialogBtnSecondary)
|
||||
|
||||
when (tone) {
|
||||
Tone.Info -> {
|
||||
iconContainer.setBackgroundResource(R.drawable.bg_dialog_icon_info)
|
||||
icon.setImageResource(R.drawable.ic_dialog_info_24)
|
||||
icon.imageTintList = null
|
||||
}
|
||||
Tone.Warning -> {
|
||||
iconContainer.setBackgroundResource(R.drawable.bg_dialog_icon_warning)
|
||||
icon.setImageResource(R.drawable.ic_dialog_warning_24)
|
||||
icon.imageTintList = null
|
||||
}
|
||||
Tone.Logout -> {
|
||||
iconContainer.setBackgroundResource(R.drawable.bg_dialog_icon_logout)
|
||||
icon.setImageResource(R.drawable.ic_lock_24)
|
||||
icon.imageTintList = ColorStateList.valueOf(
|
||||
ContextCompat.getColor(activity, R.color.brand_destructive_text),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
titleView.text = title
|
||||
messageView.text = message
|
||||
|
||||
if (!details.isNullOrBlank()) {
|
||||
detailsScroll.visibility = View.VISIBLE
|
||||
detailsView.text = details.trim()
|
||||
val maxDetailsHeight = activity.resources.getDimensionPixelSize(R.dimen.dialog_details_max_height)
|
||||
detailsScroll.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
|
||||
override fun onGlobalLayout() {
|
||||
detailsScroll.viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||
if (detailsScroll.height > maxDetailsHeight) {
|
||||
detailsScroll.layoutParams = detailsScroll.layoutParams.apply {
|
||||
height = maxDetailsHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
detailsScroll.visibility = View.GONE
|
||||
}
|
||||
|
||||
btnPrimary.text = primaryLabel
|
||||
if (tone == Tone.Logout) {
|
||||
btnPrimary.backgroundTintList = ColorStateList.valueOf(
|
||||
ContextCompat.getColor(activity, R.color.brand_destructive_text),
|
||||
)
|
||||
btnPrimary.setTextColor(ContextCompat.getColor(activity, R.color.white))
|
||||
} else {
|
||||
btnPrimary.backgroundTintList = ColorStateList.valueOf(
|
||||
ContextCompat.getColor(activity, R.color.brand_purple),
|
||||
)
|
||||
btnPrimary.setTextColor(ContextCompat.getColor(activity, R.color.white))
|
||||
}
|
||||
|
||||
btnPrimary.setOnClickListener {
|
||||
dialog.dismiss()
|
||||
onPrimary?.invoke()
|
||||
}
|
||||
|
||||
if (secondaryLabel != null) {
|
||||
btnSecondary.visibility = View.VISIBLE
|
||||
btnSecondary.text = secondaryLabel
|
||||
btnSecondary.setOnClickListener { dialog.dismiss() }
|
||||
// Safer on small screens: cancel above the destructive / primary action.
|
||||
(btnPrimary.parent as? ViewGroup)?.let { buttonRow ->
|
||||
buttonRow.removeView(btnPrimary)
|
||||
buttonRow.removeView(btnSecondary)
|
||||
buttonRow.addView(btnSecondary)
|
||||
buttonRow.addView(btnPrimary)
|
||||
}
|
||||
} else {
|
||||
btnSecondary.visibility = View.GONE
|
||||
}
|
||||
|
||||
dialog.setContentView(root)
|
||||
dialog.setCancelable(true)
|
||||
dialog.show()
|
||||
|
||||
dialog.window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
|
||||
val dm = activity.resources.displayMetrics
|
||||
val maxDialogHeight = (dm.heightPixels * 0.85f).toInt()
|
||||
scroll.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
|
||||
override fun onGlobalLayout() {
|
||||
scroll.viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||
if (scroll.height > maxDialogHeight) {
|
||||
scroll.layoutParams = scroll.layoutParams.apply {
|
||||
height = maxDialogHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun msg(languageId: String, key: String, fallback: String): String =
|
||||
SessionLogout.message(languageId, key, fallback)
|
||||
}
|
||||
26
app/src/main/java/com/dano/test1/ui/MonthsSpinner.kt
Normal file
26
app/src/main/java/com/dano/test1/ui/MonthsSpinner.kt
Normal file
@ -0,0 +1,26 @@
|
||||
package com.dano.test1.ui
|
||||
|
||||
import com.dano.test1.LanguageManager
|
||||
|
||||
data class Month(val name: String) {
|
||||
override fun toString(): String = name
|
||||
}
|
||||
|
||||
object Months {
|
||||
fun getAllMonths(languageID: String): List<Any> {
|
||||
return listOf(
|
||||
Month(LanguageManager.getText(languageID, "january")),
|
||||
Month(LanguageManager.getText(languageID, "february")),
|
||||
Month(LanguageManager.getText(languageID, "march")),
|
||||
Month(LanguageManager.getText(languageID, "april")),
|
||||
Month(LanguageManager.getText(languageID, "may")),
|
||||
Month(LanguageManager.getText(languageID, "june")),
|
||||
Month(LanguageManager.getText(languageID, "july")),
|
||||
Month(LanguageManager.getText(languageID, "august")),
|
||||
Month(LanguageManager.getText(languageID, "september")),
|
||||
Month(LanguageManager.getText(languageID, "october")),
|
||||
Month(LanguageManager.getText(languageID, "november")),
|
||||
Month(LanguageManager.getText(languageID, "december"))
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
package com.dano.test1.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.view.LayoutInflater
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.dano.test1.R
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
|
||||
/**
|
||||
* Shows a bilingual (DE + EN) explanation before the system notification permission dialog.
|
||||
*/
|
||||
object NotificationPermissionPrompter {
|
||||
private const val PREF = "app_settings"
|
||||
private const val KEY_RATIONALE_ACK = "notification_rationale_acknowledged"
|
||||
|
||||
fun shouldShow(context: Activity): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return false
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
|
||||
== PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return !context.getSharedPreferences(PREF, android.content.Context.MODE_PRIVATE)
|
||||
.getBoolean(KEY_RATIONALE_ACK, false)
|
||||
}
|
||||
|
||||
fun showIfNeeded(
|
||||
activity: Activity,
|
||||
permissionLauncher: ActivityResultLauncher<String>,
|
||||
onDismissed: () -> Unit = {},
|
||||
) {
|
||||
if (!shouldShow(activity)) {
|
||||
onDismissed()
|
||||
return
|
||||
}
|
||||
val view = LayoutInflater.from(activity).inflate(R.layout.dialog_notification_permission, null)
|
||||
MaterialAlertDialogBuilder(activity)
|
||||
.setView(view)
|
||||
.setCancelable(true)
|
||||
.setPositiveButton(activity.getString(R.string.notification_permission_continue)) { _, _ ->
|
||||
markAcknowledged(activity)
|
||||
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
onDismissed()
|
||||
}
|
||||
.setNegativeButton(activity.getString(R.string.notification_permission_later)) { _, _ ->
|
||||
onDismissed()
|
||||
}
|
||||
.setOnCancelListener { onDismissed() }
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun markAcknowledged(context: Activity) {
|
||||
context.getSharedPreferences(PREF, android.content.Context.MODE_PRIVATE).edit()
|
||||
.putBoolean(KEY_RATIONALE_ACK, true)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
143
app/src/main/java/com/dano/test1/ui/QuestionnaireCardUi.kt
Normal file
143
app/src/main/java/com/dano/test1/ui/QuestionnaireCardUi.kt
Normal file
@ -0,0 +1,143 @@
|
||||
package com.dano.test1.ui
|
||||
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Typeface
|
||||
import android.text.TextUtils
|
||||
import android.view.Gravity
|
||||
import android.widget.Button
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.dano.test1.R
|
||||
import com.dano.test1.utils.ViewUtils
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Shared styling for questionnaire list cards on the opening screen.
|
||||
*/
|
||||
object QuestionnaireCardUi {
|
||||
|
||||
data class CardParts(
|
||||
val title: TextView,
|
||||
val subtitle: TextView,
|
||||
val chip: TextView,
|
||||
)
|
||||
|
||||
/** Keeps status chip readable without overlapping title/subtitle on long server strings. */
|
||||
fun styleStatusChip(activity: AppCompatActivity, chip: TextView) {
|
||||
val dm = activity.resources.displayMetrics
|
||||
chip.maxWidth = (dm.widthPixels * 0.36f).roundToInt()
|
||||
.coerceIn(ViewUtils.dp(activity, 96), ViewUtils.dp(activity, 200))
|
||||
chip.maxLines = 3
|
||||
chip.isSingleLine = false
|
||||
chip.ellipsize = TextUtils.TruncateAt.END
|
||||
chip.textAlignment = TextView.TEXT_ALIGNMENT_CENTER
|
||||
chip.gravity = Gravity.CENTER
|
||||
chip.includeFontPadding = true
|
||||
chip.typeface = Typeface.create(chip.typeface, Typeface.BOLD)
|
||||
chip.letterSpacing = 0.02f
|
||||
}
|
||||
|
||||
fun styleTitle(activity: AppCompatActivity, title: TextView) {
|
||||
title.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 19f)
|
||||
title.setTextColor(color(activity, R.color.brand_text_dark))
|
||||
title.typeface = Typeface.create(title.typeface, Typeface.BOLD)
|
||||
title.letterSpacing = -0.01f
|
||||
title.maxLines = 3
|
||||
title.ellipsize = TextUtils.TruncateAt.END
|
||||
title.isSingleLine = false
|
||||
}
|
||||
|
||||
fun styleSubtitle(activity: AppCompatActivity, subtitle: TextView) {
|
||||
subtitle.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 13f)
|
||||
subtitle.setTextColor(color(activity, R.color.brand_text_muted))
|
||||
subtitle.maxLines = 4
|
||||
subtitle.ellipsize = TextUtils.TruncateAt.END
|
||||
subtitle.isSingleLine = false
|
||||
subtitle.letterSpacing = 0.01f
|
||||
}
|
||||
|
||||
fun setChipLabel(activity: AppCompatActivity, chip: TextView, label: String) {
|
||||
styleStatusChip(activity, chip)
|
||||
chip.text = label
|
||||
}
|
||||
|
||||
fun applyChipStatus(
|
||||
activity: AppCompatActivity,
|
||||
parts: CardParts,
|
||||
label: String,
|
||||
backgroundRes: Int,
|
||||
textColorRes: Int = R.color.brand_text_on_dark,
|
||||
) {
|
||||
setChipLabel(activity, parts.chip, label)
|
||||
parts.chip.setBackgroundResource(backgroundRes)
|
||||
parts.chip.setTextColor(color(activity, textColorRes))
|
||||
}
|
||||
|
||||
fun applyDefaultCardTexts(activity: AppCompatActivity, parts: CardParts) {
|
||||
parts.title.setTextColor(color(activity, R.color.brand_text_dark))
|
||||
parts.subtitle.setTextColor(color(activity, R.color.brand_text_muted))
|
||||
}
|
||||
|
||||
fun setLockedAppearance(activity: AppCompatActivity, button: Button, locked: Boolean) {
|
||||
val mb = button as? MaterialButton ?: return
|
||||
if (locked) {
|
||||
mb.backgroundTintList = ColorStateList.valueOf(color(activity, R.color.brand_card_locked))
|
||||
mb.strokeColor = ColorStateList.valueOf(color(activity, R.color.brand_stroke_disabled))
|
||||
mb.alpha = 0.92f
|
||||
} else {
|
||||
mb.backgroundTintList = ColorStateList.valueOf(color(activity, R.color.brand_control_chip_bg))
|
||||
mb.strokeColor = ColorStateList.valueOf(
|
||||
color(
|
||||
activity,
|
||||
if (button.isEnabled) R.color.brand_stroke_enabled else R.color.brand_control_chip_stroke,
|
||||
),
|
||||
)
|
||||
mb.alpha = 1f
|
||||
}
|
||||
}
|
||||
|
||||
fun applyLockedCardTexts(
|
||||
activity: AppCompatActivity,
|
||||
parts: CardParts,
|
||||
lockedLabel: String,
|
||||
) {
|
||||
parts.title.setTextColor(color(activity, R.color.brand_card_locked_title))
|
||||
parts.subtitle.setTextColor(color(activity, R.color.brand_card_locked_subtitle))
|
||||
parts.subtitle.maxLines = 4
|
||||
parts.subtitle.isSingleLine = false
|
||||
applyLockedChipStyle(activity, parts, lockedLabel)
|
||||
}
|
||||
|
||||
fun applyLockedChipStyle(activity: AppCompatActivity, parts: CardParts, lockedLabel: String) {
|
||||
setChipLabel(activity, parts.chip, lockedLabel)
|
||||
parts.chip.setBackgroundResource(R.drawable.bg_chip_locked)
|
||||
parts.chip.setTextColor(color(activity, R.color.brand_chip_locked_text))
|
||||
}
|
||||
|
||||
fun applyEmphasisTint(
|
||||
activity: AppCompatActivity,
|
||||
button: Button,
|
||||
emphasize: Boolean,
|
||||
) {
|
||||
val mb = button as? MaterialButton ?: return
|
||||
mb.backgroundTintList = ColorStateList.valueOf(
|
||||
color(activity, if (emphasize) R.color.brand_emphasis_bg else R.color.brand_control_chip_bg),
|
||||
)
|
||||
mb.strokeColor = ColorStateList.valueOf(
|
||||
color(activity, if (emphasize) R.color.brand_stroke_enabled else R.color.brand_control_chip_stroke),
|
||||
)
|
||||
}
|
||||
|
||||
fun setClickableStroke(activity: AppCompatActivity, button: Button, enabled: Boolean) {
|
||||
val mb = button as? MaterialButton ?: return
|
||||
mb.strokeWidth = if (enabled) ViewUtils.dp(activity, 2) else ViewUtils.dp(activity, 1)
|
||||
mb.strokeColor = ColorStateList.valueOf(
|
||||
color(activity, if (enabled) R.color.brand_stroke_enabled else R.color.brand_stroke_disabled),
|
||||
)
|
||||
}
|
||||
|
||||
private fun color(activity: AppCompatActivity, resId: Int): Int =
|
||||
ContextCompat.getColor(activity, resId)
|
||||
}
|
||||
579
app/src/main/java/com/dano/test1/ui/ReviewScoresHandler.kt
Normal file
579
app/src/main/java/com/dano/test1/ui/ReviewScoresHandler.kt
Normal file
@ -0,0 +1,579 @@
|
||||
package com.dano.test1.ui
|
||||
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Typeface
|
||||
import android.util.TypedValue
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.Button
|
||||
import android.widget.Spinner
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.dano.test1.MainActivity
|
||||
import com.dano.test1.R
|
||||
import com.dano.test1.data.CoachScoringReviewRepository
|
||||
import com.dano.test1.network.ClientScoringReview
|
||||
import com.dano.test1.network.QuestionnaireApiClient
|
||||
import com.dano.test1.network.QuestionnaireCache
|
||||
import com.dano.test1.network.ScoringReviewProfile
|
||||
import com.dano.test1.notification.UploadReminderScheduler
|
||||
import com.dano.test1.scoring.LocalScoringReviewBuilder
|
||||
import com.dano.test1.questionnaire.AppLanguages
|
||||
import com.dano.test1.ui.localization.UiStrings
|
||||
import com.dano.test1.utils.ScreenLoading
|
||||
import com.dano.test1.utils.ViewUtils
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Coach review of locally computed scoring profile totals and bands (same rules as server).
|
||||
*/
|
||||
class ReviewScoresHandler(
|
||||
private val activity: MainActivity,
|
||||
private val token: String,
|
||||
private val languageIDProvider: () -> String,
|
||||
private val clientCodeFilter: String? = null,
|
||||
private val onLanguageChanged: (String) -> Unit = {},
|
||||
private val onUploadRequested: (() -> Unit)? = null,
|
||||
private val onClose: () -> Unit,
|
||||
) {
|
||||
private val uiScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private var reviewLanguageId: String = languageIDProvider()
|
||||
private var langSpinner: Spinner? = null
|
||||
private var suppressLangCallback = false
|
||||
private var loadJob: Job? = null
|
||||
private var active = false
|
||||
private var clients: List<ClientScoringReview> = emptyList()
|
||||
private lateinit var reviewCompletePanel: View
|
||||
private lateinit var reviewCompleteTitle: TextView
|
||||
private lateinit var reviewCompleteBody: TextView
|
||||
private lateinit var reviewCompleteUploadButton: MaterialButton
|
||||
private val singleClientMode: Boolean
|
||||
get() = !clientCodeFilter.isNullOrBlank()
|
||||
|
||||
sealed class ListItem {
|
||||
data class ClientHeader(val clientCode: String) : ListItem()
|
||||
data class Profile(
|
||||
val clientCode: String,
|
||||
val profile: ScoringReviewProfile,
|
||||
) : ListItem()
|
||||
}
|
||||
|
||||
fun show() {
|
||||
active = true
|
||||
activity.setContentView(R.layout.review_scores_screen)
|
||||
activity.setBackPressInterceptor {
|
||||
close()
|
||||
true
|
||||
}
|
||||
reviewLanguageId = languageIDProvider()
|
||||
|
||||
val titleTv = requireView<TextView>(R.id.titleReviewScores)
|
||||
val subtitleTv = requireView<TextView>(R.id.subtitleReviewScores)
|
||||
val emptyView = requireView<TextView>(R.id.emptyViewReview)
|
||||
val listLoading = requireView<View>(R.id.reviewListLoading)
|
||||
val recycler = requireView<RecyclerView>(R.id.reviewListRecycler)
|
||||
val backButton = requireView<Button>(R.id.backButtonReview)
|
||||
langSpinner = requireView(R.id.reviewLangSpinner)
|
||||
reviewCompletePanel = requireView(R.id.reviewCompletePanel)
|
||||
reviewCompleteTitle = requireView(R.id.reviewCompleteTitle)
|
||||
reviewCompleteBody = requireView(R.id.reviewCompleteBody)
|
||||
reviewCompleteUploadButton = requireView(R.id.reviewCompleteUploadButton)
|
||||
|
||||
titleTv.text = td("review_scores")
|
||||
subtitleTv.text = subtitleText()
|
||||
backButton.text = td("previous")
|
||||
bindCompletionPanel()
|
||||
|
||||
setupLanguageSpinner {
|
||||
titleTv.text = td("review_scores")
|
||||
subtitleTv.text = subtitleText()
|
||||
backButton.text = td("previous")
|
||||
bindCompletionPanel()
|
||||
bindList(recycler, emptyView)
|
||||
}
|
||||
|
||||
backButton.setOnClickListener { close() }
|
||||
|
||||
val adapter = ReviewListAdapter(
|
||||
onAgree = { clientCode, profile -> submitReview(clientCode, profile, profile.calculatedBand) },
|
||||
onSetBand = { clientCode, profile, band -> submitReview(clientCode, profile, band) },
|
||||
labelProvider = ::labels,
|
||||
bandLabel = ::bandLabel,
|
||||
bandColors = ::bandChipColors,
|
||||
)
|
||||
recycler.layoutManager = LinearLayoutManager(activity)
|
||||
recycler.adapter = adapter
|
||||
|
||||
loadJob?.cancel()
|
||||
ScreenLoading.setVisible(listLoading, true)
|
||||
recycler.visibility = View.GONE
|
||||
emptyView.visibility = View.GONE
|
||||
|
||||
loadJob = uiScope.launch {
|
||||
val loaded = withContext(Dispatchers.IO) { loadReviewData() }
|
||||
if (!active) return@launch
|
||||
clients = loaded
|
||||
ScreenLoading.setVisible(listLoading, false)
|
||||
bindList(recycler, emptyView)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindList(recycler: RecyclerView, emptyView: TextView) {
|
||||
val items = flattenClients(clients)
|
||||
val adapter = recycler.adapter as? ReviewListAdapter ?: return
|
||||
adapter.submit(items, labels())
|
||||
if (items.isEmpty()) {
|
||||
recycler.visibility = View.GONE
|
||||
emptyView.visibility = View.VISIBLE
|
||||
emptyView.text = td("review_scores_empty")
|
||||
} else {
|
||||
recycler.visibility = View.VISIBLE
|
||||
emptyView.visibility = View.GONE
|
||||
}
|
||||
bindCompletionPanel()
|
||||
}
|
||||
|
||||
private fun bindCompletionPanel() {
|
||||
if (!::reviewCompletePanel.isInitialized) return
|
||||
val profiles = clients.flatMap { it.profiles }
|
||||
val hasProfiles = profiles.isNotEmpty()
|
||||
val allReviewed = hasProfiles && profiles.all { !it.pendingReview && !it.coachBand.isNullOrBlank() }
|
||||
reviewCompletePanel.visibility = if (allReviewed) View.VISIBLE else View.GONE
|
||||
if (allReviewed) {
|
||||
reviewCompleteTitle.text = td("review_scores_complete_title")
|
||||
reviewCompleteBody.text = td("review_scores_complete_body")
|
||||
if (onUploadRequested == null) {
|
||||
reviewCompleteUploadButton.visibility = View.GONE
|
||||
reviewCompleteUploadButton.setOnClickListener(null)
|
||||
} else {
|
||||
reviewCompleteUploadButton.visibility = View.VISIBLE
|
||||
reviewCompleteUploadButton.text = td("upload")
|
||||
reviewCompleteUploadButton.setOnClickListener {
|
||||
requestUploadFromCompletePanel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun flattenClients(source: List<ClientScoringReview>): List<ListItem> {
|
||||
val out = mutableListOf<ListItem>()
|
||||
source.forEach { client ->
|
||||
if (client.profiles.isEmpty()) return@forEach
|
||||
if (!singleClientMode) {
|
||||
out += ListItem.ClientHeader(client.clientCode)
|
||||
}
|
||||
client.profiles.forEach { profile ->
|
||||
out += ListItem.Profile(client.clientCode, profile)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private suspend fun loadReviewData(): List<ClientScoringReview> {
|
||||
var profiles = QuestionnaireCache.getScoringProfiles(activity)
|
||||
if (profiles.isEmpty()) {
|
||||
profiles = runCatching { QuestionnaireApiClient.getScoringProfiles(token) }
|
||||
.getOrDefault(emptyList())
|
||||
if (profiles.isNotEmpty()) {
|
||||
QuestionnaireCache.saveScoringProfiles(activity, profiles)
|
||||
}
|
||||
}
|
||||
|
||||
val clientCodes = clientCodeFilter
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let(::listOf)
|
||||
?: QuestionnaireCache.getAssignedClients(activity)
|
||||
val computed = LocalScoringReviewBuilder.build(activity, clientCodes, profiles)
|
||||
|
||||
val coachByClient = runCatching { QuestionnaireApiClient.getScoringReview(token, clientCodeFilter) }
|
||||
.getOrDefault(emptyList())
|
||||
.associate { review ->
|
||||
review.clientCode to review.profiles.associateBy { it.profileID }
|
||||
}
|
||||
|
||||
val localByClient = clientCodes.associateWith { code ->
|
||||
CoachScoringReviewRepository.getAllForClient(code).associateBy { it.profileID }
|
||||
}
|
||||
|
||||
return computed.map { client ->
|
||||
val coachProfiles = coachByClient[client.clientCode].orEmpty()
|
||||
val localProfiles = localByClient[client.clientCode].orEmpty()
|
||||
client.copy(
|
||||
profiles = client.profiles.map { profile ->
|
||||
val local = localProfiles[profile.profileID]
|
||||
val server = coachProfiles[profile.profileID]
|
||||
when {
|
||||
local != null -> profile.copy(
|
||||
coachBand = local.coachBand,
|
||||
calculatedBand = profile.calculatedBand,
|
||||
weightedTotal = profile.weightedTotal,
|
||||
pendingReview = false,
|
||||
pendingUpload = local.isPendingUpload,
|
||||
)
|
||||
server?.coachBand != null -> profile.copy(
|
||||
coachBand = server.coachBand,
|
||||
pendingReview = false,
|
||||
pendingUpload = false,
|
||||
)
|
||||
else -> profile.copy(
|
||||
pendingReview = true,
|
||||
pendingUpload = false,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun subtitleText(): String {
|
||||
val code = clientCodeFilter?.trim().orEmpty()
|
||||
if (code.isBlank()) return td("review_scores_subtitle")
|
||||
return "${td("client")}: $code"
|
||||
}
|
||||
|
||||
private fun submitReview(
|
||||
clientCode: String,
|
||||
profile: ScoringReviewProfile,
|
||||
coachBand: String,
|
||||
) {
|
||||
uiScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
CoachScoringReviewRepository.saveDecision(
|
||||
clientCode = clientCode,
|
||||
profileID = profile.profileID,
|
||||
profileName = profile.name,
|
||||
coachBand = coachBand,
|
||||
calculatedBand = profile.calculatedBand,
|
||||
weightedTotal = profile.weightedTotal,
|
||||
)
|
||||
}
|
||||
UploadReminderScheduler.schedule(activity)
|
||||
clients = clients.map { client ->
|
||||
if (client.clientCode != clientCode) client
|
||||
else client.copy(
|
||||
profiles = client.profiles.map { p ->
|
||||
if (p.profileID != profile.profileID) {
|
||||
p
|
||||
} else {
|
||||
p.copy(
|
||||
coachBand = coachBand,
|
||||
pendingReview = false,
|
||||
pendingUpload = true,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
val recycler = activity.findViewById<RecyclerView>(R.id.reviewListRecycler)
|
||||
val emptyView = activity.findViewById<TextView>(R.id.emptyViewReview)
|
||||
bindList(recycler, emptyView)
|
||||
bindCompletionPanel()
|
||||
Toast.makeText(activity, td("review_scores_saved_pending"), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
private data class ReviewLabels(
|
||||
val clientPrefix: String,
|
||||
val total: String,
|
||||
val calculated: String,
|
||||
val coach: String,
|
||||
val agree: String,
|
||||
val setCategory: String,
|
||||
val pending: String,
|
||||
val pendingUpload: String,
|
||||
val green: String,
|
||||
val yellow: String,
|
||||
val red: String,
|
||||
)
|
||||
|
||||
private fun labels(): ReviewLabels = ReviewLabels(
|
||||
clientPrefix = td("client"),
|
||||
total = td("review_scores_total"),
|
||||
calculated = td("review_scores_calculated_category"),
|
||||
coach = td("review_scores_coach_category"),
|
||||
agree = td("review_scores_agree"),
|
||||
setCategory = td("review_scores_set_category"),
|
||||
pending = td("not_done"),
|
||||
pendingUpload = td("review_scores_pending_upload"),
|
||||
green = td("category_green"),
|
||||
yellow = td("category_yellow"),
|
||||
red = td("category_red"),
|
||||
)
|
||||
|
||||
private fun bandLabel(band: String?): String = when (band) {
|
||||
"green" -> td("category_green")
|
||||
"yellow" -> td("category_yellow")
|
||||
"red" -> td("category_red")
|
||||
else -> td("not_done")
|
||||
}
|
||||
|
||||
private data class BandChipColors(val bg: Int, val fg: Int)
|
||||
|
||||
private fun bandChipColors(band: String?): BandChipColors {
|
||||
val (bgRes, fgRes) = when (band) {
|
||||
"green" -> R.color.score_low_emphasis to R.color.score_low_text
|
||||
"yellow" -> R.color.score_mid_emphasis to R.color.score_mid_text
|
||||
"red" -> R.color.score_high_emphasis to R.color.score_high_text
|
||||
else -> R.color.score_none_emphasis to R.color.score_none_text
|
||||
}
|
||||
return BandChipColors(
|
||||
bg = ContextCompat.getColor(activity, bgRes),
|
||||
fg = ContextCompat.getColor(activity, fgRes),
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupLanguageSpinner(onChanged: () -> Unit) {
|
||||
val spinner = langSpinner ?: return
|
||||
fun bindAdapter() {
|
||||
val labels = AppLanguages.spinnerLabels(reviewLanguageId)
|
||||
val selected = labels.getOrNull(AppLanguages.indexOf(reviewLanguageId))
|
||||
ViewUtils.setupLanguagePickerSpinner(activity, spinner, labels, selected)
|
||||
}
|
||||
bindAdapter()
|
||||
spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
if (suppressLangCallback) return
|
||||
val newLang = AppLanguages.ids[position]
|
||||
if (newLang == reviewLanguageId) return
|
||||
reviewLanguageId = newLang
|
||||
AppLanguageStore.set(activity, reviewLanguageId)
|
||||
onLanguageChanged(reviewLanguageId)
|
||||
suppressLangCallback = true
|
||||
bindAdapter()
|
||||
spinner.setSelection(AppLanguages.indexOf(reviewLanguageId), false)
|
||||
suppressLangCallback = false
|
||||
onChanged()
|
||||
}
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {}
|
||||
}
|
||||
suppressLangCallback = true
|
||||
spinner.setSelection(AppLanguages.indexOf(reviewLanguageId), false)
|
||||
suppressLangCallback = false
|
||||
}
|
||||
|
||||
private fun close() {
|
||||
active = false
|
||||
loadJob?.cancel()
|
||||
loadJob = null
|
||||
activity.setBackPressInterceptor(null)
|
||||
onClose()
|
||||
}
|
||||
|
||||
private fun requestUploadFromCompletePanel() {
|
||||
val callback = onUploadRequested
|
||||
if (callback == null) {
|
||||
close()
|
||||
return
|
||||
}
|
||||
active = false
|
||||
loadJob?.cancel()
|
||||
loadJob = null
|
||||
activity.setBackPressInterceptor(null)
|
||||
callback()
|
||||
}
|
||||
|
||||
private fun td(key: String): String = UiStrings.tDisplay(reviewLanguageId, key, key)
|
||||
|
||||
companion object {
|
||||
suspend fun hasScoresForClient(
|
||||
activity: MainActivity,
|
||||
token: String,
|
||||
clientCode: String,
|
||||
): Boolean {
|
||||
val code = clientCode.trim()
|
||||
if (code.isBlank()) return false
|
||||
|
||||
var profiles = QuestionnaireCache.getScoringProfiles(activity)
|
||||
if (profiles.isEmpty()) {
|
||||
profiles = runCatching { QuestionnaireApiClient.getScoringProfiles(token) }
|
||||
.getOrDefault(emptyList())
|
||||
if (profiles.isNotEmpty()) {
|
||||
QuestionnaireCache.saveScoringProfiles(activity, profiles)
|
||||
}
|
||||
}
|
||||
|
||||
return LocalScoringReviewBuilder
|
||||
.build(activity, listOf(code), profiles)
|
||||
.any { it.profiles.isNotEmpty() }
|
||||
}
|
||||
|
||||
suspend fun hasPendingReviewsForClient(
|
||||
activity: MainActivity,
|
||||
token: String,
|
||||
clientCode: String,
|
||||
): Boolean {
|
||||
val code = clientCode.trim()
|
||||
if (code.isBlank()) return false
|
||||
|
||||
var profiles = QuestionnaireCache.getScoringProfiles(activity)
|
||||
if (profiles.isEmpty()) {
|
||||
profiles = runCatching { QuestionnaireApiClient.getScoringProfiles(token) }
|
||||
.getOrDefault(emptyList())
|
||||
if (profiles.isNotEmpty()) {
|
||||
QuestionnaireCache.saveScoringProfiles(activity, profiles)
|
||||
}
|
||||
}
|
||||
|
||||
val computed = LocalScoringReviewBuilder
|
||||
.build(activity, listOf(code), profiles)
|
||||
.firstOrNull()
|
||||
?.profiles
|
||||
.orEmpty()
|
||||
if (computed.isEmpty()) return false
|
||||
|
||||
val localByProfile = CoachScoringReviewRepository
|
||||
.getAllForClient(code)
|
||||
.associateBy { it.profileID }
|
||||
val serverByProfile = runCatching {
|
||||
QuestionnaireApiClient.getScoringReview(token, code)
|
||||
}
|
||||
.getOrDefault(emptyList())
|
||||
.firstOrNull { it.clientCode.equals(code, ignoreCase = true) }
|
||||
?.profiles
|
||||
?.associateBy { it.profileID }
|
||||
.orEmpty()
|
||||
|
||||
return computed.any { profile ->
|
||||
localByProfile[profile.profileID] == null &&
|
||||
serverByProfile[profile.profileID]?.coachBand.isNullOrBlank()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T : View> requireView(id: Int): T {
|
||||
return activity.findViewById(id)
|
||||
?: throw IllegalStateException("Missing view $id")
|
||||
}
|
||||
|
||||
private class ReviewListAdapter(
|
||||
private val onAgree: (String, ScoringReviewProfile) -> Unit,
|
||||
private val onSetBand: (String, ScoringReviewProfile, String) -> Unit,
|
||||
private val labelProvider: () -> ReviewLabels,
|
||||
private val bandLabel: (String?) -> String,
|
||||
private val bandColors: (String?) -> BandChipColors,
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private var items: List<ListItem> = emptyList()
|
||||
private var labels: ReviewLabels = ReviewLabels("", "", "", "", "", "", "", "", "", "", "")
|
||||
|
||||
fun submit(list: List<ListItem>, labels: ReviewLabels) {
|
||||
items = list
|
||||
this.labels = labels
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int =
|
||||
if (items[position] is ListItem.ClientHeader) 0 else 1
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
return if (viewType == 0) {
|
||||
HeaderHolder(inflater.inflate(R.layout.item_upload_client_header, parent, false))
|
||||
} else {
|
||||
ProfileHolder(inflater.inflate(R.layout.review_score_profile_item, parent, false))
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (val item = items[position]) {
|
||||
is ListItem.ClientHeader -> {
|
||||
(holder as HeaderHolder).text.text = "${labels.clientPrefix}: ${item.clientCode}"
|
||||
}
|
||||
is ListItem.Profile -> bindProfile(holder as ProfileHolder, item)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindProfile(holder: ProfileHolder, item: ListItem.Profile) {
|
||||
val profile = item.profile
|
||||
holder.name.text = profile.name
|
||||
holder.total.text = "${labels.total}: ${profile.weightedTotal}"
|
||||
holder.ranges.text = rangeText(profile)
|
||||
holder.calculatedLabel.text = labels.calculated
|
||||
holder.coachLabel.text = labels.coach
|
||||
holder.setCategoryLabel.text = labels.setCategory
|
||||
holder.agreeButton.text = labels.agree
|
||||
holder.greenButton.text = labels.green
|
||||
holder.yellowButton.text = labels.yellow
|
||||
holder.redButton.text = labels.red
|
||||
styleBandButton(holder.greenButton, "green")
|
||||
styleBandButton(holder.yellowButton, "yellow")
|
||||
styleBandButton(holder.redButton, "red")
|
||||
|
||||
styleBandChip(holder.calculatedBand, profile.calculatedBand)
|
||||
val coachBand = profile.coachBand
|
||||
when {
|
||||
profile.pendingReview -> {
|
||||
styleBandChip(holder.coachBand, null)
|
||||
holder.coachBand.text = labels.pending
|
||||
}
|
||||
profile.pendingUpload -> {
|
||||
styleBandChip(holder.coachBand, coachBand)
|
||||
holder.coachBand.text = "${bandLabel(coachBand)} · ${labels.pendingUpload}"
|
||||
}
|
||||
else -> styleBandChip(holder.coachBand, coachBand)
|
||||
}
|
||||
|
||||
holder.agreeButton.setOnClickListener { onAgree(item.clientCode, profile) }
|
||||
holder.greenButton.setOnClickListener { onSetBand(item.clientCode, profile, "green") }
|
||||
holder.yellowButton.setOnClickListener { onSetBand(item.clientCode, profile, "yellow") }
|
||||
holder.redButton.setOnClickListener { onSetBand(item.clientCode, profile, "red") }
|
||||
}
|
||||
|
||||
private fun styleBandChip(chip: TextView, band: String?) {
|
||||
val colors = bandColors(band)
|
||||
chip.text = bandLabel(band)
|
||||
chip.setBackgroundColor(colors.bg)
|
||||
chip.setTextColor(colors.fg)
|
||||
chip.setTypeface(chip.typeface, Typeface.BOLD)
|
||||
chip.setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
|
||||
}
|
||||
|
||||
private fun styleBandButton(button: MaterialButton, band: String) {
|
||||
val colors = bandColors(band)
|
||||
button.backgroundTintList = ColorStateList.valueOf(colors.bg)
|
||||
button.setTextColor(colors.fg)
|
||||
button.strokeColor = ColorStateList.valueOf(colors.fg)
|
||||
button.strokeWidth = 1
|
||||
}
|
||||
|
||||
private fun rangeText(profile: ScoringReviewProfile): String =
|
||||
"${labels.green}: ${profile.greenMin}-${profile.greenMax} · " +
|
||||
"${labels.yellow}: ${profile.yellowMin}-${profile.yellowMax} · " +
|
||||
"${labels.red}: ${profile.redMin}+"
|
||||
|
||||
private class HeaderHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val text: TextView = view.findViewById(R.id.clientHeaderText)
|
||||
}
|
||||
|
||||
private class ProfileHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val name: TextView = view.findViewById(R.id.profileNameText)
|
||||
val total: TextView = view.findViewById(R.id.profileTotalText)
|
||||
val ranges: TextView = view.findViewById(R.id.profileRangesText)
|
||||
val calculatedLabel: TextView = view.findViewById(R.id.calculatedLabelText)
|
||||
val coachLabel: TextView = view.findViewById(R.id.coachLabelText)
|
||||
val calculatedBand: TextView = view.findViewById(R.id.calculatedBandChip)
|
||||
val coachBand: TextView = view.findViewById(R.id.coachBandChip)
|
||||
val agreeButton: MaterialButton = view.findViewById(R.id.agreeButton)
|
||||
val setCategoryLabel: TextView = view.findViewById(R.id.setCategoryLabel)
|
||||
val greenButton: MaterialButton = view.findViewById(R.id.greenButton)
|
||||
val yellowButton: MaterialButton = view.findViewById(R.id.yellowButton)
|
||||
val redButton: MaterialButton = view.findViewById(R.id.redButton)
|
||||
}
|
||||
}
|
||||
}
|
||||
63
app/src/main/java/com/dano/test1/ui/SessionLogout.kt
Normal file
63
app/src/main/java/com/dano/test1/ui/SessionLogout.kt
Normal file
@ -0,0 +1,63 @@
|
||||
package com.dano.test1.ui
|
||||
|
||||
import android.content.Context
|
||||
import com.dano.test1.LanguageManager
|
||||
import com.dano.test1.data.UnuploadedWorkRepository
|
||||
import com.dano.test1.network.ClientAnswerSyncStore
|
||||
import com.dano.test1.network.NetworkUtils
|
||||
import com.dano.test1.network.QuestionnaireCache
|
||||
import com.dano.test1.network.TokenStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* User-initiated logout: requires online + no unuploaded local work, then wipes all local data.
|
||||
*/
|
||||
object SessionLogout {
|
||||
|
||||
sealed class BlockReason {
|
||||
data object Offline : BlockReason()
|
||||
data class UnuploadedWork(val summary: UnuploadedWorkRepository.Summary) : BlockReason()
|
||||
}
|
||||
|
||||
suspend fun evaluate(context: Context): BlockReason? {
|
||||
if (!NetworkUtils.isOnline(context)) return BlockReason.Offline
|
||||
val summary = UnuploadedWorkRepository.summarize()
|
||||
if (summary.hasBlockingWork) return BlockReason.UnuploadedWork(summary)
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun wipeAllLocalData(context: Context) {
|
||||
withContext(Dispatchers.IO) {
|
||||
QuestionnaireCache.wipeEntireLocalStore(context)
|
||||
ClientAnswerSyncStore.clear(context)
|
||||
ClientRecentStore.clear(context)
|
||||
TokenStore.clear(context)
|
||||
}
|
||||
}
|
||||
|
||||
fun formatPendingDetails(languageId: String, summary: UnuploadedWorkRepository.Summary): String {
|
||||
val parts = mutableListOf<String>()
|
||||
if (summary.pendingQuestionnaires > 0) {
|
||||
parts += format(languageId, "logout_pending_detail_questionnaires", summary.pendingQuestionnaires)
|
||||
}
|
||||
if (summary.pendingScoringReviews > 0) {
|
||||
parts += format(languageId, "logout_pending_detail_scoring", summary.pendingScoringReviews)
|
||||
}
|
||||
if (summary.inProgressQuestionnaires > 0) {
|
||||
parts += format(languageId, "logout_pending_detail_in_progress", summary.inProgressQuestionnaires)
|
||||
}
|
||||
return parts.joinToString("\n")
|
||||
}
|
||||
|
||||
fun message(languageId: String, key: String, fallback: String): String {
|
||||
val raw = runCatching { LanguageManager.getText(languageId, key) }.getOrDefault(fallback)
|
||||
val cleaned = raw.trim().removePrefix("[").removeSuffix("]")
|
||||
return if (cleaned.equals(key, ignoreCase = true) || cleaned.isBlank()) fallback else cleaned
|
||||
}
|
||||
|
||||
private fun format(languageId: String, key: String, count: Int): String {
|
||||
val template = message(languageId, key, key)
|
||||
return template.replace("{count}", count.toString())
|
||||
}
|
||||
}
|
||||
75
app/src/main/java/com/dano/test1/ui/SystemBarInsets.kt
Normal file
75
app/src/main/java/com/dano/test1/ui/SystemBarInsets.kt
Normal file
@ -0,0 +1,75 @@
|
||||
package com.dano.test1.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updatePadding
|
||||
import com.dano.test1.R
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* Keeps interactive UI above system bars and preserves layout padding from XML.
|
||||
* [ensureMinContentPadding] adds [R.dimen.screen_content_padding] on sides that have
|
||||
* no XML padding (used by MainActivity for full-bleed questionnaire layouts).
|
||||
*/
|
||||
object SystemBarInsets {
|
||||
|
||||
private val insetTypes =
|
||||
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
|
||||
|
||||
private data class Padding4(
|
||||
val left: Int,
|
||||
val top: Int,
|
||||
val right: Int,
|
||||
val bottom: Int,
|
||||
)
|
||||
|
||||
fun applyToContentRoot(
|
||||
activity: Activity,
|
||||
ensureMinContentPadding: Boolean = false,
|
||||
) {
|
||||
val content = activity.findViewById<ViewGroup>(android.R.id.content) ?: return
|
||||
content.setPadding(0, 0, 0, 0)
|
||||
|
||||
val root = content.getChildAt(0) ?: return
|
||||
captureBasePadding(root)
|
||||
|
||||
val minPad = if (ensureMinContentPadding) {
|
||||
activity.resources.getDimensionPixelSize(R.dimen.screen_content_padding)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(root) { view, windowInsets ->
|
||||
val bars = windowInsets.getInsets(insetTypes)
|
||||
val base = basePadding(view)
|
||||
view.updatePadding(
|
||||
left = max(base.left, minPad) + bars.left,
|
||||
top = max(base.top, minPad) + bars.top,
|
||||
right = max(base.right, minPad) + bars.right,
|
||||
bottom = max(base.bottom, minPad) + bars.bottom,
|
||||
)
|
||||
WindowInsetsCompat.CONSUMED
|
||||
}
|
||||
ViewCompat.requestApplyInsets(root)
|
||||
}
|
||||
|
||||
private fun captureBasePadding(root: View) {
|
||||
if (root.getTag(R.id.system_bars_base_padding) != null) return
|
||||
root.setTag(
|
||||
R.id.system_bars_base_padding,
|
||||
Padding4(
|
||||
root.paddingLeft,
|
||||
root.paddingTop,
|
||||
root.paddingRight,
|
||||
root.paddingBottom,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun basePadding(view: View): Padding4 {
|
||||
return (view.getTag(R.id.system_bars_base_padding) as? Padding4) ?: Padding4(0, 0, 0, 0)
|
||||
}
|
||||
}
|
||||
723
app/src/main/java/com/dano/test1/ui/UploadOverviewHandler.kt
Normal file
723
app/src/main/java/com/dano/test1/ui/UploadOverviewHandler.kt
Normal file
@ -0,0 +1,723 @@
|
||||
package com.dano.test1.ui
|
||||
|
||||
import android.graphics.Typeface
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.*
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.dano.test1.MainActivity
|
||||
import com.dano.test1.MyApp
|
||||
import com.dano.test1.R
|
||||
import com.dano.test1.data.AnswerRepository
|
||||
import com.dano.test1.data.CoachScoringReviewRepository
|
||||
import com.dano.test1.data.CompletedQuestionnaireRepository
|
||||
import com.dano.test1.data.Question
|
||||
import com.dano.test1.network.DatabaseUploader
|
||||
import com.dano.test1.network.QuestionnaireCache
|
||||
import com.dano.test1.questionnaire.AppLanguages
|
||||
import com.dano.test1.questionnaire.QuestionnaireTranslationsLoader
|
||||
import com.dano.test1.ui.localization.UiStrings
|
||||
import com.dano.test1.utils.ScreenLoading
|
||||
import com.dano.test1.utils.ViewUtils
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.gson.JsonParser
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
/**
|
||||
* Upload overview: pending questionnaires across clients, localized UI, language selector.
|
||||
*/
|
||||
class UploadOverviewHandler(
|
||||
private val activity: MainActivity,
|
||||
private val token: String,
|
||||
private val languageIDProvider: () -> String,
|
||||
private val onLanguageChanged: (String) -> Unit = {},
|
||||
private val onClose: (uploaded: Boolean) -> Unit,
|
||||
) {
|
||||
private val uiScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private val tag = "UploadOverviewHandler"
|
||||
|
||||
private var uploadLanguageId: String = languageIDProvider()
|
||||
private var langSpinner: Spinner? = null
|
||||
private var detailLangSpinner: Spinner? = null
|
||||
private var suppressLangCallback = false
|
||||
private var overviewLoadJob: Job? = null
|
||||
private var detailLoadJob: Job? = null
|
||||
private var overviewActive = false
|
||||
private var uploadedDuringSession = false
|
||||
|
||||
private val nameMap: Map<String, String> by lazy {
|
||||
QuestionnaireCache.getQuestionnaireList(activity).associate { it.id to it.name }
|
||||
}
|
||||
|
||||
data class PendingRow(
|
||||
val clientCode: String,
|
||||
val questionnaireId: String,
|
||||
val questionnaireName: String,
|
||||
val answeredCount: Int,
|
||||
)
|
||||
|
||||
data class PendingScoringRow(
|
||||
val clientCode: String,
|
||||
val profileID: String,
|
||||
val profileName: String,
|
||||
val coachBand: String,
|
||||
val weightedTotal: Double,
|
||||
)
|
||||
|
||||
sealed class ListItem {
|
||||
data class ClientHeader(val clientCode: String) : ListItem()
|
||||
data class Questionnaire(val row: PendingRow) : ListItem()
|
||||
data class ScoringReview(val row: PendingScoringRow) : ListItem()
|
||||
}
|
||||
|
||||
fun show() {
|
||||
openOverviewScreen()
|
||||
}
|
||||
|
||||
private fun closeOverview() {
|
||||
overviewActive = false
|
||||
overviewLoadJob?.cancel()
|
||||
overviewLoadJob = null
|
||||
detailLoadJob?.cancel()
|
||||
detailLoadJob = null
|
||||
activity.setBackPressInterceptor(null)
|
||||
onClose(uploadedDuringSession)
|
||||
}
|
||||
|
||||
private fun openOverviewScreen() {
|
||||
overviewLoadJob?.cancel()
|
||||
detailLoadJob?.cancel()
|
||||
overviewActive = true
|
||||
activity.setContentView(R.layout.upload_overview_screen)
|
||||
activity.setBackPressInterceptor {
|
||||
closeOverview()
|
||||
true
|
||||
}
|
||||
uploadLanguageId = languageIDProvider()
|
||||
|
||||
val titleTv = requireView<TextView>(R.id.titleUploadOverview)
|
||||
val subtitleTv = requireView<TextView>(R.id.subtitleUploadOverview)
|
||||
val summaryTv = requireView<TextView>(R.id.summaryUploadOverview)
|
||||
val listLoading = requireView<View>(R.id.uploadListLoading)
|
||||
val progressDeterminate = requireView<ProgressBar>(R.id.progressBarUploadDeterminate)
|
||||
val emptyView = requireView<TextView>(R.id.emptyViewUpload)
|
||||
val recycler = requireView<RecyclerView>(R.id.uploadListRecycler)
|
||||
val backButton = requireView<Button>(R.id.backButtonUpload)
|
||||
val uploadButton = requireView<MaterialButton>(R.id.confirmUploadButton)
|
||||
langSpinner = requireView(R.id.uploadLangSpinner)
|
||||
|
||||
bindChrome(titleTv, subtitleTv, backButton, uploadButton)
|
||||
setupLanguageSpinner(langSpinner) {
|
||||
reloadOverviewList(
|
||||
recycler, summaryTv, listLoading, emptyView, uploadButton,
|
||||
titleTv, subtitleTv, backButton,
|
||||
)
|
||||
}
|
||||
|
||||
backButton.setOnClickListener { closeOverview() }
|
||||
|
||||
setListLoading(listLoading, true, recycler, summaryTv, emptyView)
|
||||
progressDeterminate.visibility = View.GONE
|
||||
summaryTv.visibility = View.GONE
|
||||
|
||||
val adapter = UploadListAdapter(
|
||||
onRowClick = { row -> openDetailScreen(row.clientCode, row.questionnaireId) },
|
||||
clientHeaderText = { code -> "${td("client", "Client")}: $code" },
|
||||
summaryForCount = { count -> buildSummaryString(count) },
|
||||
scoringSummaryFor = { row -> buildScoringSummaryString(row) },
|
||||
scoringTitleFor = { row -> scoringReviewTitle(row) },
|
||||
)
|
||||
recycler.layoutManager = LinearLayoutManager(activity)
|
||||
recycler.adapter = adapter
|
||||
|
||||
overviewLoadJob = uiScope.launch {
|
||||
val items = loadPendingListItems()
|
||||
if (!overviewActive) return@launch
|
||||
setListLoading(listLoading, false, recycler, summaryTv, emptyView)
|
||||
if (items.isEmpty()) {
|
||||
emptyView.text = td("no_questionnaires", "Nothing pending upload.")
|
||||
emptyView.visibility = View.VISIBLE
|
||||
uploadButton.isEnabled = false
|
||||
uploadButton.alpha = 0.5f
|
||||
adapter.submit(emptyList())
|
||||
return@launch
|
||||
}
|
||||
val qCount = items.count { it is ListItem.Questionnaire }
|
||||
val sCount = items.count { it is ListItem.ScoringReview }
|
||||
val cCount = items.filterIsInstance<ListItem.ClientHeader>().size
|
||||
summaryTv.text = formatSummary(qCount, sCount, cCount)
|
||||
summaryTv.visibility = View.VISIBLE
|
||||
uploadButton.isEnabled = true
|
||||
uploadButton.alpha = 1f
|
||||
adapter.submit(items)
|
||||
|
||||
uploadButton.setOnClickListener {
|
||||
uploadButton.isEnabled = false
|
||||
uploadButton.alpha = 0.5f
|
||||
setListLoading(listLoading, true, recycler, summaryTv, emptyView)
|
||||
progressDeterminate.visibility = View.VISIBLE
|
||||
progressDeterminate.progress = 0
|
||||
progressDeterminate.max = (qCount + sCount).coerceAtLeast(1)
|
||||
|
||||
DatabaseUploader.uploadDatabaseWithToken(
|
||||
activity,
|
||||
token,
|
||||
uploadLanguageId,
|
||||
onProgress = { done, total ->
|
||||
if (!overviewActive) return@uploadDatabaseWithToken
|
||||
progressDeterminate.max = total.coerceAtLeast(1)
|
||||
progressDeterminate.progress = done
|
||||
},
|
||||
) { result ->
|
||||
if (!overviewActive) return@uploadDatabaseWithToken
|
||||
setListLoading(listLoading, false, recycler, summaryTv, emptyView)
|
||||
progressDeterminate.visibility = View.GONE
|
||||
showUploadResultDialog(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun reloadOverviewList(
|
||||
recycler: RecyclerView,
|
||||
summaryTv: TextView,
|
||||
listLoading: View,
|
||||
emptyView: TextView,
|
||||
uploadButton: MaterialButton,
|
||||
titleTv: TextView,
|
||||
subtitleTv: TextView,
|
||||
backButton: Button,
|
||||
) {
|
||||
if (!overviewActive) return
|
||||
val adapter = recycler.adapter as? UploadListAdapter ?: return
|
||||
overviewLoadJob?.cancel()
|
||||
setListLoading(listLoading, true, recycler, summaryTv, emptyView)
|
||||
overviewLoadJob = uiScope.launch {
|
||||
val items = loadPendingListItems()
|
||||
if (!overviewActive) return@launch
|
||||
setListLoading(listLoading, false, recycler, summaryTv, emptyView)
|
||||
bindChrome(titleTv, subtitleTv, backButton, uploadButton)
|
||||
adapter.submit(items)
|
||||
if (items.isEmpty()) {
|
||||
summaryTv.visibility = View.GONE
|
||||
emptyView.text = td("no_questionnaires", "Nothing pending upload.")
|
||||
emptyView.visibility = View.VISIBLE
|
||||
uploadButton.isEnabled = false
|
||||
uploadButton.alpha = 0.5f
|
||||
} else {
|
||||
val qCount = items.count { it is ListItem.Questionnaire }
|
||||
val sCount = items.count { it is ListItem.ScoringReview }
|
||||
val cCount = items.filterIsInstance<ListItem.ClientHeader>().size
|
||||
summaryTv.text = formatSummary(qCount, sCount, cCount)
|
||||
summaryTv.visibility = View.VISIBLE
|
||||
emptyView.visibility = View.GONE
|
||||
uploadButton.isEnabled = true
|
||||
uploadButton.alpha = 1f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadPendingListItems(): List<ListItem> = withContext(Dispatchers.IO) {
|
||||
val pending = CompletedQuestionnaireRepository.getAllPendingUploads()
|
||||
val pendingScoring = CoachScoringReviewRepository.getAllPendingUploads()
|
||||
if (pending.isEmpty() && pendingScoring.isEmpty()) return@withContext emptyList()
|
||||
|
||||
val qRows = pending.map { entry ->
|
||||
val answers = AnswerRepository
|
||||
.getAnswersForClientAndQuestionnaire(entry.clientCode, entry.questionnaireId)
|
||||
val answeredCount = filterDuplicateAliasAnswerIds(
|
||||
entry.questionnaireId,
|
||||
answers.map { it.questionId },
|
||||
)
|
||||
.mapNotNull { questionId -> answers.find { it.questionId == questionId } }
|
||||
.count { it.answerValue.isNotBlank() }
|
||||
val displayName = UiStrings.questionnaireDisplayName(
|
||||
uploadLanguageId,
|
||||
entry.questionnaireId,
|
||||
nameMap[entry.questionnaireId],
|
||||
)
|
||||
PendingRow(entry.clientCode, entry.questionnaireId, displayName, answeredCount)
|
||||
}
|
||||
|
||||
val sRows = pendingScoring.map { review ->
|
||||
PendingScoringRow(
|
||||
clientCode = review.clientCode,
|
||||
profileID = review.profileID,
|
||||
profileName = review.profileName.ifBlank { review.profileID },
|
||||
coachBand = review.coachBand,
|
||||
weightedTotal = review.weightedTotal,
|
||||
)
|
||||
}
|
||||
|
||||
val clientCodes = (qRows.map { it.clientCode } + sRows.map { it.clientCode }).distinct()
|
||||
.sortedBy { it.lowercase() }
|
||||
|
||||
val items = mutableListOf<ListItem>()
|
||||
for (code in clientCodes) {
|
||||
items.add(ListItem.ClientHeader(code))
|
||||
qRows.filter { it.clientCode == code }
|
||||
.sortedWith(
|
||||
compareBy(
|
||||
{ UiStrings.extractQuestionnaireNumber(it.questionnaireId) ?: Int.MAX_VALUE },
|
||||
{ it.questionnaireId },
|
||||
),
|
||||
)
|
||||
.forEach { items.add(ListItem.Questionnaire(it)) }
|
||||
sRows.filter { it.clientCode == code }
|
||||
.sortedBy { it.profileName.lowercase() }
|
||||
.forEach { items.add(ListItem.ScoringReview(it)) }
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
private fun formatSummary(questionnaireCount: Int, scoringCount: Int, clientCount: Int): String {
|
||||
val template = td(
|
||||
"upload_summary",
|
||||
"{qn} questionnaires · {scores} score reviews · {clients} clients",
|
||||
)
|
||||
return template
|
||||
.replace("{qn}", questionnaireCount.toString())
|
||||
.replace("{scores}", scoringCount.toString())
|
||||
.replace("{clients}", clientCount.toString())
|
||||
}
|
||||
|
||||
private fun scoringReviewTitle(row: PendingScoringRow): String {
|
||||
val template = td("upload_item_scoring_review", "Score review: {profile}")
|
||||
return template.replace("{profile}", row.profileName)
|
||||
}
|
||||
|
||||
private fun buildScoringSummaryString(row: PendingScoringRow): String {
|
||||
val bandLabel = when (row.coachBand) {
|
||||
"green" -> td("category_green", "Green")
|
||||
"yellow" -> td("category_yellow", "Yellow")
|
||||
"red" -> td("category_red", "Red")
|
||||
else -> row.coachBand
|
||||
}
|
||||
val template = td("upload_scoring_review_summary", "Coach: {band} · total {total}")
|
||||
return template
|
||||
.replace("{band}", bandLabel)
|
||||
.replace("{total}", row.weightedTotal.toString())
|
||||
}
|
||||
|
||||
private fun openDetailScreen(clientCode: String, questionnaireId: String) {
|
||||
overviewActive = false
|
||||
overviewLoadJob?.cancel()
|
||||
overviewLoadJob = null
|
||||
activity.setContentView(R.layout.questionnaire_detail_screen)
|
||||
activity.setBackPressInterceptor {
|
||||
openOverviewScreen()
|
||||
true
|
||||
}
|
||||
|
||||
val title = requireView<TextView>(R.id.titleQuestionnaireDetail)
|
||||
val recycler = requireView<RecyclerView>(R.id.qaListRecycler)
|
||||
val listLoading = requireView<View>(R.id.qaListLoading)
|
||||
val emptyView = requireView<TextView>(R.id.emptyViewQA)
|
||||
val backButton = requireView<Button>(R.id.backButtonQA)
|
||||
detailLangSpinner = requireView(R.id.detailLangSpinner)
|
||||
|
||||
val displayName = UiStrings.questionnaireDisplayName(
|
||||
uploadLanguageId,
|
||||
questionnaireId,
|
||||
nameMap[questionnaireId],
|
||||
)
|
||||
title.text = "${td("client", "Client")}: $clientCode – $displayName"
|
||||
backButton.text = td("previous", "Back")
|
||||
emptyView.text = td("no_questions_available", "No questions available.")
|
||||
backButton.setOnClickListener { openOverviewScreen() }
|
||||
|
||||
setupLanguageSpinner(detailLangSpinner) {
|
||||
val dn = UiStrings.questionnaireDisplayName(
|
||||
uploadLanguageId,
|
||||
questionnaireId,
|
||||
nameMap[questionnaireId],
|
||||
)
|
||||
title.text = "${td("client", "Client")}: $clientCode – $dn"
|
||||
backButton.text = td("previous", "Back")
|
||||
reloadDetailList(clientCode, questionnaireId, recycler, listLoading, emptyView)
|
||||
}
|
||||
|
||||
val qaAdapter = QaListAdapter()
|
||||
recycler.layoutManager = LinearLayoutManager(activity)
|
||||
recycler.adapter = qaAdapter
|
||||
|
||||
emptyView.visibility = View.GONE
|
||||
reloadDetailList(clientCode, questionnaireId, recycler, listLoading, emptyView)
|
||||
}
|
||||
|
||||
private fun reloadDetailList(
|
||||
clientCode: String,
|
||||
questionnaireId: String,
|
||||
recycler: RecyclerView,
|
||||
listLoading: View,
|
||||
emptyView: TextView,
|
||||
) {
|
||||
val adapter = recycler.adapter as? QaListAdapter ?: return
|
||||
detailLoadJob?.cancel()
|
||||
setListLoading(listLoading, true, recycler, emptyView)
|
||||
detailLoadJob = uiScope.launch {
|
||||
val cards = withContext(Dispatchers.IO) {
|
||||
QuestionnaireTranslationsLoader.applyFor(activity, questionnaireId)
|
||||
val questions = MyApp.database.questionDao().getQuestionsForQuestionnaire(questionnaireId)
|
||||
val answers = AnswerRepository
|
||||
.getAnswersForClientAndQuestionnaire(clientCode, questionnaireId)
|
||||
val filteredAnswerIds = filterDuplicateAliasAnswerIds(
|
||||
questionnaireId,
|
||||
answers.map { it.questionId },
|
||||
).toSet()
|
||||
val answerMap = answers
|
||||
.filter { it.questionId in filteredAnswerIds }
|
||||
.associate { it.questionId to it.answerValue }
|
||||
questions
|
||||
.filter { it.questionId in filteredAnswerIds }
|
||||
.mapIndexed { idx, q ->
|
||||
QaCard(
|
||||
index = idx + 1,
|
||||
questionText = UiStrings.localizeQuestionLabel(
|
||||
uploadLanguageId,
|
||||
q.questionId,
|
||||
q.question,
|
||||
),
|
||||
answerText = localizeAnswerForRow(q, answerMap[q.questionId]),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!isActive) return@launch
|
||||
setListLoading(listLoading, false, recycler, emptyView)
|
||||
if (cards.isEmpty()) {
|
||||
emptyView.visibility = View.VISIBLE
|
||||
adapter.submit(emptyList())
|
||||
} else {
|
||||
emptyView.visibility = View.GONE
|
||||
adapter.submit(cards)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun localizeAnswerForRow(q: Question, raw: String?): String {
|
||||
val value = raw?.takeIf { it.isNotBlank() } ?: "—"
|
||||
val baseId = q.questionId.substringAfterLast('-', q.questionId)
|
||||
return UiStrings.localizeAnswerValue(uploadLanguageId, baseId, value)
|
||||
}
|
||||
|
||||
private fun filterDuplicateAliasAnswerIds(
|
||||
questionnaireId: String,
|
||||
questionIds: List<String>,
|
||||
): List<String> {
|
||||
val canonicalByAlias = canonicalAnswerRoomIds(questionnaireId)
|
||||
if (canonicalByAlias.isEmpty()) return questionIds
|
||||
|
||||
return questionIds.filter { questionId ->
|
||||
canonicalByAlias[questionId] == null
|
||||
}
|
||||
}
|
||||
|
||||
private fun canonicalAnswerRoomIds(questionnaireId: String): Map<String, String> {
|
||||
val json = QuestionnaireCache.loadQuestionnaireJson(activity, questionnaireId) ?: return emptyMap()
|
||||
val root = runCatching { JsonParser.parseString(json).asJsonObject }.getOrNull() ?: return emptyMap()
|
||||
val questions = root.getAsJsonArray("questions") ?: return emptyMap()
|
||||
val result = mutableMapOf<String, String>()
|
||||
|
||||
questions.forEach { element ->
|
||||
val obj = element.asJsonObject
|
||||
val shortId = obj.get("id")?.asString?.takeIf { it.isNotBlank() } ?: return@forEach
|
||||
val key = obj.get("question")?.asString?.takeIf { it.isNotBlank() } ?: return@forEach
|
||||
if (key == shortId) return@forEach
|
||||
|
||||
result["$questionnaireId-$shortId"] = "$questionnaireId-$key"
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private fun bindChrome(
|
||||
titleTv: TextView,
|
||||
subtitleTv: TextView,
|
||||
backButton: Button,
|
||||
uploadButton: MaterialButton,
|
||||
) {
|
||||
titleTv.text = td("start_upload", "Upload Overview")
|
||||
subtitleTv.text = td("upload_pending_subtitle", "Pending upload")
|
||||
backButton.text = td("cancel", td("previous", "Back"))
|
||||
uploadButton.text = td("upload", "Upload")
|
||||
}
|
||||
|
||||
private fun setupLanguageSpinner(spinner: Spinner?, onChanged: () -> Unit) {
|
||||
spinner ?: return
|
||||
fun bindAdapter() {
|
||||
val labels = AppLanguages.spinnerLabels(uploadLanguageId)
|
||||
val selected = labels.getOrNull(AppLanguages.indexOf(uploadLanguageId))
|
||||
ViewUtils.setupLanguagePickerSpinner(activity, spinner, labels, selected)
|
||||
}
|
||||
bindAdapter()
|
||||
spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
if (suppressLangCallback) return
|
||||
val newLang = AppLanguages.ids[position]
|
||||
if (newLang == uploadLanguageId) return
|
||||
uploadLanguageId = newLang
|
||||
AppLanguageStore.set(activity, uploadLanguageId)
|
||||
onLanguageChanged(uploadLanguageId)
|
||||
suppressLangCallback = true
|
||||
bindAdapter()
|
||||
spinner.setSelection(AppLanguages.indexOf(uploadLanguageId), false)
|
||||
suppressLangCallback = false
|
||||
onChanged()
|
||||
}
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {}
|
||||
}
|
||||
suppressLangCallback = true
|
||||
spinner.setSelection(AppLanguages.indexOf(uploadLanguageId), false)
|
||||
suppressLangCallback = false
|
||||
}
|
||||
|
||||
private fun showUploadResultDialog(result: DatabaseUploader.UploadResult) {
|
||||
if (result.needsReLogin) {
|
||||
activity.requestLogin()
|
||||
return
|
||||
}
|
||||
if (!result.hasErrors) {
|
||||
if (result.nothingUploaded) {
|
||||
val msgKey = if (result.skipped > 0) "upload_prepare_failed" else "upload_nothing_pending"
|
||||
MaterialAlertDialogBuilder(activity)
|
||||
.setTitle(td("upload_failed_title", td("error", "Upload")))
|
||||
.setMessage(
|
||||
td(
|
||||
msgKey,
|
||||
"No questionnaires were uploaded. Complete a questionnaire first, then try again.",
|
||||
),
|
||||
)
|
||||
.setPositiveButton(td("ok", "OK")) { d, _ ->
|
||||
d.dismiss()
|
||||
openOverviewScreen()
|
||||
}
|
||||
.show()
|
||||
return
|
||||
}
|
||||
|
||||
val msg = buildUploadSuccessMessage(result) +
|
||||
"\n\n" +
|
||||
td("upload_success_sync_next", "upload_success_sync_next")
|
||||
MaterialAlertDialogBuilder(activity)
|
||||
.setTitle(td("upload_success_title", "upload_success_title"))
|
||||
.setMessage(msg)
|
||||
.setPositiveButton(td("ok", "OK")) { d, _ ->
|
||||
d.dismiss()
|
||||
uploadedDuringSession = true
|
||||
closeOverview()
|
||||
}
|
||||
.show()
|
||||
return
|
||||
}
|
||||
|
||||
val sb = StringBuilder()
|
||||
if (result.submitted > 0) {
|
||||
sb.appendLine("✓ ${result.submitted} ${td("done", "submitted")}")
|
||||
sb.appendLine()
|
||||
}
|
||||
sb.appendLine("✗ ${result.errors.size} ${td("error", "failed")}:")
|
||||
result.errors.forEach { err ->
|
||||
val name = when {
|
||||
err.questionnaireId.isNotBlank() -> err.questionnaireId
|
||||
err.profileName.isNotBlank() -> err.profileName
|
||||
err.profileID.isNotBlank() -> err.profileID
|
||||
else -> err.clientCode
|
||||
}
|
||||
sb.appendLine()
|
||||
sb.append("• $name")
|
||||
if (err.clientCode.isNotBlank() && name != err.clientCode) {
|
||||
sb.append(" (${err.clientCode})")
|
||||
}
|
||||
sb.appendLine()
|
||||
sb.append(" ")
|
||||
sb.appendLine(friendlyErrorMessage(err))
|
||||
}
|
||||
|
||||
MaterialAlertDialogBuilder(activity)
|
||||
.setTitle(
|
||||
if (result.allFailed) "✗ ${td("error", "Upload failed")}"
|
||||
else "⚠ ${td("error", "Partial upload")}",
|
||||
)
|
||||
.setMessage(sb.toString().trimEnd())
|
||||
.setPositiveButton(td("ok", "OK")) { d, _ ->
|
||||
d.dismiss()
|
||||
openOverviewScreen()
|
||||
}
|
||||
.setNegativeButton(td("cancel", "Back")) { d, _ ->
|
||||
d.dismiss()
|
||||
closeOverview()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun friendlyErrorMessage(err: DatabaseUploader.UploadError): String {
|
||||
return when (err.serverCode) {
|
||||
"VALIDATION_FAILED" -> err.message.ifBlank {
|
||||
td("error_invalid_data", "Some answers are missing or invalid.")
|
||||
}
|
||||
"NOT_FOUND" -> td("error_not_found", "Client or questionnaire not found on server.")
|
||||
"MISSING_FIELDS", "INVALID_BODY", "INVALID_FIELD" ->
|
||||
td("error_invalid_data", "The data could not be accepted by the server.")
|
||||
else -> when {
|
||||
err.message.contains("401") || err.message.contains("403") ->
|
||||
td("login_required", "Session expired. Please log in again.")
|
||||
err.message.contains("UnknownHost") || err.message.contains("connect") ->
|
||||
td("offline", "No internet connection.")
|
||||
else -> err.message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun td(key: String, fallback: String): String =
|
||||
UiStrings.tDisplay(uploadLanguageId, key, fallback)
|
||||
|
||||
private fun setListLoading(overlay: View, loading: Boolean, vararg content: View) {
|
||||
ScreenLoading.setVisible(overlay, loading, *content)
|
||||
}
|
||||
|
||||
private fun buildUploadSuccessMessage(result: DatabaseUploader.UploadResult): String {
|
||||
val parts = mutableListOf<String>()
|
||||
if (result.submittedQuestionnaires > 0) {
|
||||
val t = td("upload_success_questionnaires", "{count} questionnaire(s)")
|
||||
parts += t.replace("{count}", result.submittedQuestionnaires.toString())
|
||||
}
|
||||
if (result.submittedScoringReviews > 0) {
|
||||
val t = td("upload_success_scoring_reviews", "{count} score review(s)")
|
||||
parts += t.replace("{count}", result.submittedScoringReviews.toString())
|
||||
}
|
||||
val base = if (parts.isEmpty()) {
|
||||
td("upload_success_message", "Upload complete.")
|
||||
} else {
|
||||
parts.joinToString(" · ")
|
||||
}
|
||||
return if (result.revisionNotices.isEmpty()) {
|
||||
base
|
||||
} else {
|
||||
base + "\n\n" + result.revisionNotices.joinToString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
private class UploadListAdapter(
|
||||
private val onRowClick: (PendingRow) -> Unit,
|
||||
private val clientHeaderText: (String) -> String,
|
||||
private val summaryForCount: (Int) -> String,
|
||||
private val scoringSummaryFor: (PendingScoringRow) -> String,
|
||||
private val scoringTitleFor: (PendingScoringRow) -> String,
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private var items: List<ListItem> = emptyList()
|
||||
|
||||
fun submit(list: List<ListItem>) {
|
||||
items = list
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int = when (items[position]) {
|
||||
is ListItem.ClientHeader -> 0
|
||||
is ListItem.Questionnaire -> 1
|
||||
is ListItem.ScoringReview -> 2
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
return when (viewType) {
|
||||
0 -> UploadHeaderHolder(inflater.inflate(R.layout.item_upload_client_header, parent, false))
|
||||
else -> UploadCardHolder(inflater.inflate(R.layout.item_upload_questionnaire, parent, false))
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (val item = items[position]) {
|
||||
is ListItem.ClientHeader -> {
|
||||
val h = holder as UploadHeaderHolder
|
||||
h.text.text = clientHeaderText(item.clientCode)
|
||||
}
|
||||
is ListItem.Questionnaire -> {
|
||||
val h = holder as UploadCardHolder
|
||||
val row = item.row
|
||||
h.title.text = row.questionnaireName
|
||||
h.summary.text = summaryForCount(row.answeredCount)
|
||||
h.chevron.visibility = View.VISIBLE
|
||||
h.itemView.isClickable = true
|
||||
h.itemView.setOnClickListener { onRowClick(row) }
|
||||
}
|
||||
is ListItem.ScoringReview -> {
|
||||
val h = holder as UploadCardHolder
|
||||
val row = item.row
|
||||
h.title.text = scoringTitleFor(row)
|
||||
h.summary.text = scoringSummaryFor(row)
|
||||
h.chevron.visibility = View.GONE
|
||||
h.itemView.isClickable = false
|
||||
h.itemView.setOnClickListener(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
data class QaCard(val index: Int, val questionText: String, val answerText: String)
|
||||
|
||||
private class QaListAdapter : RecyclerView.Adapter<QaAnswerHolder>() {
|
||||
private var cards: List<QaCard> = emptyList()
|
||||
|
||||
fun submit(list: List<QaCard>) {
|
||||
cards = list
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QaAnswerHolder {
|
||||
val v = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_qa_answer_card, parent, false)
|
||||
return QaAnswerHolder(v)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = cards.size
|
||||
|
||||
override fun onBindViewHolder(holder: QaAnswerHolder, position: Int) {
|
||||
val card = cards[position]
|
||||
ViewUtils.styleQuestionTitle(holder.question)
|
||||
ViewUtils.styleQuestionValue(holder.answer)
|
||||
holder.question.text = "${card.index}. ${card.questionText}"
|
||||
holder.answer.text = card.answerText
|
||||
}
|
||||
}
|
||||
|
||||
private class UploadHeaderHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val text: TextView = view.findViewById(R.id.clientHeaderText)
|
||||
}
|
||||
|
||||
private class UploadCardHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val title: TextView = view.findViewById(R.id.uploadCardTitle)
|
||||
val summary: TextView = view.findViewById(R.id.uploadCardSummary)
|
||||
val chevron: View = view.findViewById(R.id.uploadCardChevron)
|
||||
}
|
||||
|
||||
private class QaAnswerHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val question: TextView = view.findViewById(R.id.qaQuestionLabel)
|
||||
val answer: TextView = view.findViewById(R.id.qaAnswerValue)
|
||||
}
|
||||
|
||||
private fun buildSummaryString(answeredCount: Int): String {
|
||||
val doneLabel = td("done", "Completed")
|
||||
val questionsLabel = td("questions_filled", "answers")
|
||||
return "✓ $doneLabel · $answeredCount $questionsLabel"
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T : View> requireView(id: Int): T {
|
||||
val v = activity.findViewById<T>(id)
|
||||
if (v == null) {
|
||||
val msg = "Missing view: $id"
|
||||
Log.e(tag, msg)
|
||||
throw IllegalStateException(msg)
|
||||
}
|
||||
return v
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,87 @@
|
||||
package com.dano.test1.ui.localization
|
||||
|
||||
import com.dano.test1.LanguageManager
|
||||
|
||||
/**
|
||||
* Shared UI string helpers for upload overview and activities.
|
||||
*/
|
||||
object UiStrings {
|
||||
|
||||
fun t(languageId: String, key: String): String =
|
||||
LanguageManager.getText(languageId, key)
|
||||
|
||||
/**
|
||||
* Resolves UI text for display: selected language, then German API fallback, then [fallback].
|
||||
* Avoids showing raw translation keys when possible.
|
||||
*/
|
||||
fun tDisplay(languageId: String, key: String, fallback: String): String {
|
||||
tOrNull(languageId, key)?.let { return it }
|
||||
tOrNull("GERMAN", key)?.let { return it }
|
||||
val raw = t(languageId, key)
|
||||
val cleaned = stripBrackets(raw).trim()
|
||||
return if (cleaned.equals(key, ignoreCase = true) || cleaned.isBlank()) fallback else cleaned
|
||||
}
|
||||
|
||||
fun questionnaireTitleKey(questionnaireId: String): String =
|
||||
questionnaireId
|
||||
.substringAfter("questionnaire_")
|
||||
.substringAfter("_")
|
||||
.removeSuffix(".json")
|
||||
.ifBlank { questionnaireId }
|
||||
|
||||
fun questionnaireDisplayName(
|
||||
languageId: String,
|
||||
questionnaireId: String,
|
||||
serverName: String?,
|
||||
): String {
|
||||
serverName?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
val key = questionnaireTitleKey(questionnaireId)
|
||||
return tDisplay(languageId, key, questionnaireId)
|
||||
}
|
||||
|
||||
/** Like [t] but returns null when translation is missing or equals the key. */
|
||||
fun tOrNull(languageId: String, key: String): String? {
|
||||
val txt = try {
|
||||
LanguageManager.getText(languageId, key)
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
val out = stripBrackets(txt).trim()
|
||||
return if (out.equals(key, ignoreCase = true) || out.isBlank()) null else out
|
||||
}
|
||||
|
||||
fun stripBrackets(s: String): String {
|
||||
val m = Regex("^\\[(.*)]$").matchEntire(s.trim())
|
||||
return m?.groupValues?.get(1) ?: s
|
||||
}
|
||||
|
||||
fun localizeQuestionLabel(
|
||||
languageId: String,
|
||||
questionId: String,
|
||||
fallbackQuestionText: String?,
|
||||
): String {
|
||||
val field = questionId.substringAfterLast('-', questionId)
|
||||
tOrNull(languageId, field)?.let { return it }
|
||||
tOrNull(languageId, questionId)?.let { return it }
|
||||
fallbackQuestionText?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
return field.replace('_', ' ').replaceFirstChar { it.titlecase() }
|
||||
}
|
||||
|
||||
fun localizeAnswerValue(languageId: String, fieldId: String, raw: String): String {
|
||||
if (raw == "—") return raw
|
||||
if (raw.matches(Regex("^\\d{1,4}([./-]\\d{1,2}([./-]\\d{1,4})?)?$"))) return raw
|
||||
tOrNull(languageId, raw)?.let { return it }
|
||||
val norm = raw.lowercase().replace(Regex("[^a-z0-9]+"), "_").trim('_')
|
||||
if (norm.isNotBlank()) tOrNull(languageId, norm)?.let { return it }
|
||||
if (norm.isNotBlank()) {
|
||||
tOrNull(languageId, "${fieldId}_$norm")?.let { return it }
|
||||
tOrNull(languageId, "${fieldId}-$norm")?.let { return it }
|
||||
}
|
||||
return stripBrackets(raw)
|
||||
}
|
||||
|
||||
fun extractQuestionnaireNumber(id: String): Int? {
|
||||
val m = Regex("^questionnaire_(\\d+)").find(id.lowercase())
|
||||
return m?.groupValues?.get(1)?.toIntOrNull()
|
||||
}
|
||||
}
|
||||
69
app/src/main/java/com/dano/test1/utils/DateAnswerFormat.kt
Normal file
69
app/src/main/java/com/dano/test1/utils/DateAnswerFormat.kt
Normal file
@ -0,0 +1,69 @@
|
||||
package com.dano.test1.utils
|
||||
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
|
||||
/** Supported storage formats: YYYY, YYYY-MM, YYYY-MM-DD */
|
||||
enum class DatePrecision(val wire: String) {
|
||||
YEAR("year"),
|
||||
YEAR_MONTH("year_month"),
|
||||
FULL("full");
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: String?): DatePrecision = when (value?.lowercase(Locale.ROOT)) {
|
||||
YEAR.wire -> YEAR
|
||||
YEAR_MONTH.wire -> YEAR_MONTH
|
||||
else -> FULL
|
||||
}
|
||||
|
||||
fun detectStored(value: String?): DatePrecision {
|
||||
if (value.isNullOrBlank()) return FULL
|
||||
return when (value.count { it == '-' }) {
|
||||
0 -> YEAR
|
||||
1 -> YEAR_MONTH
|
||||
else -> FULL
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object DateAnswerFormat {
|
||||
|
||||
data class Parsed(val year: Int, val month: Int = 1, val day: Int = 1)
|
||||
|
||||
fun parseStored(value: String?): Parsed? {
|
||||
if (value.isNullOrBlank()) return null
|
||||
val parts = value.trim().split("-")
|
||||
val year = parts.getOrNull(0)?.toIntOrNull() ?: return null
|
||||
val month = parts.getOrNull(1)?.toIntOrNull() ?: 1
|
||||
val day = parts.getOrNull(2)?.toIntOrNull() ?: 1
|
||||
return Parsed(year, month.coerceIn(1, 12), day.coerceIn(1, 31))
|
||||
}
|
||||
|
||||
fun format(precision: DatePrecision, year: Int, month: Int, day: Int): String = when (precision) {
|
||||
DatePrecision.YEAR -> year.toString()
|
||||
DatePrecision.YEAR_MONTH -> "%04d-%02d".format(year, month)
|
||||
DatePrecision.FULL -> "%04d-%02d-%02d".format(year, month, day)
|
||||
}
|
||||
|
||||
/** Start of answer period as YYYYMMDD int for comparisons. */
|
||||
fun periodStart(value: String): Int {
|
||||
val p = parseStored(value) ?: return 0
|
||||
return p.year * 10000 + p.month * 100 + p.day
|
||||
}
|
||||
|
||||
/** End of answer period as YYYYMMDD int for comparisons. */
|
||||
fun periodEnd(value: String): Int {
|
||||
val p = parseStored(value) ?: return 0
|
||||
return when (DatePrecision.detectStored(value)) {
|
||||
DatePrecision.YEAR -> p.year * 10000 + 1231
|
||||
DatePrecision.YEAR_MONTH -> {
|
||||
val cal = Calendar.getInstance()
|
||||
cal.set(p.year, p.month - 1, 1)
|
||||
val last = cal.getActualMaximum(Calendar.DAY_OF_MONTH)
|
||||
p.year * 10000 + p.month * 100 + last
|
||||
}
|
||||
DatePrecision.FULL -> p.year * 10000 + p.month * 100 + p.day
|
||||
}
|
||||
}
|
||||
}
|
||||
54
app/src/main/java/com/dano/test1/utils/LanguageManager.kt
Normal file
54
app/src/main/java/com/dano/test1/utils/LanguageManager.kt
Normal file
@ -0,0 +1,54 @@
|
||||
package com.dano.test1
|
||||
|
||||
/**
|
||||
* Resolves UI strings from API translations (synced from the website).
|
||||
* No embedded translation tables — configure strings in the admin Translations UI.
|
||||
*/
|
||||
object LanguageManager {
|
||||
/** Global app UI strings from GET ?translations=1 */
|
||||
private var apiTranslations: Map<String, Map<String, String>> = emptyMap()
|
||||
/** Questionnaire-specific strings from GET ?id=... */
|
||||
private var questionnaireTranslations: Map<String, Map<String, String>> = emptyMap()
|
||||
|
||||
fun setApiTranslations(translations: Map<String, Map<String, String>>) {
|
||||
apiTranslations = translations
|
||||
}
|
||||
|
||||
fun setQuestionnaireTranslations(translations: Map<String, Map<String, String>>) {
|
||||
questionnaireTranslations = translations
|
||||
}
|
||||
|
||||
fun clearQuestionnaireTranslations() {
|
||||
questionnaireTranslations = emptyMap()
|
||||
}
|
||||
|
||||
fun getTextFormatted(languageId: String, key: String, vararg args: Any): String {
|
||||
val raw = getText(languageId, key)
|
||||
return String.format(raw, *args)
|
||||
}
|
||||
|
||||
fun getText(languageCode: String, id: String): String {
|
||||
val lang = apiLanguageCode(languageCode)
|
||||
val raw = questionnaireTranslations[lang]?.get(id)
|
||||
?: apiTranslations[lang]?.get(id)
|
||||
?: apiTranslations["de"]?.get(id)
|
||||
?: id
|
||||
return injectDynamicValues(raw)
|
||||
}
|
||||
|
||||
private fun apiLanguageCode(languageCode: String): String = when (languageCode) {
|
||||
"GERMAN" -> "de"
|
||||
"ENGLISH" -> "en"
|
||||
"FRENCH" -> "fr"
|
||||
"ROMANIAN" -> "ro"
|
||||
"ARABIC" -> "ar"
|
||||
"POLISH" -> "pl"
|
||||
"TURKISH" -> "tr"
|
||||
"UKRAINIAN" -> "uk"
|
||||
"RUSSIAN" -> "ru"
|
||||
"SPANISH" -> "es"
|
||||
else -> languageCode.lowercase()
|
||||
}
|
||||
|
||||
private fun injectDynamicValues(text: String): String = text
|
||||
}
|
||||
113
app/src/main/java/com/dano/test1/utils/ScreenLoading.kt
Normal file
113
app/src/main/java/com/dano/test1/utils/ScreenLoading.kt
Normal file
@ -0,0 +1,113 @@
|
||||
package com.dano.test1.utils
|
||||
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import com.dano.test1.R
|
||||
import com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
|
||||
/**
|
||||
* Shows the branded loading overlay (view_screen_loading_overlay).
|
||||
* Child views are resolved within [overlay]'s subtree only (safe with multiple includes on one screen).
|
||||
*/
|
||||
object ScreenLoading {
|
||||
private const val TAG = "ScreenLoading"
|
||||
private const val SCRIM_FADE_MS = 200L
|
||||
private const val PANEL_SCALE_MS = 280L
|
||||
|
||||
private data class OverlayViews(
|
||||
val panel: View,
|
||||
val indicator: CircularProgressIndicator,
|
||||
)
|
||||
|
||||
fun setVisible(overlay: View?, loading: Boolean, vararg contentToDisable: View) {
|
||||
if (overlay == null) return
|
||||
val views = resolveViews(overlay)
|
||||
if (views == null) {
|
||||
Log.e(TAG, "loadingPanel/loadingIndicator missing under overlay id=${overlay.id}")
|
||||
overlay.visibility = if (loading) View.VISIBLE else View.GONE
|
||||
for (view in contentToDisable) {
|
||||
view.isEnabled = !loading
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
overlay.animate().cancel()
|
||||
views.panel.animate().cancel()
|
||||
|
||||
for (view in contentToDisable) {
|
||||
view.isEnabled = !loading
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
show(overlay, views)
|
||||
} else {
|
||||
hide(overlay, views)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveViews(overlay: View): OverlayViews? {
|
||||
val panel = findDescendant(overlay, R.id.loadingPanel) ?: return null
|
||||
val indicator = findDescendant(overlay, R.id.loadingIndicator) as? CircularProgressIndicator
|
||||
?: return null
|
||||
return OverlayViews(panel, indicator)
|
||||
}
|
||||
|
||||
private fun findDescendant(root: View, id: Int): View? {
|
||||
if (root.id == id) return root
|
||||
if (root is ViewGroup) {
|
||||
for (i in 0 until root.childCount) {
|
||||
findDescendant(root.getChildAt(i), id)?.let { return it }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun show(overlay: View, views: OverlayViews) {
|
||||
views.panel.visibility = View.VISIBLE
|
||||
views.panel.alpha = 1f
|
||||
views.panel.scaleX = 0.9f
|
||||
views.panel.scaleY = 0.9f
|
||||
views.indicator.visibility = View.VISIBLE
|
||||
|
||||
overlay.visibility = View.VISIBLE
|
||||
overlay.bringToFront()
|
||||
|
||||
overlay.post {
|
||||
if (overlay.visibility != View.VISIBLE) return@post
|
||||
views.indicator.show()
|
||||
views.panel.animate()
|
||||
.scaleX(1f)
|
||||
.scaleY(1f)
|
||||
.setDuration(PANEL_SCALE_MS)
|
||||
.setInterpolator(DecelerateInterpolator())
|
||||
.start()
|
||||
|
||||
if (overlay.alpha < 0.99f) {
|
||||
overlay.alpha = 0f
|
||||
overlay.animate()
|
||||
.alpha(1f)
|
||||
.setDuration(SCRIM_FADE_MS)
|
||||
.setInterpolator(DecelerateInterpolator())
|
||||
.start()
|
||||
} else {
|
||||
overlay.alpha = 1f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hide(overlay: View, views: OverlayViews) {
|
||||
views.indicator.hide()
|
||||
overlay.animate()
|
||||
.alpha(0f)
|
||||
.setDuration(SCRIM_FADE_MS)
|
||||
.withEndAction {
|
||||
overlay.visibility = View.GONE
|
||||
overlay.alpha = 1f
|
||||
views.panel.scaleX = 1f
|
||||
views.panel.scaleY = 1f
|
||||
}
|
||||
.start()
|
||||
}
|
||||
}
|
||||
20
app/src/main/java/com/dano/test1/utils/SyncPhaseIcons.kt
Normal file
20
app/src/main/java/com/dano/test1/utils/SyncPhaseIcons.kt
Normal file
@ -0,0 +1,20 @@
|
||||
package com.dano.test1.utils
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import com.dano.test1.R
|
||||
import com.dano.test1.network.SyncPhase
|
||||
|
||||
/** Flat Material-style icons per sync phase — no emoji or decorative chrome. */
|
||||
object SyncPhaseIcons {
|
||||
|
||||
@DrawableRes
|
||||
fun iconRes(phase: SyncPhase): Int = when (phase) {
|
||||
SyncPhase.PREPARING -> R.drawable.ic_sync_phase_prepare
|
||||
SyncPhase.DOWNLOADING_QUESTIONNAIRES -> R.drawable.ic_sync_phase_questionnaires
|
||||
SyncPhase.DOWNLOADING_CLIENT_INFO -> R.drawable.ic_sync_phase_clients
|
||||
SyncPhase.DOWNLOADING_ANSWERS -> R.drawable.ic_sync_phase_answers
|
||||
SyncPhase.PROCESSING -> R.drawable.ic_sync_phase_processing
|
||||
SyncPhase.SECURELY_SAVING -> R.drawable.ic_sync_phase_saving
|
||||
SyncPhase.FINISHED -> R.drawable.ic_sync_complete
|
||||
}
|
||||
}
|
||||
219
app/src/main/java/com/dano/test1/utils/SyncProgressOverlay.kt
Normal file
219
app/src/main/java/com/dano/test1/utils/SyncProgressOverlay.kt
Normal file
@ -0,0 +1,219 @@
|
||||
package com.dano.test1.utils
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.AnimationUtils
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.widget.ImageViewCompat
|
||||
import com.dano.test1.R
|
||||
import com.dano.test1.network.SyncPhase
|
||||
import com.dano.test1.network.SyncProgress
|
||||
import com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
|
||||
/**
|
||||
* Full-screen sync overlay with phase-based status text (no counts) and restrained motion.
|
||||
*/
|
||||
class SyncProgressOverlay(
|
||||
private val activity: Activity,
|
||||
initialLocale: SyncProgressTexts.Locale = SyncProgressTexts.Locale.GERMAN,
|
||||
) {
|
||||
|
||||
private var locale: SyncProgressTexts.Locale = initialLocale
|
||||
private var root: View? = null
|
||||
private var panel: View? = null
|
||||
private var titleView: TextView? = null
|
||||
private var statusView: TextView? = null
|
||||
private var subtitleView: TextView? = null
|
||||
private var phaseIcon: ImageView? = null
|
||||
private var bar: LinearProgressIndicator? = null
|
||||
private var checkpointList: LinearLayout? = null
|
||||
private val checkpoints = mutableListOf<CheckpointViews>()
|
||||
private var lastProgress: SyncProgress? = null
|
||||
private var hideRunnable: Runnable? = null
|
||||
|
||||
private val checkpointPhases = listOf(
|
||||
SyncPhase.PREPARING,
|
||||
SyncPhase.DOWNLOADING_QUESTIONNAIRES,
|
||||
SyncPhase.DOWNLOADING_CLIENT_INFO,
|
||||
SyncPhase.DOWNLOADING_ANSWERS,
|
||||
SyncPhase.PROCESSING,
|
||||
SyncPhase.SECURELY_SAVING,
|
||||
SyncPhase.FINISHED,
|
||||
)
|
||||
|
||||
private data class CheckpointViews(
|
||||
val phase: SyncPhase,
|
||||
val dot: View,
|
||||
)
|
||||
|
||||
fun setLocale(locale: SyncProgressTexts.Locale) {
|
||||
this.locale = locale
|
||||
applyTexts()
|
||||
}
|
||||
|
||||
fun show() {
|
||||
ensureInflated()
|
||||
hideRunnable?.let { root?.removeCallbacks(it) }
|
||||
hideRunnable = null
|
||||
applyTexts()
|
||||
root?.bringToFront()
|
||||
root?.visibility = View.VISIBLE
|
||||
root?.animate()?.cancel()
|
||||
root?.alpha = 0f
|
||||
root?.animate()
|
||||
?.alpha(1f)
|
||||
?.setDuration(240L)
|
||||
?.start()
|
||||
panel?.let { card ->
|
||||
card.clearAnimation()
|
||||
card.startAnimation(AnimationUtils.loadAnimation(activity, R.anim.sync_overlay_enter))
|
||||
}
|
||||
applyProgressState(lastProgress ?: SyncProgress(SyncPhase.PREPARING))
|
||||
}
|
||||
|
||||
fun update(progress: SyncProgress) {
|
||||
val phaseChanged = lastProgress == null || lastProgress?.phase != progress.phase
|
||||
lastProgress = progress
|
||||
ensureInflated()
|
||||
applyTexts(animate = phaseChanged)
|
||||
applyProgressState(progress, animateIcon = phaseChanged)
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
bar?.hide()
|
||||
val overlay = root ?: return
|
||||
hideRunnable?.let { overlay.removeCallbacks(it) }
|
||||
overlay.animate().cancel()
|
||||
panel?.clearAnimation()
|
||||
overlay.animate()
|
||||
.alpha(0f)
|
||||
.setDuration(220L)
|
||||
.withEndAction {
|
||||
if (!overlay.isVisible) return@withEndAction
|
||||
overlay.visibility = View.GONE
|
||||
overlay.alpha = 1f
|
||||
}
|
||||
.start()
|
||||
hideRunnable = Runnable {
|
||||
overlay.visibility = View.GONE
|
||||
overlay.alpha = 1f
|
||||
}.also { overlay.postDelayed(it, 260L) }
|
||||
lastProgress = null
|
||||
}
|
||||
|
||||
private fun applyTexts(animate: Boolean = false) {
|
||||
titleView?.text = SyncProgressTexts.title(locale)
|
||||
val progress = lastProgress ?: SyncProgress(SyncPhase.PREPARING)
|
||||
statusView?.text = SyncProgressTexts.status(progress.phase, locale)
|
||||
subtitleView?.text = SyncProgressTexts.subtitle(progress.phase, locale)
|
||||
if (animate) {
|
||||
val fade = AnimationUtils.loadAnimation(activity, R.anim.sync_text_fade)
|
||||
statusView?.startAnimation(fade)
|
||||
subtitleView?.startAnimation(fade)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyProgressState(progress: SyncProgress, animateIcon: Boolean = false) {
|
||||
val finished = progress.phase == SyncPhase.FINISHED
|
||||
phaseIcon?.setImageResource(SyncPhaseIcons.iconRes(progress.phase))
|
||||
val tint = ContextCompat.getColor(
|
||||
activity,
|
||||
if (finished) R.color.brand_status_online else R.color.brand_purple,
|
||||
)
|
||||
phaseIcon?.let { ImageViewCompat.setImageTintList(it, android.content.res.ColorStateList.valueOf(tint)) }
|
||||
if (animateIcon) {
|
||||
phaseIcon?.startAnimation(AnimationUtils.loadAnimation(activity, R.anim.sync_text_fade))
|
||||
}
|
||||
bar?.visibility = View.VISIBLE
|
||||
bar?.show()
|
||||
updateCheckpoints(progress.phase)
|
||||
}
|
||||
|
||||
private fun ensureInflated() {
|
||||
if (root != null) return
|
||||
val decor = activity.window.decorView as? ViewGroup ?: return
|
||||
val overlay = LayoutInflater.from(activity)
|
||||
.inflate(R.layout.view_sync_progress_overlay, decor, false)
|
||||
decor.addView(
|
||||
overlay,
|
||||
ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
),
|
||||
)
|
||||
root = overlay
|
||||
panel = overlay.findViewById(R.id.syncProgressPanel)
|
||||
titleView = overlay.findViewById(R.id.syncProgressTitle)
|
||||
statusView = overlay.findViewById(R.id.syncProgressStatus)
|
||||
subtitleView = overlay.findViewById(R.id.syncProgressSubtitle)
|
||||
phaseIcon = overlay.findViewById(R.id.syncProgressPhaseIcon)
|
||||
bar = overlay.findViewById(R.id.syncProgressBar)
|
||||
checkpointList = overlay.findViewById(R.id.syncCheckpointList)
|
||||
buildCheckpointList()
|
||||
bar?.max = checkpointPhases.lastIndex
|
||||
}
|
||||
|
||||
private fun buildCheckpointList() {
|
||||
val list = checkpointList ?: return
|
||||
if (checkpoints.isNotEmpty()) return
|
||||
list.removeAllViews()
|
||||
|
||||
checkpointPhases.forEachIndexed { index, phase ->
|
||||
val cell = android.widget.FrameLayout(activity).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
0,
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
1f,
|
||||
)
|
||||
}
|
||||
val dot = View(activity).apply {
|
||||
layoutParams = android.widget.FrameLayout.LayoutParams(dp(14), dp(14)).apply {
|
||||
gravity = android.view.Gravity.CENTER
|
||||
}
|
||||
}
|
||||
cell.addView(dot)
|
||||
list.addView(cell)
|
||||
checkpoints += CheckpointViews(phase, dot)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCheckpoints(currentPhase: SyncPhase) {
|
||||
if (checkpoints.isEmpty()) return
|
||||
val currentIndex = checkpointPhases.indexOf(currentPhase).coerceAtLeast(0)
|
||||
val completedColor = ContextCompat.getColor(activity, R.color.brand_status_online)
|
||||
val activeColor = ContextCompat.getColor(activity, R.color.brand_purple)
|
||||
val pendingColor = ContextCompat.getColor(activity, R.color.brand_surface)
|
||||
val pendingStroke = ContextCompat.getColor(activity, R.color.brand_loading_track)
|
||||
bar?.setProgressCompat(currentIndex, true)
|
||||
|
||||
checkpoints.forEachIndexed { index, checkpoint ->
|
||||
val isComplete = index < currentIndex || currentPhase == SyncPhase.FINISHED
|
||||
val isActive = index == currentIndex && currentPhase != SyncPhase.FINISHED
|
||||
checkpoint.dot.background = circle(
|
||||
fillColor = when {
|
||||
isComplete -> completedColor
|
||||
isActive -> activeColor
|
||||
else -> pendingColor
|
||||
},
|
||||
strokeColor = if (isComplete || isActive) activeColor else pendingStroke,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun circle(fillColor: Int, strokeColor: Int): GradientDrawable =
|
||||
GradientDrawable().apply {
|
||||
shape = GradientDrawable.OVAL
|
||||
setColor(fillColor)
|
||||
setStroke(dp(2), strokeColor)
|
||||
}
|
||||
|
||||
private fun dp(value: Int): Int =
|
||||
(value * activity.resources.displayMetrics.density).toInt()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user