initial copy from nat-as app

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

1
app/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

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

@ -0,0 +1,89 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
id("kotlin-kapt")
}
android {
namespace = "com.dano.test1"
compileSdk = 35
defaultConfig {
applicationId = "com.dano.test1"
minSdk = 29
targetSdk = 35
versionCode = 1
versionName = "1.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildFeatures {
buildConfig = true
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
// Apache POI bringt viele META-INF-Dateien mit hier aus dem APK ausschließen
packaging {
resources {
excludes += listOf(
"META-INF/DEPENDENCIES",
"META-INF/LICENSE",
"META-INF/LICENSE.txt",
"META-INF/NOTICE",
"META-INF/NOTICE.txt",
"META-INF/NOTICE.md",
"META-INF/LICENSE.md",
"META-INF/*.kotlin_module",
"META-INF/versions/**"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
}
val room_version = "2.6.1"
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.activity)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.recyclerview)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
implementation("com.google.code.gson:gson:2.10.1")
implementation("androidx.room:room-runtime:$room_version")
kapt("androidx.room:room-compiler:$room_version")
implementation("androidx.room:room-ktx:$room_version")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
// Server Upload
implementation("com.squareup.okhttp3:okhttp:4.12.0")
}

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

@ -0,0 +1,21 @@
# Gson models used for API and on-disk cache deserialization.
-keep class com.dano.test1.network.** { *; }
-keep class com.dano.test1.data.** { *; }
-keep class com.dano.test1.questionnaire.** { *; }
# Room
-keep class * extends androidx.room.RoomDatabase
-keep @androidx.room.Entity class *
-dontwarn androidx.room.paging.**
# AndroidX Security crypto (EncryptedSharedPreferences / MasterKey)
-keep class androidx.security.crypto.** { *; }
# Gson reflective serialization
-keepattributes Signature
-keepattributes *Annotation*
-dontwarn sun.misc.**
# Keep line numbers for crash reports.
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,37 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.dano.test1",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 1,
"versionName": "1.3",
"outputFile": "app-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/app-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/app-release.dm"
]
}
],
"minSdkVersionForDexing": 29
}

View File

@ -0,0 +1,24 @@
package com.dano.test1
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.dano.test1", appContext.packageName)
}
}

View File

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

View File

@ -0,0 +1,506 @@
package com.dano.test1
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import android.os.Bundle
import android.content.Intent
import androidx.activity.OnBackPressedCallback
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.dano.test1.LanguageManager
import com.dano.test1.ui.AppLanguageStore
import com.dano.test1.ui.NotificationPermissionPrompter
import com.dano.test1.ui.AuthActivity
import com.dano.test1.ui.HandlerOpeningScreen
import com.dano.test1.ui.LogoutDialogs
import com.dano.test1.ui.SessionLogout
import com.dano.test1.ui.SystemBarInsets
import com.dano.test1.utils.SyncProgressOverlay
import com.dano.test1.utils.SyncProgressTexts
import com.dano.test1.utils.ViewUtils
import com.dano.test1.data.PendingUploadRepository
import com.dano.test1.network.NetworkUtils
import com.dano.test1.network.QuestionnaireApiClient
import com.dano.test1.network.SyncLog
import com.dano.test1.network.QuestionnaireCache
import com.dano.test1.network.SyncCoordinator
import com.dano.test1.network.SyncProgress
import com.dano.test1.network.SyncResult
import com.dano.test1.network.TokenStore
import com.dano.test1.questionnaire.QuestionnaireBase
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainActivity : AppCompatActivity() {
private companion object {
private const val STARTUP_SYNC_PREFS = "startup_sync_state"
private const val KEY_LAST_STARTUP_SYNC_MS = "last_startup_sync_ms"
private const val STARTUP_SYNC_INTERVAL_MS = 30L * 60L * 1000L
}
private lateinit var openingScreenHandler: HandlerOpeningScreen
var isInQuestionnaire: Boolean = false
var isFirstQuestionnairePage: Boolean = false
/** Set before opening DevSettings so onResume runs a full opening-screen rebuild. */
private var needsOpeningReinit: Boolean = false
private var assignedClientsRefreshGeneration: Long = 0L
fun markOpeningScreenReinit() {
needsOpeningReinit = true
}
fun bumpAssignedClientsRefreshGeneration() {
assignedClientsRefreshGeneration++
}
private var syncProgressOverlay: SyncProgressOverlay? = null
private var backPressInterceptor: (() -> Boolean)? = null
private var notificationPromptRequested = false
private val requestNotificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* no-op */ }
private val authLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode != RESULT_OK) {
finishAffinity()
return@registerForActivityResult
}
val token = result.data?.getStringExtra(AuthActivity.EXTRA_TOKEN)
if (token.isNullOrBlank()) {
finishAffinity()
return@registerForActivityResult
}
proceedAfterLogin(token)
}
// LIVE: Network-Callback (optional für Statusleiste)
private var netCb: ConnectivityManager.NetworkCallback? = null
private val bootLanguageId: String get() = AppLanguageStore.get(this)
private fun t(key: String): String = LanguageManager.getText(bootLanguageId, key)
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
when {
isInQuestionnaire -> confirmExitQuestionnaire()
backPressInterceptor?.invoke() == true -> Unit
::openingScreenHandler.isInitialized && openingScreenHandler.onBackPressed() -> Unit
else -> {
isEnabled = false
onBackPressedDispatcher.onBackPressed()
}
}
}
})
TokenStore.ensureLegacyLoginTimestamp(this)
setContentView(R.layout.activity_startup_loading)
// === Offline-Start ermöglichen ===
// Bedingung: gespeicherter User/Token UND lokaler API-Cache vorhanden -> direkt OpeningScreen
val hasCreds = !TokenStore.getUsername(this).isNullOrBlank() && TokenStore.hasToken(this)
val hasCache = QuestionnaireCache.hasQuestionnaireCache(this)
SyncLog.d("MAIN", "onCreate: hasCreds=$hasCreds, hasCache=$hasCache")
SyncLog.d("MAIN", "onCreate: cached assigned clients count=${QuestionnaireCache.getAssignedClients(this).size}")
if (hasCreds && hasCache) {
validateStoredSessionThenOpen()
return
}
SyncCoordinator.loadCachedTranslations(this)
CoroutineScope(Dispatchers.Main).launch {
SyncCoordinator.refreshPublicTranslations(this@MainActivity)
}
// Sonst: Login-Screen -> Login -> Daten laden -> OpeningScreen
launchAuthScreen()
}
private fun validateStoredSessionThenOpen() {
val token = TokenStore.getToken(this)
if (token.isNullOrBlank() || !TokenStore.isSessionValid(this)) {
requestLogin()
return
}
if (!NetworkUtils.isOnline(this)) {
showCachedOpeningScreen(refreshAfterOpen = false)
return
}
CoroutineScope(Dispatchers.Main).launch {
val sessionCheck = withContext(Dispatchers.IO) {
runCatching { QuestionnaireApiClient.getSession(token) }
}
val session = sessionCheck.getOrNull()
val error = sessionCheck.exceptionOrNull()
if (session != null && session.valid && session.role == "coach" && !session.mustChangePassword) {
routeCachedStartup(token)
} else if (error != null && TokenStore.isAuthError(error)) {
requestLogin()
} else if (error == null) {
requestLogin()
} else {
// Network/server transient failure: keep offline-capable cached mode.
showCachedOpeningScreen(refreshAfterOpen = false)
}
}
}
private suspend fun routeCachedStartup(token: String) {
val hasPendingUploads = withContext(Dispatchers.IO) {
PendingUploadRepository.hasAny()
}
if (hasPendingUploads || !NetworkUtils.isOnline(this)) {
showCachedOpeningScreen(refreshAfterOpen = false, showUploadPrompt = hasPendingUploads)
return
}
if (!shouldRunStartupSync()) {
showCachedOpeningScreen(refreshAfterOpen = false)
return
}
showSyncProgressOverlay(bootLanguageId)
val syncResult = withContext(Dispatchers.IO) {
SyncCoordinator.refreshManual(
context = this@MainActivity,
token = token,
onProgress = { reportSyncProgress(it) },
)
}
hideSyncProgressOverlay()
when (syncResult) {
SyncResult.AuthRequired -> {
requestLogin()
return
}
else -> {
if (syncResult is SyncResult.Success) {
markSuccessfulSync()
}
showCachedOpeningScreen(refreshAfterOpen = false)
if (syncResult is SyncResult.Failure) {
SyncCoordinator.toastForResult(this, bootLanguageId, syncResult)
}
}
}
}
private fun shouldRunStartupSync(): Boolean {
val lastSyncAt = getSharedPreferences(STARTUP_SYNC_PREFS, Context.MODE_PRIVATE)
.getLong(KEY_LAST_STARTUP_SYNC_MS, 0L)
return lastSyncAt <= 0L ||
System.currentTimeMillis() - lastSyncAt >= STARTUP_SYNC_INTERVAL_MS
}
fun markSuccessfulSync() {
getSharedPreferences(STARTUP_SYNC_PREFS, Context.MODE_PRIVATE)
.edit()
.putLong(KEY_LAST_STARTUP_SYNC_MS, System.currentTimeMillis())
.apply()
}
private fun showCachedOpeningScreen(refreshAfterOpen: Boolean, showUploadPrompt: Boolean = false) {
SyncCoordinator.loadCachedTranslations(this)
openingScreenHandler = HandlerOpeningScreen(this)
openingScreenHandler.init()
if (showUploadPrompt) {
openingScreenHandler.runAfterFirstClientLoad {
openingScreenHandler.showStartupUploadPrompt()
}
}
if (refreshAfterOpen) {
openingScreenHandler.runAfterFirstClientLoad {
refreshAssignedClientsInBackground()
}
}
scheduleNotificationPermissionPrompt()
}
/** Refreshes assigned clients from the server when online (e.g. after app restart). */
private fun refreshAssignedClientsInBackground() {
val token = TokenStore.getToken(this) ?: return
if (!NetworkUtils.isOnline(this)) {
SyncLog.d("MAIN", "refreshAssignedClients skipped: offline")
return
}
val generation = assignedClientsRefreshGeneration
CoroutineScope(Dispatchers.IO).launch {
when (val result = SyncCoordinator.refreshClientsOnly(this@MainActivity, token)) {
SyncResult.AuthRequired -> {
withContext(Dispatchers.Main) { requestLogin() }
return@launch
}
SyncResult.Success -> {
withContext(Dispatchers.Main) {
if (generation != assignedClientsRefreshGeneration) return@withContext
if (::openingScreenHandler.isInitialized && !isInQuestionnaire) {
openingScreenHandler.onAssignedClientsSynced()
}
}
}
else -> {}
}
}
}
/**
* Clears all local data and shows the login screen (e.g. expired token).
* Does not check pending uploads — session is already invalid.
*/
fun requestLogin() {
isInQuestionnaire = false
CoroutineScope(Dispatchers.Main).launch {
val previousUser = TokenStore.getUsername(this@MainActivity)
withContext(Dispatchers.IO) { SessionLogout.wipeAllLocalData(this@MainActivity) }
launchAuthScreen(prefillUsername = previousUser)
}
}
/** User-initiated logout from settings; blocked when offline or unuploaded data remains. */
fun attemptLogout(languageId: String? = null) {
val lang = languageId ?: bootLanguageId
CoroutineScope(Dispatchers.Main).launch {
val block = withContext(Dispatchers.IO) { SessionLogout.evaluate(this@MainActivity) }
when (block) {
null -> LogoutDialogs.showConfirm(this@MainActivity, lang) {
CoroutineScope(Dispatchers.Main).launch {
withContext(Dispatchers.IO) { SessionLogout.wipeAllLocalData(this@MainActivity) }
isInQuestionnaire = false
launchAuthScreen()
}
}
SessionLogout.BlockReason.Offline -> LogoutDialogs.showBlocked(
activity = this@MainActivity,
languageId = lang,
title = SessionLogout.message(lang, "logout_offline_title", "Cannot log out"),
message = SessionLogout.message(
lang,
"logout_offline_message",
"You are offline. Connect to the internet, upload pending data, then try again.",
),
tone = LogoutDialogs.Tone.Info,
)
is SessionLogout.BlockReason.UnuploadedWork -> {
val details = SessionLogout.formatPendingDetails(lang, block.summary)
val bodyTemplate = SessionLogout.message(
lang,
"logout_pending_message",
"You still have data on this device that has not been uploaded.\n\n{details}\n\nPlease tap Upload and wait until it finishes.",
)
val message = bodyTemplate.replace("{details}", "").trim()
LogoutDialogs.showBlocked(
activity = this@MainActivity,
languageId = lang,
title = SessionLogout.message(lang, "logout_pending_title", "Cannot log out"),
message = message,
details = details,
tone = LogoutDialogs.Tone.Warning,
)
}
}
}
}
private fun launchAuthScreen(prefillUsername: String? = null) {
val intent = Intent(this, AuthActivity::class.java)
prefillUsername?.takeIf { it.isNotBlank() }?.let {
intent.putExtra(AuthActivity.EXTRA_PREFILL_USERNAME, it)
}
authLauncher.launch(intent)
}
fun showSyncProgressOverlay(languageId: String? = null) {
val locale = SyncProgressTexts.Locale.fromLanguageId(languageId ?: bootLanguageId)
ensureSyncProgressOverlay().setLocale(locale)
ensureSyncProgressOverlay().show()
}
fun hideSyncProgressOverlay() {
syncProgressOverlay?.hide()
}
fun reportSyncProgress(progress: SyncProgress) {
runOnUiThread { ensureSyncProgressOverlay().update(progress) }
}
private fun ensureSyncProgressOverlay(): SyncProgressOverlay {
if (syncProgressOverlay == null) {
syncProgressOverlay = SyncProgressOverlay(this)
}
return syncProgressOverlay!!
}
private fun proceedAfterLogin(token: String) {
SyncLog.d("MAIN", "Login success")
SyncLog.d("MAIN", "Assigned clients from cache after login count=${QuestionnaireCache.getAssignedClients(this).size}")
showSyncProgressOverlay(bootLanguageId)
CoroutineScope(Dispatchers.IO).launch {
val syncResult = SyncCoordinator.refreshAfterLogin(
context = this@MainActivity,
token = token,
languageId = bootLanguageId,
onProgress = { reportSyncProgress(it) },
)
withContext(Dispatchers.Main) {
hideSyncProgressOverlay()
when (syncResult) {
SyncResult.AuthRequired -> {
requestLogin()
return@withContext
}
is SyncResult.Failure -> {
SyncCoordinator.toastForResult(this@MainActivity, bootLanguageId, syncResult)
}
SyncResult.Success -> markSuccessfulSync()
else -> {}
}
openingScreenHandler = HandlerOpeningScreen(this@MainActivity)
openingScreenHandler.init()
openingScreenHandler.refreshHeaderStatusLive()
scheduleNotificationPermissionPrompt()
}
}
}
fun setBackPressInterceptor(interceptor: (() -> Boolean)?) {
backPressInterceptor = interceptor
}
private fun scheduleNotificationPermissionPrompt() {
if (notificationPromptRequested) return
notificationPromptRequested = true
window.decorView.post {
NotificationPermissionPrompter.showIfNeeded(this, requestNotificationPermission)
}
}
private fun confirmExitQuestionnaire() {
fun td(key: String, fallbackDe: String, fallbackEn: String): String {
val raw = LanguageManager.getText(bootLanguageId, key)
if (raw != key) return raw
return if (bootLanguageId == "GERMAN") fallbackDe else fallbackEn
}
MaterialAlertDialogBuilder(this)
.setTitle(td("questionnaire_exit_title", "Fragebogen verlassen?", "Leave questionnaire?"))
.setMessage(
td(
"questionnaire_exit_message",
"Nicht gespeicherte Eingaben in diesem Schritt können verloren gehen.",
"Unsaved answers on this screen may be lost.",
),
)
.setNegativeButton(td("cancel", "Abbrechen", "Cancel")) { d, _ -> d.dismiss() }
.setPositiveButton(td("leave", "Verlassen", "Leave")) { _, _ -> finishQuestionnaire() }
.show()
}
// --- LIVE NETZSTATUS (optional, für deine Status-Leiste) ---
override fun onResume() {
super.onResume()
registerNetworkCallback()
if (::openingScreenHandler.isInitialized && !isInQuestionnaire) {
if (needsOpeningReinit) {
needsOpeningReinit = false
openingScreenHandler.init()
} else {
openingScreenHandler.refreshHeaderStatusLive()
}
if (QuestionnaireCache.getAssignedClients(this).isEmpty()) {
refreshAssignedClientsInBackground()
}
}
}
override fun onPause() {
super.onPause()
unregisterNetworkCallback()
}
private fun registerNetworkCallback() {
if (netCb != null) return
val cm = getSystemService(ConnectivityManager::class.java)
val req = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
netCb = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
runOnUiThread {
if (::openingScreenHandler.isInitialized && !isInQuestionnaire) {
openingScreenHandler.refreshHeaderStatusLive()
}
}
}
override fun onLost(network: Network) {
runOnUiThread {
if (::openingScreenHandler.isInitialized && !isInQuestionnaire) {
openingScreenHandler.refreshHeaderStatusLive()
}
}
}
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
runOnUiThread {
if (::openingScreenHandler.isInitialized && !isInQuestionnaire) {
openingScreenHandler.refreshHeaderStatusLive()
}
}
}
}
cm.registerNetworkCallback(req, netCb!!)
}
private fun unregisterNetworkCallback() {
val cb = netCb ?: return
val cm = getSystemService(ConnectivityManager::class.java)
cm.unregisterNetworkCallback(cb)
netCb = null
}
// --- /LIVE NETZSTATUS ---
override fun onContentChanged() {
super.onContentChanged()
SystemBarInsets.applyToContentRoot(this, ensureMinContentPadding = true)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
}
fun startQuestionnaire(questionnaire: QuestionnaireBase<*>, languageID: String) {
isInQuestionnaire = true
isFirstQuestionnairePage = true
questionnaire.attach(this, languageID)
questionnaire.startQuestionnaire()
}
fun finishQuestionnaire() {
isInQuestionnaire = false
isFirstQuestionnairePage = false
ViewUtils.setTextSizeScale(1f)
LanguageManager.clearQuestionnaireTranslations()
if (::openingScreenHandler.isInitialized) {
openingScreenHandler.init()
}
}
}

View File

@ -0,0 +1,52 @@
package com.dano.test1
import android.app.Application
import android.util.Log
import androidx.appcompat.app.AppCompatDelegate
import androidx.room.Room
import androidx.room.RoomDatabase
import com.dano.test1.data.AppDatabase
import com.dano.test1.security.SensitiveDataCrypto
/*
MyApp (Application)
- Einstiegspunkt der App, der einmal pro Prozessstart initialisiert wird.
Besonderheiten der DB-Konfiguration:
- Name: "questionnaire_database"
- fallbackToDestructiveMigration():
* Falls sich das Schema ändert und keine Migration vorliegt,wird die DB zerstört und neu angelegt.
- setJournalMode(TRUNCATE):
* Verwendet TRUNCATE-Journal (keine separaten -wal/-shm Dateien), es existiert nur die Hauptdatei „questionnaire_database“.
- Callback onOpen():
* Loggt beim Öffnen der Datenbank einen Hinweis.
*/
class MyApp : Application() {
companion object {
lateinit var database: AppDatabase
private set
}
override fun onCreate() {
super.onCreate()
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
SensitiveDataCrypto.init(this)
// Room-Datenbank bauen: nur die Hauptdatei, ohne WAL und Journal
database = Room.databaseBuilder(
applicationContext,
AppDatabase::class.java,
"questionnaire_database"
)
.fallbackToDestructiveMigration()
.setJournalMode(RoomDatabase.JournalMode.TRUNCATE) // TRUNCATE = keine -wal Datei, nur Hauptdatei
.addCallback(object : RoomDatabase.Callback() {
override fun onOpen(db: androidx.sqlite.db.SupportSQLiteDatabase) {
super.onOpen(db)
Log.d("DB", "Datenbank geöffnet. Nur questionnaire_database wird genutzt.")
}
})
.build()
}
}

View File

@ -0,0 +1,71 @@
package com.dano.test1.data
import com.dano.test1.MyApp
import com.dano.test1.security.SensitiveDataCrypto
object AnswerRepository {
private val answerDao get() = MyApp.database.answerDao()
private fun key(clientCode: String) = SensitiveDataCrypto.clientCodeKey(clientCode)
suspend fun getAnswersForClient(clientCode: String): List<Answer> =
answerDao.getAnswersForClient(key(clientCode)).map { it.toDomain(clientCode) }
/**
* Which (client, questionnaire) pairs already have answer rows — no Keystore decrypt.
* Used for bulk import pending-edit checks only.
*/
suspend fun localAnswerPresence(clientCodes: List<String>): Set<Pair<String, String>> {
if (clientCodes.isEmpty()) return emptySet()
val keyToCode = clientCodes.associateBy { key(it) }
return answerDao.listClientQuestionnairesWithAnswers(keyToCode.keys.toList())
.mapNotNull { row ->
val code = keyToCode[row.clientCodeKey] ?: return@mapNotNull null
code to row.questionnaireId
}
.toSet()
}
fun toEntities(answers: List<Answer>): List<AnswerEntity> =
answers.map { it.toEntity() }
suspend fun deleteAnswersForClientAndQuestionnaires(
clientCode: String,
questionnaireIds: List<String>,
) {
if (questionnaireIds.isEmpty()) return
answerDao.deleteAnswersForClientAndQuestionnaires(key(clientCode), questionnaireIds)
}
suspend fun getAnswersForClientAndQuestionnaire(
clientCode: String,
questionnaireId: String,
): List<Answer> =
answerDao.getAnswersForClientAndQuestionnaire(key(clientCode), questionnaireId)
.map { it.toDomain(clientCode) }
suspend fun insertAnswers(answers: List<Answer>) {
if (answers.isEmpty()) return
answerDao.insertAnswers(answers.map { it.toEntity() })
}
suspend fun deleteAnswersForClientAndQuestionnaire(clientCode: String, questionnaireId: String) {
answerDao.deleteAnswersForClientAndQuestionnaire(key(clientCode), questionnaireId)
}
fun findAnswerValue(answers: List<Answer>, roomQuestionId: String): String? =
answers.find { it.questionId == roomQuestionId }?.answerValue
private fun Answer.toEntity() = AnswerEntity(
clientCodeKey = key(clientCode),
questionId = questionId,
answerValueEnc = SensitiveDataCrypto.encrypt(answerValue),
)
private fun AnswerEntity.toDomain(clientCode: String) = Answer(
clientCode = clientCode,
questionId = questionId,
answerValue = SensitiveDataCrypto.decrypt(answerValueEnc),
)
}

View File

@ -0,0 +1,38 @@
package com.dano.test1.data
import androidx.room.Database
import androidx.room.RoomDatabase
/*
Zentrale Room-Datenbank der App. Diese Klasse beschreibt:
- welche Tabellen (entities) es gibt: Client, Questionnaire, Question, Answer, CompletedQuestionnaire
- die Datenbank-Version (version = 1) für Migrations/Schema-Updates
Über die abstrakten DAO-Getter (clientDao(), questionnaireDao(), …) erhält der Rest der App Typsichere Zugriffe auf die jeweiligen Tabellen.
Hinweis:
- Room erzeugt zur Build-Zeit die konkrete Implementierung dieser abstrakten Klasse.
- Eine Instanz der Datenbank wird typischerweise per Room.databaseBuilder(...) erstellt und als Singleton verwendet.
*/
@Database(
entities = [
Client::class,
Questionnaire::class,
Question::class,
AnswerEntity::class,
CompletedQuestionnaireEntity::class,
CoachScoringReviewEntity::class,
],
version = 4,
exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {
abstract fun clientDao(): ClientDao
abstract fun questionnaireDao(): QuestionnaireDao
abstract fun questionDao(): QuestionDao
abstract fun answerDao(): AnswerDao
abstract fun completedQuestionnaireDao(): CompletedQuestionnaireDao
abstract fun coachScoringReviewDao(): CoachScoringReviewDao
}

View File

@ -0,0 +1,40 @@
package com.dano.test1.data
/**
* In-memory cache of answers for the active client to avoid repeated Room reads
* when evaluating questionnaire conditions (≤10 questionnaires per client).
*/
object ClientAnswersCache {
private var cachedClient: String? = null
private val byQuestionnaire = mutableMapOf<String, List<Answer>>()
private var allAnswers: List<Answer>? = null
suspend fun answersForQuestionnaire(clientCode: String, questionnaireId: String): List<Answer> {
ensureClient(clientCode)
return byQuestionnaire.getOrPut(questionnaireId) {
AnswerRepository.getAnswersForClientAndQuestionnaire(clientCode, questionnaireId)
}
}
suspend fun allForClient(clientCode: String): List<Answer> {
ensureClient(clientCode)
return allAnswers ?: AnswerRepository.getAnswersForClient(clientCode).also { allAnswers = it }
}
fun invalidate(clientCode: String? = null) {
if (clientCode == null || cachedClient == null || cachedClient.equals(clientCode, ignoreCase = true)) {
cachedClient = null
byQuestionnaire.clear()
allAnswers = null
}
}
private fun ensureClient(clientCode: String) {
if (!cachedClient.equals(clientCode, ignoreCase = true)) {
cachedClient = clientCode
byQuestionnaire.clear()
allAnswers = null
}
}
}

View File

@ -0,0 +1,45 @@
package com.dano.test1.data
import com.dano.test1.MyApp
import com.dano.test1.security.SensitiveDataCrypto
object ClientRepository {
private val dao get() = MyApp.database.clientDao()
suspend fun ensureClient(clientCode: String) {
val key = SensitiveDataCrypto.clientCodeKey(clientCode)
if (dao.getByKey(key) != null) return
dao.insertClient(
Client(
clientCodeKey = key,
clientCodeEnc = SensitiveDataCrypto.encrypt(clientCode.trim()),
),
)
}
suspend fun exists(clientCode: String): Boolean =
dao.getByKey(SensitiveDataCrypto.clientCodeKey(clientCode)) != null
suspend fun getAllClientCodes(): List<String> =
dao.getAll().map { SensitiveDataCrypto.decrypt(it.clientCodeEnc) }
suspend fun deleteAllClients() {
dao.deleteAllClients()
}
suspend fun deleteClientsNotIn(clientCodes: List<String>) {
val keys = clientCodes.map { SensitiveDataCrypto.clientCodeKey(it) }.distinct()
if (keys.isEmpty()) {
dao.deleteAllClients()
} else {
dao.deleteClientsNotIn(keys)
}
}
suspend fun replaceClients(clientCodes: List<String>) {
val codes = clientCodes.distinct()
deleteClientsNotIn(codes)
codes.forEach { ensureClient(it) }
}
}

View File

@ -0,0 +1,65 @@
package com.dano.test1.data
import com.dano.test1.MyApp
import com.dano.test1.security.SensitiveDataCrypto
object CoachScoringReviewRepository {
private val dao get() = MyApp.database.coachScoringReviewDao()
private fun key(clientCode: String) = SensitiveDataCrypto.clientCodeKey(clientCode)
suspend fun saveDecision(
clientCode: String,
profileID: String,
profileName: String,
coachBand: String,
calculatedBand: String,
weightedTotal: Double,
) {
ClientRepository.ensureClient(clientCode)
val now = System.currentTimeMillis()
val existing = dao.getAllForClient(key(clientCode)).find { it.profileID == profileID }
dao.upsert(
CoachScoringReviewEntity(
clientCodeKey = key(clientCode),
profileID = profileID,
profileName = profileName,
coachBand = coachBand,
calculatedBand = calculatedBand,
weightedTotal = weightedTotal,
updatedAt = now,
uploadedAt = existing?.uploadedAt,
),
)
}
suspend fun getAllForClient(clientCode: String): List<CoachScoringReview> =
dao.getAllForClient(key(clientCode)).map { it.toDomain(clientCode) }
suspend fun getAllPendingUploads(): List<CoachScoringReview> {
val keyToCode = ClientRepository.getAllClientCodes()
.associateBy { SensitiveDataCrypto.clientCodeKey(it) }
return dao.getAllPendingUploads().mapNotNull { entity ->
val code = keyToCode[entity.clientCodeKey] ?: return@mapNotNull null
entity.toDomain(code)
}
}
suspend fun countPendingUploads(): Int = dao.countPendingUploads()
suspend fun markUploaded(clientCode: String, profileID: String, uploadedAt: Long) {
dao.markUploaded(key(clientCode), profileID, uploadedAt)
}
private fun CoachScoringReviewEntity.toDomain(clientCode: String) = CoachScoringReview(
clientCode = clientCode,
profileID = profileID,
profileName = profileName,
coachBand = coachBand,
calculatedBand = calculatedBand,
weightedTotal = weightedTotal,
updatedAt = updatedAt,
uploadedAt = uploadedAt,
)
}

View File

@ -0,0 +1,75 @@
package com.dano.test1.data
import com.dano.test1.MyApp
import com.dano.test1.security.SensitiveDataCrypto
object CompletedQuestionnaireRepository {
private val dao get() = MyApp.database.completedQuestionnaireDao()
private fun key(clientCode: String) = SensitiveDataCrypto.clientCodeKey(clientCode)
suspend fun insert(entry: CompletedQuestionnaire) {
dao.insert(entry.toEntity())
}
suspend fun insertAll(entries: List<CompletedQuestionnaire>) {
if (entries.isEmpty()) return
dao.insertAll(entries.map { it.toEntity() })
}
suspend fun getStatus(clientCode: String, questionnaireId: String): CompletedQuestionnaire? =
dao.getStatus(key(clientCode), questionnaireId)?.toDomain(clientCode)
suspend fun getAllForClient(clientCode: String): List<CompletedQuestionnaire> =
dao.getAllForClient(key(clientCode)).map { it.toDomain(clientCode) }
/** One query for many clients (bulk answer import prep). */
suspend fun getAllGroupedByClient(clientCodes: List<String>): Map<String, List<CompletedQuestionnaire>> {
if (clientCodes.isEmpty()) return emptyMap()
val keyToCode = clientCodes.associateBy { key(it) }
val grouped = mutableMapOf<String, MutableList<CompletedQuestionnaire>>()
for (entity in dao.getAllForClients(keyToCode.keys.toList())) {
val code = keyToCode[entity.clientCodeKey] ?: continue
grouped.getOrPut(code) { mutableListOf() }.add(entity.toDomain(code))
}
return grouped
}
suspend fun getCompletedQuestionnaireIds(clientCode: String): List<String> =
dao.getCompletedQuestionnairesForClient(key(clientCode))
suspend fun getPendingUploadForClient(clientCode: String): List<CompletedQuestionnaire> =
dao.getPendingUploadForClient(key(clientCode)).map { it.toDomain(clientCode) }
suspend fun getAllPendingUploads(): List<CompletedQuestionnaire> {
val keyToCode = ClientRepository.getAllClientCodes().associateBy { SensitiveDataCrypto.clientCodeKey(it) }
return dao.getAllPendingUploads().mapNotNull { entity ->
val code = keyToCode[entity.clientCodeKey] ?: return@mapNotNull null
entity.toDomain(code)
}
}
suspend fun countPendingUploads(): Int = dao.countAllPendingUploads()
suspend fun markUploaded(clientCode: String, questionnaireId: String, uploadedAt: Long) {
dao.markUploaded(key(clientCode), questionnaireId, uploadedAt)
}
private fun CompletedQuestionnaire.toEntity() = CompletedQuestionnaireEntity(
clientCodeKey = key(clientCode),
questionnaireId = questionnaireId,
timestamp = timestamp,
isDone = isDone,
sumPoints = sumPoints,
uploadedAt = uploadedAt,
)
private fun CompletedQuestionnaireEntity.toDomain(clientCode: String) = CompletedQuestionnaire(
clientCode = clientCode,
questionnaireId = questionnaireId,
timestamp = timestamp,
isDone = isDone,
sumPoints = sumPoints,
uploadedAt = uploadedAt,
)
}

View File

@ -0,0 +1,191 @@
package com.dano.test1.data
import androidx.room.*
@Dao
interface ClientDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertClient(client: Client)
@Query("SELECT * FROM clients WHERE clientCodeKey = :key LIMIT 1")
suspend fun getByKey(key: String): Client?
@Query("SELECT * FROM clients")
suspend fun getAll(): List<Client>
@Query("DELETE FROM clients")
suspend fun deleteAllClients()
@Query("DELETE FROM clients WHERE clientCodeKey NOT IN (:keep)")
suspend fun deleteClientsNotIn(keep: List<String>)
}
@Dao
interface QuestionnaireDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertQuestionnaire(questionnaire: Questionnaire)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertQuestionnaires(questionnaires: List<Questionnaire>)
@Query("SELECT * FROM questionnaires WHERE id = :id LIMIT 1")
suspend fun getById(id: String): Questionnaire?
}
@Dao
interface QuestionDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertQuestions(questions: List<Question>)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertQuestion(question: Question)
@Query("SELECT * FROM questions WHERE questionId = :id LIMIT 1")
suspend fun getById(id: String): Question?
@Query("SELECT * FROM questions WHERE questionnaireId = :questionnaireId")
suspend fun getQuestionsForQuestionnaire(questionnaireId: String): List<Question>
}
data class ClientQuestionnaireKey(
val clientCodeKey: String,
val questionnaireId: String,
)
@Dao
interface AnswerDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAnswers(answers: List<AnswerEntity>)
@Query("""
SELECT a.* FROM answers a
INNER JOIN questions q ON a.questionId = q.questionId
WHERE a.clientCodeKey = :clientCodeKey AND q.questionnaireId = :questionnaireId
""")
suspend fun getAnswersForClientAndQuestionnaire(
clientCodeKey: String,
questionnaireId: String,
): List<AnswerEntity>
@Query("SELECT * FROM answers WHERE clientCodeKey = :clientCodeKey")
suspend fun getAnswersForClient(clientCodeKey: String): List<AnswerEntity>
@Query("SELECT * FROM answers WHERE clientCodeKey IN (:clientCodeKeys)")
suspend fun getAnswersForClients(clientCodeKeys: List<String>): List<AnswerEntity>
/** Existence only — no decryption (bulk import pending-edit check). */
@Query(
"""
SELECT DISTINCT a.clientCodeKey, q.questionnaireId
FROM answers a
INNER JOIN questions q ON a.questionId = q.questionId
WHERE a.clientCodeKey IN (:clientCodeKeys)
""",
)
suspend fun listClientQuestionnairesWithAnswers(
clientCodeKeys: List<String>,
): List<ClientQuestionnaireKey>
@Query("""
DELETE FROM answers
WHERE clientCodeKey = :clientCodeKey
AND questionId IN (
SELECT questionId FROM questions WHERE questionnaireId = :questionnaireId
)
""")
suspend fun deleteAnswersForClientAndQuestionnaire(
clientCodeKey: String,
questionnaireId: String,
)
@Query("""
DELETE FROM answers
WHERE clientCodeKey = :clientCodeKey
AND questionId IN (
SELECT questionId FROM questions WHERE questionnaireId IN (:questionnaireIds)
)
""")
suspend fun deleteAnswersForClientAndQuestionnaires(
clientCodeKey: String,
questionnaireIds: List<String>,
)
}
@Dao
interface CompletedQuestionnaireDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(entry: CompletedQuestionnaireEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(entries: List<CompletedQuestionnaireEntity>)
@Query(
"SELECT * FROM completed_questionnaires WHERE clientCodeKey = :clientCodeKey " +
"AND questionnaireId = :questionnaireId LIMIT 1",
)
suspend fun getStatus(clientCodeKey: String, questionnaireId: String): CompletedQuestionnaireEntity?
@Query("SELECT * FROM completed_questionnaires WHERE clientCodeKey = :clientCodeKey")
suspend fun getAllForClient(clientCodeKey: String): List<CompletedQuestionnaireEntity>
@Query("SELECT * FROM completed_questionnaires WHERE clientCodeKey IN (:clientCodeKeys)")
suspend fun getAllForClients(clientCodeKeys: List<String>): List<CompletedQuestionnaireEntity>
@Query("SELECT questionnaireId FROM completed_questionnaires WHERE clientCodeKey = :clientCodeKey")
suspend fun getCompletedQuestionnairesForClient(clientCodeKey: String): List<String>
@Query(
"UPDATE completed_questionnaires SET uploadedAt = :uploadedAt " +
"WHERE clientCodeKey = :clientCodeKey AND questionnaireId = :questionnaireId",
)
suspend fun markUploaded(clientCodeKey: String, questionnaireId: String, uploadedAt: Long)
@Query(
"SELECT * FROM completed_questionnaires WHERE clientCodeKey = :clientCodeKey AND isDone = 1 " +
"AND (uploadedAt IS NULL OR uploadedAt < timestamp)",
)
suspend fun getPendingUploadForClient(clientCodeKey: String): List<CompletedQuestionnaireEntity>
@Query(
"SELECT * FROM completed_questionnaires WHERE isDone = 1 " +
"AND (uploadedAt IS NULL OR uploadedAt < timestamp)",
)
suspend fun getAllPendingUploads(): List<CompletedQuestionnaireEntity>
@Query(
"SELECT COUNT(*) FROM completed_questionnaires WHERE isDone = 1 " +
"AND (uploadedAt IS NULL OR uploadedAt < timestamp)",
)
suspend fun countAllPendingUploads(): Int
}
@Dao
interface CoachScoringReviewDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(entry: CoachScoringReviewEntity)
@Query("SELECT * FROM coach_scoring_reviews WHERE clientCodeKey = :clientCodeKey")
suspend fun getAllForClient(clientCodeKey: String): List<CoachScoringReviewEntity>
@Query(
"SELECT * FROM coach_scoring_reviews WHERE uploadedAt IS NULL OR uploadedAt < updatedAt",
)
suspend fun getAllPendingUploads(): List<CoachScoringReviewEntity>
@Query(
"SELECT COUNT(*) FROM coach_scoring_reviews WHERE uploadedAt IS NULL OR uploadedAt < updatedAt",
)
suspend fun countPendingUploads(): Int
@Query(
"UPDATE coach_scoring_reviews SET uploadedAt = :uploadedAt " +
"WHERE clientCodeKey = :clientCodeKey AND profileID = :profileID",
)
suspend fun markUploaded(clientCodeKey: String, profileID: String, uploadedAt: Long)
}

View File

@ -0,0 +1,139 @@
package com.dano.test1.data
import androidx.room.*
@Entity(tableName = "clients")
data class Client(
@PrimaryKey val clientCodeKey: String,
val clientCodeEnc: String,
)
@Entity(tableName = "questionnaires")
data class Questionnaire(
@PrimaryKey val id: String,
)
@Entity(
tableName = "questions",
foreignKeys = [
ForeignKey(
entity = Questionnaire::class,
parentColumns = ["id"],
childColumns = ["questionnaireId"],
onDelete = ForeignKey.CASCADE,
)
],
indices = [Index("questionnaireId")],
)
data class Question(
@PrimaryKey val questionId: String,
val questionnaireId: String,
val question: String = "",
)
@Entity(
tableName = "answers",
primaryKeys = ["clientCodeKey", "questionId"],
foreignKeys = [
ForeignKey(
entity = Client::class,
parentColumns = ["clientCodeKey"],
childColumns = ["clientCodeKey"],
onDelete = ForeignKey.CASCADE,
),
ForeignKey(
entity = Question::class,
parentColumns = ["questionId"],
childColumns = ["questionId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("clientCodeKey"), Index("questionId")],
)
data class AnswerEntity(
val clientCodeKey: String,
val questionId: String,
val answerValueEnc: String = "",
)
/** Plaintext answer row used by app code (never persisted without encryption). */
data class Answer(
val clientCode: String,
val questionId: String,
val answerValue: String = "",
)
@Entity(
tableName = "completed_questionnaires",
primaryKeys = ["clientCodeKey", "questionnaireId"],
foreignKeys = [
ForeignKey(
entity = Client::class,
parentColumns = ["clientCodeKey"],
childColumns = ["clientCodeKey"],
onDelete = ForeignKey.CASCADE,
),
ForeignKey(
entity = Questionnaire::class,
parentColumns = ["id"],
childColumns = ["questionnaireId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("clientCodeKey"), Index("questionnaireId")],
)
data class CompletedQuestionnaireEntity(
val clientCodeKey: String,
val questionnaireId: String,
val timestamp: Long = System.currentTimeMillis(),
val isDone: Boolean,
val sumPoints: Int? = null,
val uploadedAt: Long? = null,
)
data class CompletedQuestionnaire(
val clientCode: String,
val questionnaireId: String,
val timestamp: Long = System.currentTimeMillis(),
val isDone: Boolean,
val sumPoints: Int? = null,
val uploadedAt: Long? = null,
)
@Entity(
tableName = "coach_scoring_reviews",
primaryKeys = ["clientCodeKey", "profileID"],
foreignKeys = [
ForeignKey(
entity = Client::class,
parentColumns = ["clientCodeKey"],
childColumns = ["clientCodeKey"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("clientCodeKey")],
)
data class CoachScoringReviewEntity(
val clientCodeKey: String,
val profileID: String,
val profileName: String = "",
val coachBand: String,
val calculatedBand: String,
val weightedTotal: Double,
val updatedAt: Long = System.currentTimeMillis(),
val uploadedAt: Long? = null,
)
data class CoachScoringReview(
val clientCode: String,
val profileID: String,
val profileName: String = "",
val coachBand: String,
val calculatedBand: String,
val weightedTotal: Double,
val updatedAt: Long = System.currentTimeMillis(),
val uploadedAt: Long? = null,
) {
val isPendingUpload: Boolean
get() = uploadedAt == null || uploadedAt < updatedAt
}

View File

@ -0,0 +1,10 @@
package com.dano.test1.data
object PendingUploadRepository {
suspend fun countAll(): Int =
CompletedQuestionnaireRepository.countPendingUploads() +
CoachScoringReviewRepository.countPendingUploads()
suspend fun hasAny(): Boolean = countAll() > 0
}

View File

@ -0,0 +1,36 @@
package com.dano.test1.data
/**
* Summarises local interview data that must be uploaded (or finished) before logout.
*/
object UnuploadedWorkRepository {
data class Summary(
val pendingQuestionnaires: Int,
val pendingScoringReviews: Int,
val inProgressQuestionnaires: Int,
) {
val hasBlockingWork: Boolean
get() = pendingQuestionnaires > 0 || pendingScoringReviews > 0 || inProgressQuestionnaires > 0
}
suspend fun summarize(): Summary {
val codes = ClientRepository.getAllClientCodes()
return Summary(
pendingQuestionnaires = CompletedQuestionnaireRepository.countPendingUploads(),
pendingScoringReviews = CoachScoringReviewRepository.countPendingUploads(),
inProgressQuestionnaires = countInProgressQuestionnaires(codes),
)
}
private suspend fun countInProgressQuestionnaires(clientCodes: List<String>): Int {
if (clientCodes.isEmpty()) return 0
val pairs = AnswerRepository.localAnswerPresence(clientCodes)
var count = 0
for ((clientCode, questionnaireId) in pairs) {
val status = CompletedQuestionnaireRepository.getStatus(clientCode, questionnaireId)
if (status == null || !status.isDone) count++
}
return count
}
}

View File

@ -0,0 +1,141 @@
package com.dano.test1.network
data class ApiError(
val code: String? = null,
val message: String = ""
)
data class LoginResult(
val token: String,
val user: String,
val role: String? = null,
val mustChangePassword: Boolean = false,
val assignedClientCodes: List<String> = emptyList()
)
data class SessionInfo(
val valid: Boolean,
val user: String = "",
val role: String = "",
val mustChangePassword: Boolean = false,
)
data class QuestionnaireListItem(
val id: String,
val name: String,
val showPoints: Boolean = false,
/** Raw JSON from API `condition`; cached for offline availability rules. */
val conditionJson: String = "{}",
/** Optional opening-screen group key (label via app UI strings). */
val categoryKey: String? = null,
/** Server structure revision; bump when questions/options change structurally. */
val structureRevision: Int = 1,
)
/** Server payload for one completed questionnaire (GET ?answers=1). */
data class ServerQuestionnaireAnswers(
val questionnaireID: String,
val completedAt: Long? = null,
val sumPoints: Int = 0,
val answers: List<SubmitAnswer> = emptyList(),
)
data class ClientAnswersBundle(
val clientCode: String,
val questionnaires: List<ServerQuestionnaireAnswers> = emptyList(),
)
data class SubmitQuestionnaireRequest(
val questionnaireID: String,
val clientCode: String,
val startedAt: Long? = null,
val completedAt: Long? = null,
val structureRevision: Int? = null,
val answers: List<SubmitAnswer>
)
data class SubmitAnswer(
val questionID: String,
val answerOptionKey: String? = null,
val freeTextValue: String? = null,
val numericValue: Double? = null,
val answeredAt: Long? = null
)
data class SubmitQuestionnaireResponse(
val submitted: Boolean = false,
val sumPoints: Int = 0,
val structureRevision: Int? = null,
val legacySubmit: Boolean = false,
)
/** One questionnaire already completed on the server side. */
data class ServerCompletedQuestionnaire(
val questionnaireID: String,
val sumPoints: Int = 0,
/** Unix seconds (as returned by server). */
val completedAt: Long = 0L
)
/** A client with its server-side completion records. */
data class AssignedClientInfo(
val clientCode: String,
val completedQuestionnaires: List<ServerCompletedQuestionnaire> = emptyList()
)
data class ScoringProfileMember(
val questionnaireID: String,
val weight: Double = 1.0,
val orderIndex: Int = 0,
val name: String = "",
)
data class ScoringProfileDefinition(
val profileID: String,
val name: String,
val description: String = "",
val isActive: Boolean = true,
val greenMin: Int = 0,
val greenMax: Int = 12,
val yellowMin: Int = 13,
val yellowMax: Int = 36,
val redMin: Int = 37,
val questionnaires: List<ScoringProfileMember> = emptyList(),
)
data class ScoringReviewProfile(
val profileID: String,
val name: String,
val weightedTotal: Double = 0.0,
val calculatedBand: String = "",
val greenMin: Int = 0,
val greenMax: Int = 12,
val yellowMin: Int = 13,
val yellowMax: Int = 36,
val redMin: Int = 37,
val coachBand: String? = null,
val pendingReview: Boolean = true,
/** Coach decision saved on device but not uploaded yet. */
val pendingUpload: Boolean = false,
)
data class ClientScoringReview(
val clientCode: String,
val profiles: List<ScoringReviewProfile> = emptyList(),
)
data class CoachScoringReviewRequest(
val action: String = "coachScoringReview",
val clientCode: String,
val profileID: String,
val coachBand: String,
val calculatedBand: String,
val weightedTotal: Double,
)
class ApiException(
message: String,
val code: String? = null,
/** Server `error.details` (e.g. validation errors list). */
val details: com.google.gson.JsonElement? = null,
) : Exception(message)

View File

@ -0,0 +1,48 @@
package com.dano.test1.network
import android.content.Context
import com.dano.test1.security.SensitiveDataCrypto
/** Persists per-client answer sync timestamps (survives process restarts). */
internal object ClientAnswerSyncStore {
private const val PREFS = "client_answer_sync_v1"
fun lastPullMs(context: Context, clientCode: String): Long {
val key = normalize(clientCode) ?: return 0L
return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getLong(key, 0L)
}
fun markPulled(context: Context, clientCode: String, atMs: Long = System.currentTimeMillis()) {
val key = normalize(clientCode) ?: return
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putLong(key, atMs)
.apply()
}
fun markPulledBatch(context: Context, clientCodes: List<String>, atMs: Long = System.currentTimeMillis()) {
if (clientCodes.isEmpty()) return
val edit = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
for (code in clientCodes) {
val key = normalize(code) ?: continue
edit.putLong(key, atMs)
}
edit.apply()
}
fun clear(context: Context, clientCode: String? = null) {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
if (clientCode.isNullOrBlank()) {
prefs.edit().clear().apply()
} else {
normalize(clientCode)?.let { prefs.edit().remove(it).apply() }
prefs.edit().remove(clientCode.trim().uppercase()).apply()
}
}
private fun normalize(clientCode: String): String? =
clientCode.trim()
.takeIf { it.isNotBlank() }
?.uppercase()
?.let { SensitiveDataCrypto.clientCodeKey(it) }
}

View File

@ -0,0 +1,221 @@
package com.dano.test1.network
import android.content.Context
import com.dano.test1.LanguageManager
import com.dano.test1.notification.UploadReminderScheduler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
object DatabaseUploader {
data class UploadResult(
val submittedQuestionnaires: Int,
val submittedScoringReviews: Int,
val skipped: Int,
val errors: List<UploadError>,
val revisionNotices: List<String> = emptyList(),
) {
val submitted get() = submittedQuestionnaires + submittedScoringReviews
val hasErrors get() = errors.isNotEmpty()
val allFailed get() = submitted == 0 && errors.isNotEmpty()
val needsReLogin get() = errors.any { it.isAuthError }
/** Sync/upload ran but nothing reached the server (no pending rows or all skipped). */
val nothingUploaded get() = submitted == 0 && !hasErrors
}
data class UploadError(
val clientCode: String,
val questionnaireId: String = "",
val profileID: String = "",
val profileName: String = "",
val message: String,
/** Non-null when the server rejected with a specific error code (e.g. NOT_FOUND, 403). */
val serverCode: String? = null,
val isAuthError: Boolean = false,
)
/**
* Uploads all pending (completed but not yet uploaded, or re-edited after last upload)
* questionnaires for all assigned clients. Reports results via [onResult] on the main thread.
*/
fun uploadDatabaseWithToken(
context: Context,
token: String,
languageId: String = "GERMAN",
onProgress: ((done: Int, total: Int) -> Unit)? = null,
onResult: (UploadResult) -> Unit,
) {
CoroutineScope(Dispatchers.IO).launch {
val allPending = com.dano.test1.data.CompletedQuestionnaireRepository
.getAllPendingUploads()
val pendingScoring = com.dano.test1.data.CoachScoringReviewRepository
.getAllPendingUploads()
if (allPending.isEmpty() && pendingScoring.isEmpty()) {
val assigned = QuestionnaireCache.getAssignedClients(context)
if (assigned.isEmpty()) {
withContext(Dispatchers.Main) {
onResult(
UploadResult(
submittedQuestionnaires = 0,
submittedScoringReviews = 0,
skipped = 0,
errors = listOf(
UploadError(
clientCode = "",
message = LanguageManager.getText(languageId, "no_profile"),
),
),
),
)
}
} else {
withContext(Dispatchers.Main) {
onResult(
UploadResult(
submittedQuestionnaires = 0,
submittedScoringReviews = 0,
skipped = 0,
errors = emptyList(),
),
)
}
}
return@launch
}
var submittedQuestionnaires = 0
var submittedScoringReviews = 0
var skipped = 0
val errors = mutableListOf<UploadError>()
val revisionNotices = mutableListOf<String>()
val total = allPending.size + pendingScoring.size
var processed = 0
withContext(Dispatchers.Main) { onProgress?.invoke(0, total) }
allPending.forEach { entry ->
val clientCode = entry.clientCode
val request = runCatching {
QuestionnaireSubmitMapper.buildRequest(
context = context,
clientCode = clientCode,
questionnaireId = entry.questionnaireId
)
}.getOrNull()
if (request == null) {
skipped++
SyncLog.w("UPLOAD", "Skipped ${entry.questionnaireId} for ${SyncLog.clientRef(clientCode)}: buildRequest returned null")
} else {
runCatching {
QuestionnaireApiClient.submitQuestionnaire(token, request)
}.onSuccess { response ->
submittedQuestionnaires++
com.dano.test1.data.CompletedQuestionnaireRepository
.markUploaded(clientCode, entry.questionnaireId, System.currentTimeMillis())
if (response.legacySubmit) {
val submitRev = response.structureRevision
?: request.structureRevision
?: 1
val serverRev = QuestionnaireCache.getQuestionnaireList(context)
.find { it.id == entry.questionnaireId }
?.structureRevision
?: submitRev
revisionNotices += "Uploaded ${entry.questionnaireId} using form revision $submitRev; server is on $serverRev."
}
SyncLog.d("UPLOAD", "Submitted ${entry.questionnaireId} for ${SyncLog.clientRef(clientCode)}")
}.onFailure { e ->
val apiEx = e as? ApiException
val serverCode = apiEx?.code
val authErr = TokenStore.isAuthError(e)
val displayMsg = when (apiEx) {
null -> e.message ?: "Unknown error"
else -> QuestionnaireApiClient.formatErrorMessage(apiEx)
}
errors += UploadError(
clientCode = clientCode,
questionnaireId = entry.questionnaireId,
message = displayMsg,
serverCode = serverCode,
isAuthError = authErr,
)
SyncLog.e("UPLOAD", "Failed ${entry.questionnaireId} for ${SyncLog.clientRef(clientCode)}: ${e.message}", e)
}
}
processed++
withContext(Dispatchers.Main) { onProgress?.invoke(processed, total) }
}
pendingScoring.forEach { review ->
runCatching {
QuestionnaireApiClient.submitCoachScoringReview(
token,
CoachScoringReviewRequest(
clientCode = review.clientCode,
profileID = review.profileID,
coachBand = review.coachBand,
calculatedBand = review.calculatedBand,
weightedTotal = review.weightedTotal,
),
)
}.onSuccess {
submittedScoringReviews++
com.dano.test1.data.CoachScoringReviewRepository.markUploaded(
review.clientCode,
review.profileID,
System.currentTimeMillis(),
)
SyncLog.d("UPLOAD", "Submitted scoring review ${review.profileID} for ${SyncLog.clientRef(review.clientCode)}")
}.onFailure { e ->
val apiEx = e as? ApiException
val displayMsg = when (apiEx) {
null -> e.message ?: "Unknown error"
else -> QuestionnaireApiClient.formatErrorMessage(apiEx)
}
errors += UploadError(
clientCode = review.clientCode,
profileID = review.profileID,
profileName = review.profileName,
message = displayMsg,
serverCode = apiEx?.code,
isAuthError = TokenStore.isAuthError(e),
)
SyncLog.e("UPLOAD", "Failed scoring ${review.profileID} for ${SyncLog.clientRef(review.clientCode)}: ${e.message}", e)
}
processed++
withContext(Dispatchers.Main) { onProgress?.invoke(processed, total) }
}
if (!errors.any() && (submittedQuestionnaires + submittedScoringReviews) > 0) {
val stillPending = UploadReminderScheduler.hasPendingUploads()
if (!stillPending) {
UploadReminderScheduler.cancel(context)
}
}
// Refresh server cache after upload so local interview rows are not wiped first.
runCatching {
SyncCoordinator.refreshManual(context, token)
}.onFailure { e ->
SyncLog.w("UPLOAD", "Post-upload sync failed: ${e.message}")
if (TokenStore.isAuthError(e)) {
errors += UploadError(clientCode = "", message = "Session expired", isAuthError = true)
}
}
withContext(Dispatchers.Main) {
onResult(
UploadResult(
submittedQuestionnaires = submittedQuestionnaires,
submittedScoringReviews = submittedScoringReviews,
skipped = skipped,
errors = errors,
revisionNotices = revisionNotices,
),
)
}
}
}
}

View File

@ -0,0 +1,22 @@
package com.dano.test1.network
import okhttp3.Dispatcher
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
object HttpModule {
const val BASE_URL = "http://49.13.157.44/nat-as-server/api"
val client: OkHttpClient by lazy {
val dispatcher = Dispatcher().apply {
maxRequests = 24
maxRequestsPerHost = 8
}
OkHttpClient.Builder()
.dispatcher(dispatcher)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.build()
}
}

View File

@ -0,0 +1,86 @@
package com.dano.test1.network
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
object LoginManager {
suspend fun login(
context: Context,
username: String,
password: String,
onMustChangePassword: suspend (tempToken: String, username: String) -> Unit,
onSuccess: suspend (token: String, username: String, assignedClients: List<String>) -> Unit,
onError: suspend (String) -> Unit
) {
try {
val result = withContext(Dispatchers.IO) {
QuestionnaireApiClient.login(username, password)
}
if (result.mustChangePassword) {
onMustChangePassword(result.token, result.user.ifBlank { username })
return
}
val user = result.user.ifBlank { username }
onSuccess(result.token, user, result.assignedClientCodes)
} catch (e: Exception) {
SyncLog.e("LOGIN", "login failed", e)
onError(e.message ?: "Login failed")
}
}
suspend fun changePassword(
context: Context,
token: String,
username: String,
oldPassword: String,
newPassword: String,
onSuccess: suspend (token: String, username: String, assignedClients: List<String>) -> Unit,
onError: suspend (String) -> Unit
) {
try {
val result = withContext(Dispatchers.IO) {
QuestionnaireApiClient.changePassword(token, username, oldPassword, newPassword)
}
val user = result.user.ifBlank { username }
onSuccess(result.token, user, result.assignedClientCodes)
} catch (e: Exception) {
SyncLog.e("LOGIN", "changePassword failed", e)
onError(e.message ?: "Password change failed")
}
}
/** @deprecated Use [login] from [AuthActivity] instead. */
fun loginUserWithCredentials(
context: Context,
username: String,
password: String,
onSuccess: (String) -> Unit,
onError: (String) -> Unit
) {
CoroutineScope(Dispatchers.Main).launch {
login(
context = context,
username = username,
password = password,
onMustChangePassword = { _, _ ->
onError("Password change required. Please use the login screen.")
},
onSuccess = { token, user, clients ->
withContext(Dispatchers.IO) {
QuestionnaireCache.clearAllClientData(context)
TokenStore.save(context, token, user)
QuestionnaireCache.replaceAssignedClients(context, clients)
}
onSuccess(token)
},
onError = onError
)
}
}
}

View File

@ -0,0 +1,33 @@
package com.dano.test1.network
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
/*
Zweck:
- Einfache Hilfsklasse, um den aktuellen Online-Status des Geräts zu prüfen.
Funktionsweise:
- `isOnline(context)` nutzt den systemweiten `ConnectivityManager`, fragt die aktive Verbindung (`activeNetwork`) ab und prüft deren `NetworkCapabilities`.
- Es wird nur dann `true` zurückgegeben, wenn:
* eine aktive Verbindung existiert und
* die Verbindung die Fähigkeit „INTERNET“ besitzt und
* die Verbindung als „VALIDATED“ gilt (vom System als funktionsfähig verifiziert).
Verwendung:
- Vo Netzwerkaufrufen (Login, Upload, Download) aufrufen, um „Offline“-Fälle frühzeitig abzufangen und nutzerfreundliche Meldungen zu zeigen.
*/
object NetworkUtils {
fun isOnline(context: Context): Boolean {
return try {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager? ?: return false
val network = cm.activeNetwork ?: return false
val caps = cm.getNetworkCapabilities(network) ?: return false
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
} catch (_: SecurityException) {
false
}
}
}

View File

@ -0,0 +1,472 @@
package com.dano.test1.network
import android.content.Context
import com.dano.test1.MyApp
import com.dano.test1.data.AnswerEntity
import com.dano.test1.data.AnswerRepository
import com.dano.test1.data.CompletedQuestionnaire
import com.dano.test1.data.CompletedQuestionnaireRepository
import com.dano.test1.data.Question
import com.dano.test1.data.Questionnaire
import com.dano.test1.questionnaire.AnswerKeyUtils
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import androidx.room.withTransaction
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
object QuestionnaireAnswerSync {
private const val TAG = "AnswerSync"
/** Parallel HTTP downloads during per-client answer fallback. */
private const val CLIENT_FETCH_PARALLELISM = 6
/** Room/SQLite allows one writer — serialize imports to avoid lock contention. */
private val dbImportGate = Semaphore(1)
/** Re-download on client switch only if last successful sync is older than this. */
const val CLIENT_PULL_STALE_MS = 3_600_000L
private val sessionPullAtMs = mutableMapOf<String, Long>()
private data class PreparedBlockImport(
val questionnaireId: String,
val questions: List<Question>,
val answerEntities: List<AnswerEntity>,
val completion: CompletedQuestionnaire,
)
private data class PreparedClientImport(
val clientCode: String,
val blocks: List<PreparedBlockImport>,
)
private data class DetailCaches(
val parsed: Map<String, JsonObject>,
val rawJson: Map<String, String>,
)
/**
* Pulls server answers for assigned clients (offline facility prep).
* When [force] is false, only clients not synced within [CLIENT_PULL_STALE_MS] are fetched.
* Pass [prefetchedBundles] when bulk HTTP was started earlier (e.g. parallel with detail fetch).
*/
suspend fun pullForAllAssignedClients(
context: Context,
token: String,
clientCodes: List<String>? = null,
force: Boolean = false,
prefetchedBundles: List<ClientAnswersBundle>? = null,
pacer: SyncProgressPacer? = null,
): Int {
if (!NetworkUtils.isOnline(context)) {
SyncLog.d(TAG, "offline — skip answer pull for all clients")
return 0
}
val codes = (clientCodes ?: QuestionnaireCache.getAssignedClients(context))
.filter { it.isNotBlank() }
.distinct()
if (codes.isEmpty()) {
SyncLog.d(TAG, "no assigned clients — skip answer pull")
return 0
}
val toPull = clientsNeedingPull(context, codes, force)
if (toPull.isEmpty()) {
SyncLog.d(TAG, "all ${codes.size} clients fresh (synced within 1h)")
return codes.size
}
SyncLog.d(TAG, "pulling answers for ${toPull.size}/${codes.size} clients (force=$force)")
pullClientsParallel(context, token, toPull, prefetchedBundles, pacer)
return toPull.size
}
fun clientsNeedingPull(context: Context, codes: List<String>, force: Boolean): List<String> =
if (force) codes else codes.filter { shouldPull(context, it) }
/**
* Pulls answers for one client when selected on the opening screen.
* Skips network if synced within [CLIENT_PULL_STALE_MS] unless [force] is true.
*/
suspend fun pullForClient(
context: Context,
token: String,
clientCode: String,
force: Boolean = false,
): Boolean {
if (!NetworkUtils.isOnline(context)) {
SyncLog.d(TAG, "offline — skip answer pull for ${SyncLog.clientRef(clientCode)}")
return false
}
val code = clientCode.trim()
if (code.isBlank()) return false
if (!force && !shouldPull(context, code)) {
SyncLog.d(TAG, "skip answer pull for ${SyncLog.clientRef(code)} (synced within 1h)")
return true
}
return runCatching {
val bundle = QuestionnaireApiClient.fetchClientAnswers(token, code)
importClientBundle(context, code, bundle.questionnaires)
markPulled(context, code)
true
}.onFailure { e ->
SyncLog.w(TAG, "answer pull failed for ${SyncLog.clientRef(code)}: ${e.message}")
}.getOrDefault(false)
}
fun invalidatePullCache(context: Context, clientCode: String? = null) {
ClientAnswerSyncStore.clear(context, clientCode)
if (clientCode.isNullOrBlank()) {
sessionPullAtMs.clear()
} else {
sessionPullAtMs.remove(clientCode.trim().uppercase())
}
}
fun needsPull(context: Context, clientCode: String): Boolean =
shouldPull(context, clientCode.trim())
private fun shouldPull(context: Context, clientCode: String): Boolean {
val key = clientCode.trim().uppercase()
val last = maxOf(
sessionPullAtMs[key] ?: 0L,
ClientAnswerSyncStore.lastPullMs(context, key),
)
if (last <= 0L) return true
return System.currentTimeMillis() - last > CLIENT_PULL_STALE_MS
}
private fun markPulled(context: Context, clientCode: String) {
val now = System.currentTimeMillis()
val key = clientCode.trim().uppercase()
sessionPullAtMs[key] = now
ClientAnswerSyncStore.markPulled(context, clientCode, now)
}
/**
* Bulk HTTP when possible; one Room transaction per chunk. Falls back to parallel per-client fetches.
*/
private suspend fun pullClientsParallel(
context: Context,
token: String,
codes: List<String>,
prefetchedBundles: List<ClientAnswersBundle>?,
pacer: SyncProgressPacer? = null,
): Int {
if (!NetworkUtils.isOnline(context)) {
SyncLog.d(TAG, "offline — skip answer pull")
return 0
}
if (prefetchedBundles != null) {
return importBulkBundles(context, codes, prefetchedBundles, pacer)
}
pacer?.emit(SyncPhase.DOWNLOADING_ANSWERS)
val bulkResult = runCatching {
withContext(Dispatchers.IO) {
QuestionnaireApiClient.fetchBulkClientAnswers(token)
}
}
if (bulkResult.isSuccess) {
return importBulkBundles(context, codes, bulkResult.getOrDefault(emptyList()), pacer)
}
SyncLog.w(TAG, "bulk answer pull failed, falling back to per-client: ${bulkResult.exceptionOrNull()?.message}")
return pullClientsParallelFallback(context, token, codes, pacer)
}
private suspend fun importBulkBundles(
context: Context,
codes: List<String>,
bundles: List<ClientAnswersBundle>,
pacer: SyncProgressPacer?,
): Int {
val byCode = bundles.associateBy { it.clientCode.trim().uppercase() }
val imported = runCatching {
importManyClients(
context = context,
codes = codes,
blocksByCode = { code -> byCode[code.trim().uppercase()]?.questionnaires.orEmpty() },
pacer = pacer,
)
ClientAnswerSyncStore.markPulledBatch(context, codes)
codes.size
}.getOrElse {
SyncLog.w(TAG, "bulk import failed: ${it.message}")
0
}
SyncLog.d(TAG, "bulk answer pull finished: $imported/${codes.size} clients")
return imported
}
private suspend fun pullClientsParallelFallback(
context: Context,
token: String,
codes: List<String>,
pacer: SyncProgressPacer? = null,
): Int = coroutineScope {
pacer?.emit(SyncPhase.DOWNLOADING_ANSWERS)
val fetchGate = Semaphore(CLIENT_FETCH_PARALLELISM)
data class Fetched(val code: String, val blocks: List<ServerQuestionnaireAnswers>)
val fetched = codes.map { code ->
async(Dispatchers.IO) {
fetchGate.withPermit {
runCatching {
val bundle = QuestionnaireApiClient.fetchClientAnswers(token, code)
Fetched(code, bundle.questionnaires)
}.getOrElse {
SyncLog.w(TAG, "fetch failed for ${SyncLog.clientRef(code)}: ${it.message}")
null
}
}
}
}.awaitAll().filterNotNull()
val fetchedByCode = fetched.associate { it.code.trim().uppercase() to it.blocks }
val imported = runCatching {
importManyClients(
context = context,
codes = fetched.map { it.code },
blocksByCode = { code -> fetchedByCode[code.trim().uppercase()].orEmpty() },
pacer = pacer,
)
ClientAnswerSyncStore.markPulledBatch(context, fetched.map { it.code })
fetched.size
}.getOrElse {
SyncLog.w(TAG, "fallback import failed: ${it.message}")
0
}
SyncLog.d(TAG, "answer pull finished: $imported/${codes.size} clients (${fetched.size} fetched)")
imported
}
/**
* Prepares all clients on a worker thread (encrypt + JSON), then one Room transaction for SQLite writes.
*/
private suspend fun importManyClients(
context: Context,
codes: List<String>,
blocksByCode: (String) -> List<ServerQuestionnaireAnswers>,
pacer: SyncProgressPacer? = null,
) {
if (codes.isEmpty()) return
pacer?.emit(SyncPhase.PROCESSING)
val listQnIds = QuestionnaireCache.getQuestionnaireList(context).map { it.id }.toSet()
val allQnIds = buildSet {
addAll(listQnIds)
codes.forEach { code ->
blocksByCode(code).forEach { add(it.questionnaireID) }
}
}
val detailCaches = loadDetailCaches(context, allQnIds)
val localAnswerPresence = AnswerRepository.localAnswerPresence(codes)
val completionsByClient = CompletedQuestionnaireRepository.getAllGroupedByClient(codes)
val prepared = withContext(Dispatchers.Default) {
buildList {
for (code in codes) {
val blocks = blocksByCode(code)
if (blocks.isEmpty()) continue
val completionByQn = completionsByClient[code].orEmpty()
.associateBy { it.questionnaireId }
val clientBlocks = mutableListOf<PreparedBlockImport>()
for (block in blocks) {
prepareBlockImport(
clientCode = code,
block = block,
localAnswerPresence = localAnswerPresence,
completionByQn = completionByQn,
detailCaches = detailCaches,
)?.let { clientBlocks.add(it) }
}
if (clientBlocks.isNotEmpty()) {
add(PreparedClientImport(code, clientBlocks))
}
}
}
}
if (prepared.isEmpty()) return
pacer?.emit(SyncPhase.SECURELY_SAVING)
dbImportGate.withPermit {
val db = MyApp.database
db.withTransaction {
val allQuestionnaires = prepared.flatMap { it.blocks }
.map { Questionnaire(id = it.questionnaireId) }
.distinctBy { it.id }
db.questionnaireDao().insertQuestionnaires(allQuestionnaires)
for (client in prepared) {
val qnIds = client.blocks.map { it.questionnaireId }
AnswerRepository.deleteAnswersForClientAndQuestionnaires(client.clientCode, qnIds)
val allQuestions = client.blocks.flatMap { it.questions }
if (allQuestions.isNotEmpty()) {
db.questionDao().insertQuestions(allQuestions)
}
val allAnswers = client.blocks.flatMap { it.answerEntities }
if (allAnswers.isNotEmpty()) {
db.answerDao().insertAnswers(allAnswers)
}
db.completedQuestionnaireDao().insertAll(
client.blocks.map { it.completion.toEntity(client.clientCode) },
)
}
}
}
}
private suspend fun importClientBundle(
context: Context,
clientCode: String,
blocks: List<ServerQuestionnaireAnswers>,
) {
if (blocks.isEmpty()) return
importManyClients(
context = context,
codes = listOf(clientCode),
blocksByCode = { if (it == clientCode) blocks else emptyList() },
)
}
private fun loadDetailCaches(context: Context, questionnaireIds: Set<String>): DetailCaches {
val parsed = mutableMapOf<String, JsonObject>()
val rawJson = mutableMapOf<String, String>()
for (id in questionnaireIds) {
if (id.isBlank()) continue
val json = QuestionnaireCache.loadQuestionnaireJson(context, id) ?: continue
rawJson[id] = json
parsed[id] = JsonParser.parseString(json).asJsonObject
}
return DetailCaches(parsed, rawJson)
}
private fun prepareBlockImport(
clientCode: String,
block: ServerQuestionnaireAnswers,
localAnswerPresence: Set<Pair<String, String>>,
completionByQn: Map<String, CompletedQuestionnaire>,
detailCaches: DetailCaches,
): PreparedBlockImport? {
val questionnaireId = block.questionnaireID
if (questionnaireId.isBlank()) return null
val status = completionByQn[questionnaireId]
val pendingEdit = status?.isDone == true &&
(status.uploadedAt == null || (status.uploadedAt ?: 0L) < status.timestamp)
if (localAnswerPresence.contains(clientCode to questionnaireId) && pendingEdit) {
return null
}
val detail = detailCaches.parsed[questionnaireId] ?: return null
val questions = detail.getAsJsonArray("questions") ?: return null
val stored = mapServerAnswersToStored(block, questions) ?: return null
val completedAtMs = (block.completedAt ?: 0L).let { if (it > 0) it * 1000L else System.currentTimeMillis() }
val sumPoints = block.sumPoints
val clientKey = com.dano.test1.security.SensitiveDataCrypto.clientCodeKey(clientCode)
return PreparedBlockImport(
questionnaireId = questionnaireId,
questions = stored.keys.map { key ->
Question(
questionId = AnswerKeyUtils.roomQuestionId(questionnaireId, key),
questionnaireId = questionnaireId,
question = key,
)
},
answerEntities = stored.map { (key, value) ->
AnswerEntity(
clientCodeKey = clientKey,
questionId = AnswerKeyUtils.roomQuestionId(questionnaireId, key),
answerValueEnc = com.dano.test1.security.SensitiveDataCrypto.encrypt(value),
)
},
completion = CompletedQuestionnaire(
clientCode = clientCode,
questionnaireId = questionnaireId,
isDone = true,
sumPoints = sumPoints,
timestamp = completedAtMs,
uploadedAt = completedAtMs,
),
)
}
private fun mapServerAnswersToStored(
block: ServerQuestionnaireAnswers,
questions: JsonArray,
): Map<String, String>? {
val submitByQuestion = block.answers.groupBy { it.questionID }
val stored = mutableMapOf<String, String>()
val symptomKeys = mutableSetOf<String>()
questions.forEach { el ->
val q = el.asJsonObject
val shortId = q.get("id")?.asString ?: return@forEach
val layout = q.get("layout")?.asString.orEmpty()
val questionKey = q.get("question")?.asString?.takeIf { it.isNotBlank() } ?: shortId
val rows = submitByQuestion[shortId].orEmpty()
q.getAsJsonArray("symptoms")?.forEach { s ->
symptomKeys.add(s.asString)
}
when (layout) {
"multi_check_box_question" -> {
val keys = rows.mapNotNull { it.answerOptionKey?.takeIf { k -> k.isNotBlank() } }
if (keys.isNotEmpty()) {
stored[questionKey] = keys.joinToString(
prefix = "[",
postfix = "]",
) { "\"$it\"" }
}
}
"glass_scale_question" -> Unit
"radio_question", "string_spinner" -> {
val row = rows.firstOrNull() ?: return@forEach
val value = row.answerOptionKey?.takeIf { it.isNotBlank() }
?: row.freeTextValue?.takeIf { it.isNotBlank() }
if (value != null) stored[questionKey] = value
}
"value_spinner", "slider_question" -> {
val row = rows.firstOrNull() ?: return@forEach
val num = row.numericValue
val value = when {
num != null -> num.toString()
else -> row.freeTextValue
}
if (!value.isNullOrBlank()) stored[questionKey] = value
}
else -> {
val row = rows.firstOrNull() ?: return@forEach
val value = row.freeTextValue?.takeIf { it.isNotBlank() }
?: row.answerOptionKey?.takeIf { it.isNotBlank() }
?: row.numericValue?.toString()
if (!value.isNullOrBlank()) stored[questionKey] = value
}
}
}
symptomKeys.forEach { symptomKey ->
val row = block.answers.find { it.questionID == symptomKey } ?: return@forEach
val value = row.answerOptionKey?.takeIf { it.isNotBlank() } ?: return@forEach
stored[symptomKey] = value
}
return stored.takeIf { it.isNotEmpty() }
}
private fun CompletedQuestionnaire.toEntity(clientCode: String) =
com.dano.test1.data.CompletedQuestionnaireEntity(
clientCodeKey = com.dano.test1.security.SensitiveDataCrypto.clientCodeKey(clientCode),
questionnaireId = questionnaireId,
timestamp = timestamp,
isDone = isDone,
sumPoints = sumPoints,
uploadedAt = uploadedAt,
)
}

View File

@ -0,0 +1,570 @@
package com.dano.test1.network
import com.dano.test1.security.SessionPayloadCrypto
import com.google.gson.Gson
import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
object QuestionnaireApiClient {
//URL ONLY VALID ON CURRENT NETWORK; HAS TO BE CHANGED IN FUTURE
private val jsonMediaType = "application/json; charset=UTF-8".toMediaType()
private val client get() = HttpModule.client
private val gson = Gson()
suspend fun changePassword(
token: String,
username: String,
oldPassword: String,
newPassword: String
): LoginResult = withContext(Dispatchers.IO) {
val body = JSONObject()
.put("username", username)
.put("old_password", oldPassword)
.put("new_password", newPassword)
.toString()
.toRequestBody(jsonMediaType)
val request = authorizedRequest(token)
.url("${HttpModule.BASE_URL}/auth/change-password")
.post(body)
.build()
client.newCall(request).execute().use { response ->
val bodyText = response.body?.string()
SyncLog.apiResponse("API_CHANGE_PW", "changePassword", response.code, bodyText)
val root = parseEnvelope(response.code, bodyText)
val data = root.getAsJsonObject("data") ?: throw ApiException("Change password response missing data")
parseLoginResult(data)
}
}
suspend fun login(username: String, password: String): LoginResult = withContext(Dispatchers.IO) {
val body = JSONObject()
.put("username", username)
.put("password", password)
.toString()
.toRequestBody(jsonMediaType)
val request = Request.Builder()
.url("${HttpModule.BASE_URL}/auth/login")
.post(body)
.build()
client.newCall(request).execute().use { response ->
val bodyText = response.body?.string()
SyncLog.apiResponse("API_LOGIN", "login", response.code, bodyText)
val root = parseEnvelope(response.code, bodyText)
val data = root.getAsJsonObject("data") ?: throw ApiException("Login response missing data")
parseLoginResult(data)
}
}
/** App UI strings for the login screen — no Bearer token required. */
suspend fun getPublicTranslations(): JsonObject = withContext(Dispatchers.IO) {
val request = Request.Builder()
.url("${HttpModule.BASE_URL}/app_questionnaires?translations=1")
.get()
.build()
fetchTranslationsEnvelope(request, logTag = "API_TRANSLATIONS_PUBLIC")
}
suspend fun getTranslations(token: String): JsonObject = withContext(Dispatchers.IO) {
val request = authorizedRequest(token)
.url("${HttpModule.BASE_URL}/app_questionnaires?translations=1")
.get()
.build()
fetchTranslationsEnvelope(request, logTag = "API_TRANSLATIONS")
}
suspend fun getSession(token: String): SessionInfo = withContext(Dispatchers.IO) {
val request = authorizedRequest(token)
.url("${HttpModule.BASE_URL}/session")
.get()
.build()
client.newCall(request).execute().use { response ->
val bodyText = response.body?.string()
SyncLog.apiResponse("API_SESSION", "getSession", response.code, bodyText)
val root = parseEnvelope(response.code, bodyText)
val data = root.getAsJsonObject("data") ?: throw ApiException("Session response missing data")
SessionInfo(
valid = data.get("valid")?.asBoolean ?: false,
user = data.get("user")?.asString.orEmpty(),
role = data.get("role")?.asString.orEmpty(),
mustChangePassword = data.get("mustChangePassword")?.asBoolean ?: false,
)
}
}
private fun fetchTranslationsEnvelope(request: Request, logTag: String): JsonObject =
client.newCall(request).execute().use { response ->
val bodyText = response.body?.string()
SyncLog.apiResponse(logTag, "translations", response.code, bodyText)
val root = parseEnvelope(response.code, bodyText)
val data = root.get("data")
when {
data == null || data.isJsonNull -> JsonObject()
data.isJsonObject -> data.asJsonObject
else -> {
SyncLog.w(logTag, "unexpected data type for translations: ${data::class.simpleName}")
JsonObject()
}
}
}
suspend fun getQuestionnaires(token: String): List<QuestionnaireListItem> = withContext(Dispatchers.IO) {
val request = authorizedRequest(token)
.url("${HttpModule.BASE_URL}/app_questionnaires")
.get()
.build()
client.newCall(request).execute().use { response ->
val bodyText = response.body?.string()
SyncLog.apiResponse("API_QUESTIONNAIRES", "getQuestionnaires", response.code, bodyText)
val root = parseEnvelope(response.code, bodyText)
val data = root.getAsJsonArray("data") ?: JsonArray()
data.mapNotNull { item ->
val obj = item.asJsonObject
val id = obj.get("id")?.asString ?: return@mapNotNull null
val conditionElement = obj.get("condition")
val conditionJson = when {
conditionElement == null || conditionElement.isJsonNull -> "{}"
conditionElement.isJsonObject -> conditionElement.asJsonObject.toString()
else -> "{}"
}
QuestionnaireListItem(
id = id,
name = obj.get("name")?.asString ?: id,
showPoints = obj.get("showPoints")?.asBoolean ?: false,
conditionJson = conditionJson,
categoryKey = obj.get("categoryKey")?.asString?.takeIf { it.isNotBlank() },
structureRevision = obj.get("structureRevision")?.asInt?.coerceAtLeast(1) ?: 1,
)
}
}
}
/**
* Fetches assigned clients together with their server-side completion records.
* Supports both the legacy plain-string array and the new rich object format:
* { "data": { "clients": [ { "clientCode": "...", "completedQuestionnaires": [...] } ] } }
* { "data": { "clients": ["CLIENT-001", ...] } }
* { "data": ["CLIENT-001", ...] }
*/
suspend fun getAssignedClients(token: String): List<AssignedClientInfo> = withContext(Dispatchers.IO) {
val request = authorizedRequest(token)
.url("${HttpModule.BASE_URL}/app_questionnaires?clients=1")
.get()
.build()
client.newCall(request).execute().use { response ->
val bodyText = response.body?.string()
SyncLog.apiResponse("API_CLIENTS", "getAssignedClients", response.code, bodyText)
val root = parseEnvelope(response.code, bodyText)
val dataEl = root.get("data") ?: return@use emptyList()
val data = when {
dataEl.isJsonObject -> SessionPayloadCrypto.decryptDataElement(dataEl.asJsonObject, token)
else -> return@use emptyList()
}
val arr = when {
data.has("clients") -> data.getAsJsonArray("clients")
else -> null
} ?: return@use emptyList()
arr.mapNotNull { item ->
when {
item.isJsonPrimitive -> {
val code = item.asString.takeIf { it.isNotBlank() } ?: return@mapNotNull null
AssignedClientInfo(clientCode = code)
}
item.isJsonObject -> {
val obj = item.asJsonObject
val code = obj.get("clientCode")?.asString?.takeIf { it.isNotBlank() }
?: return@mapNotNull null
val completions = obj.getAsJsonArray("completedQuestionnaires")
?.mapNotNull { cItem ->
if (!cItem.isJsonObject) return@mapNotNull null
val cObj = cItem.asJsonObject
val qId = cObj.get("questionnaireID")?.asString?.takeIf { it.isNotBlank() }
?: return@mapNotNull null
ServerCompletedQuestionnaire(
questionnaireID = qId,
sumPoints = cObj.get("sumPoints")?.asInt ?: 0,
completedAt = cObj.get("completedAt")?.asLong ?: 0L
)
} ?: emptyList()
AssignedClientInfo(clientCode = code, completedQuestionnaires = completions)
}
else -> null
}
}
}
}
suspend fun fetchBulkClientAnswers(token: String): List<ClientAnswersBundle> =
withContext(Dispatchers.IO) {
val request = authorizedRequest(token)
.url("${HttpModule.BASE_URL}/app_questionnaires?answersBulk=1")
.get()
.build()
client.newCall(request).execute().use { response ->
val bodyText = response.body?.string()
SyncLog.apiResponse("API_ANSWERS", "fetchBulkClientAnswers", response.code, bodyText)
val root = parseEnvelope(response.code, bodyText)
val dataEl = root.get("data") ?: return@use emptyList()
val data = when {
dataEl.isJsonObject -> SessionPayloadCrypto.decryptDataElement(dataEl.asJsonObject, token)
else -> return@use emptyList()
}
parseClientAnswerBundles(data.getAsJsonArray("clients"))
}
}
suspend fun fetchClientAnswers(token: String, clientCode: String): ClientAnswersBundle =
withContext(Dispatchers.IO) {
val request = authorizedRequest(token)
.url(
"${HttpModule.BASE_URL}/app_questionnaires" +
"?clientCode=${java.net.URLEncoder.encode(clientCode, Charsets.UTF_8.name())}&answers=1",
)
.get()
.build()
client.newCall(request).execute().use { response ->
val bodyText = response.body?.string()
SyncLog.apiResponse("API_ANSWERS", "fetchClientAnswers(${SyncLog.clientRef(clientCode)})", response.code, bodyText)
val root = parseEnvelope(response.code, bodyText)
val dataEl = root.get("data") ?: return@use ClientAnswersBundle(clientCode)
val data = when {
dataEl.isJsonObject -> SessionPayloadCrypto.decryptDataElement(dataEl.asJsonObject, token)
else -> return@use ClientAnswersBundle(clientCode)
}
val blocks = parseQuestionnaireAnswerBlocks(data.getAsJsonArray("questionnaires"))
ClientAnswersBundle(
clientCode = data.get("clientCode")?.asString ?: clientCode,
questionnaires = blocks,
)
}
}
private fun parseClientAnswerBundles(clientsArr: JsonArray?): List<ClientAnswersBundle> {
if (clientsArr == null) return emptyList()
return clientsArr.mapNotNull { clientEl ->
if (!clientEl.isJsonObject) return@mapNotNull null
val clientObj = clientEl.asJsonObject
val code = clientObj.get("clientCode")?.asString?.takeIf { it.isNotBlank() }
?: return@mapNotNull null
ClientAnswersBundle(
clientCode = code,
questionnaires = parseQuestionnaireAnswerBlocks(clientObj.getAsJsonArray("questionnaires")),
)
}
}
private fun parseQuestionnaireAnswerBlocks(qnArr: JsonArray?): List<ServerQuestionnaireAnswers> {
if (qnArr == null) return emptyList()
return qnArr.mapNotNull { item ->
if (!item.isJsonObject) return@mapNotNull null
val obj = item.asJsonObject
val qnId = obj.get("questionnaireID")?.asString?.takeIf { it.isNotBlank() }
?: return@mapNotNull null
val answersArr = obj.getAsJsonArray("answers") ?: JsonArray()
val answers = answersArr.mapNotNull { aEl ->
if (!aEl.isJsonObject) return@mapNotNull null
val a = aEl.asJsonObject
val qid = a.get("questionID")?.asString ?: return@mapNotNull null
SubmitAnswer(
questionID = qid,
answerOptionKey = a.get("answerOptionKey")?.asString,
freeTextValue = a.get("freeTextValue")?.asString,
numericValue = a.get("numericValue")?.takeIf { !it.isJsonNull }?.asDouble,
answeredAt = a.get("answeredAt")?.asLong,
)
}
ServerQuestionnaireAnswers(
questionnaireID = qnId,
completedAt = obj.get("completedAt")?.asLong,
sumPoints = obj.get("sumPoints")?.asInt ?: 0,
answers = answers,
)
}
}
suspend fun getQuestionnaire(token: String, questionnaireId: String): JsonObject = withContext(Dispatchers.IO) {
val request = authorizedRequest(token)
.url("${HttpModule.BASE_URL}/app_questionnaires?id=$questionnaireId")
.get()
.build()
client.newCall(request).execute().use { response ->
val root = parseEnvelope(response.code, response.body?.string())
root.getAsJsonObject("data") ?: throw ApiException("Questionnaire response missing data")
}
}
suspend fun submitQuestionnaire(
token: String,
requestBody: SubmitQuestionnaireRequest
): SubmitQuestionnaireResponse = withContext(Dispatchers.IO) {
val envelope = SessionPayloadCrypto.envelope(gson.toJson(requestBody), token)
val request = authorizedRequest(token)
.url("${HttpModule.BASE_URL}/app_questionnaires")
.post(gson.toJson(envelope).toRequestBody(jsonMediaType))
.build()
client.newCall(request).execute().use { response ->
val root = parseEnvelope(response.code, response.body?.string())
val data = root.getAsJsonObject("data") ?: JsonObject()
gson.fromJson(data, SubmitQuestionnaireResponse::class.java)
?: SubmitQuestionnaireResponse()
}
}
suspend fun getScoringProfiles(token: String): List<ScoringProfileDefinition> =
withContext(Dispatchers.IO) {
val request = authorizedRequest(token)
.url("${HttpModule.BASE_URL}/app_questionnaires?scoringProfiles=1")
.get()
.build()
client.newCall(request).execute().use { response ->
val bodyText = response.body?.string()
SyncLog.apiResponse("API_SCORING_PROFILES", "getScoringProfiles", response.code, bodyText)
val root = parseEnvelope(response.code, bodyText)
val dataEl = root.get("data") ?: return@use emptyList()
val data = when {
dataEl.isJsonObject -> SessionPayloadCrypto.decryptDataElement(dataEl.asJsonObject, token)
else -> return@use emptyList()
}
parseScoringProfiles(data.getAsJsonArray("profiles"))
}
}
suspend fun getScoringReview(token: String, clientCode: String? = null): List<ClientScoringReview> =
withContext(Dispatchers.IO) {
val urlBuilder = StringBuilder("${HttpModule.BASE_URL}/app_questionnaires?scoringReview=1")
if (!clientCode.isNullOrBlank()) {
urlBuilder.append("&clientCode=")
.append(java.net.URLEncoder.encode(clientCode, Charsets.UTF_8.name()))
}
val request = authorizedRequest(token).url(urlBuilder.toString()).get().build()
client.newCall(request).execute().use { response ->
val bodyText = response.body?.string()
SyncLog.apiResponse("API_SCORING_REVIEW", "getScoringReview", response.code, bodyText)
val root = parseEnvelope(response.code, bodyText)
val dataEl = root.get("data") ?: return@use emptyList()
val data = when {
dataEl.isJsonObject -> SessionPayloadCrypto.decryptDataElement(dataEl.asJsonObject, token)
else -> return@use emptyList()
}
parseScoringReviewClients(data.getAsJsonArray("clients"))
}
}
suspend fun submitCoachScoringReview(
token: String,
requestBody: CoachScoringReviewRequest,
): ScoringReviewProfile = withContext(Dispatchers.IO) {
val envelope = SessionPayloadCrypto.envelope(gson.toJson(requestBody), token)
val request = authorizedRequest(token)
.url("${HttpModule.BASE_URL}/app_questionnaires")
.post(gson.toJson(envelope).toRequestBody(jsonMediaType))
.build()
client.newCall(request).execute().use { response ->
val root = parseEnvelope(response.code, response.body?.string())
val data = root.getAsJsonObject("data") ?: throw ApiException("Review response missing data")
val profileObj = data.getAsJsonObject("profile")
?: throw ApiException("Review response missing profile")
parseScoringReviewProfile(profileObj)
?: throw ApiException("Review response profile invalid")
}
}
private fun parseScoringProfiles(arr: JsonArray?): List<ScoringProfileDefinition> {
if (arr == null) return emptyList()
return arr.mapNotNull { item ->
if (!item.isJsonObject) return@mapNotNull null
val obj = item.asJsonObject
val profileID = obj.get("profileID")?.asString?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
val members = obj.getAsJsonArray("questionnaires")?.mapNotNull { mEl ->
if (!mEl.isJsonObject) return@mapNotNull null
val m = mEl.asJsonObject
val qId = m.get("questionnaireID")?.asString?.takeIf { it.isNotBlank() }
?: return@mapNotNull null
ScoringProfileMember(
questionnaireID = qId,
weight = m.get("weight")?.asDouble ?: 1.0,
orderIndex = m.get("orderIndex")?.asInt ?: 0,
name = m.get("name")?.asString.orEmpty(),
)
} ?: emptyList()
ScoringProfileDefinition(
profileID = profileID,
name = obj.get("name")?.asString ?: profileID,
description = obj.get("description")?.asString.orEmpty(),
isActive = obj.get("isActive")?.asInt == 1 || obj.get("isActive")?.asBoolean == true,
greenMin = obj.get("greenMin")?.asInt ?: 0,
greenMax = obj.get("greenMax")?.asInt ?: 12,
yellowMin = obj.get("yellowMin")?.asInt ?: 13,
yellowMax = obj.get("yellowMax")?.asInt ?: 36,
redMin = obj.get("redMin")?.asInt ?: 37,
questionnaires = members,
)
}
}
private fun parseScoringReviewClients(arr: JsonArray?): List<ClientScoringReview> {
if (arr == null) return emptyList()
return arr.mapNotNull { item ->
if (!item.isJsonObject) return@mapNotNull null
val obj = item.asJsonObject
val code = obj.get("clientCode")?.asString?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
ClientScoringReview(
clientCode = code,
profiles = parseScoringReviewProfiles(obj.getAsJsonArray("profiles")),
)
}
}
private fun parseScoringReviewProfiles(arr: JsonArray?): List<ScoringReviewProfile> {
if (arr == null) return emptyList()
return arr.mapNotNull { item ->
if (!item.isJsonObject) return@mapNotNull null
parseScoringReviewProfile(item.asJsonObject)
}
}
private fun parseScoringReviewProfile(obj: JsonObject): ScoringReviewProfile? {
val profileID = obj.get("profileID")?.asString?.takeIf { it.isNotBlank() } ?: return null
val coachBand = obj.get("coachBand")?.takeIf { !it.isJsonNull }?.asString
return ScoringReviewProfile(
profileID = profileID,
name = obj.get("name")?.asString ?: profileID,
weightedTotal = obj.get("weightedTotal")?.asDouble ?: 0.0,
calculatedBand = obj.get("calculatedBand")?.asString ?: obj.get("band")?.asString.orEmpty(),
greenMin = obj.get("greenMin")?.asInt ?: 0,
greenMax = obj.get("greenMax")?.asInt ?: 12,
yellowMin = obj.get("yellowMin")?.asInt ?: 13,
yellowMax = obj.get("yellowMax")?.asInt ?: 36,
redMin = obj.get("redMin")?.asInt ?: 37,
coachBand = coachBand,
pendingReview = obj.get("pendingReview")?.asBoolean ?: coachBand.isNullOrBlank(),
)
}
private fun authorizedRequest(token: String): Request.Builder =
Request.Builder().header("Authorization", "Bearer $token")
private fun apiExceptionFromErrorElement(errorEl: JsonElement?, httpCode: Int): ApiException {
if (errorEl == null || errorEl.isJsonNull) {
return ApiException("Unexpected server response ($httpCode)")
}
if (errorEl.isJsonObject) {
val o = errorEl.asJsonObject
return ApiException(
o.get("message")?.asString ?: "Server error ($httpCode)",
o.get("code")?.asString,
o.get("details"),
)
}
if (errorEl.isJsonPrimitive) {
return ApiException(errorEl.asString, null, null)
}
return ApiException("Unexpected server response ($httpCode)")
}
/** User-visible text for failed API calls (includes validation breakdown). */
fun formatErrorMessage(exception: ApiException): String {
if (exception.code != "VALIDATION_FAILED") {
return exception.message ?: "Unknown error"
}
val details = exception.details?.takeIf { it.isJsonObject }?.asJsonObject ?: return exception.message ?: "Validation failed"
val errors = details.getAsJsonArray("errors") ?: return exception.message ?: "Validation failed"
val lines = mutableListOf<String>()
errors.forEach { item ->
if (!item.isJsonObject) return@forEach
val o = item.asJsonObject
val qid = o.get("questionID")?.asString?.takeIf { it.isNotBlank() }
val msg = o.get("message")?.asString ?: o.get("code")?.asString ?: "Invalid"
lines += if (qid != null) "$qid: $msg" else msg
}
return if (lines.isEmpty()) {
exception.message ?: "Validation failed"
} else {
(exception.message ?: "Validation failed") + "\n" + lines.joinToString("\n")
}
}
private fun parseEnvelope(httpCode: Int, body: String?): JsonObject {
if (body.isNullOrBlank()) {
throw ApiException("Empty server response ($httpCode)")
}
val root = try {
JsonParser.parseString(body).asJsonObject
} catch (e: Exception) {
SyncLog.e("API", "Failed to parse JSON (HTTP $httpCode, ${body?.length ?: 0} bytes)", e)
throw ApiException("Server response was not valid JSON ($httpCode). See logs for details.")
}
if (!root.has("ok")) {
throw apiExceptionFromErrorElement(root.get("error"), httpCode)
}
if (!root.get("ok").asBoolean) {
throw apiExceptionFromErrorElement(root.get("error"), httpCode)
}
return root
}
private fun parseLoginResult(data: JsonObject): LoginResult {
val token = data.get("token")?.asString ?: throw ApiException("Login response missing token")
val user = data.get("user")?.asString ?: ""
val clientsPayload = data.getAsJsonObject("clientsPayload")
val clientSource = if (clientsPayload != null) {
SessionPayloadCrypto.decryptEnvelope(clientsPayload, token)
} else {
data
}
return LoginResult(
token = token,
user = user,
role = data.get("role")?.asString,
mustChangePassword = data.get("mustChangePassword")?.asBoolean ?: false,
assignedClientCodes = parseAssignedClientCodes(clientSource),
)
}
private fun parseAssignedClientCodes(data: JsonObject): List<String> {
val sources = listOf("assignedClientCodes", "clientCodes", "clients")
return sources.firstNotNullOfOrNull { key ->
data.get(key)?.let(::extractClientCodes)
?.takeIf { it.isNotEmpty() }
}.orEmpty()
}
private fun extractClientCodes(element: JsonElement): List<String> {
if (!element.isJsonArray) return emptyList()
return element.asJsonArray.mapNotNull { item ->
when {
item.isJsonPrimitive -> item.asString
item.isJsonObject -> {
val obj = item.asJsonObject
obj.get("clientCode")?.asString ?: obj.get("code")?.asString
}
else -> null
}
}.filter { it.isNotBlank() }.distinct()
}
}

View File

@ -0,0 +1,178 @@
package com.dano.test1.network
import android.content.Context
import com.dano.test1.LanguageManager
import com.dano.test1.MyApp
import com.dano.test1.data.ClientRepository
import com.dano.test1.security.SensitiveDataCrypto
import com.dano.test1.questionnaire.GlobalValues
import com.google.gson.Gson
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import java.io.File
object QuestionnaireCache {
private const val CACHE_DIR = "api_questionnaire_cache"
private const val DETAILS_DIR = "details"
private const val TRANSLATIONS_FILE = "translations.json"
private const val QUESTIONNAIRES_FILE = "questionnaires.json"
private const val CLIENTS_FILE = "assigned_clients.json"
private const val SCORING_PROFILES_FILE = "scoring_profiles.json"
private val gson = Gson()
fun hasQuestionnaireCache(context: Context): Boolean =
questionnairesFile(context).exists() && detailsDir(context).listFiles()?.isNotEmpty() == true
fun saveTranslations(context: Context, data: JsonObject) {
// Server may return { "translations": { "en": {...} } }, directly { "en": {...} },
// or { "translations": [] } when no translations are configured yet.
val translationsElement = if (data.has("translations")) data.get("translations") else data
val translations = if (translationsElement.isJsonObject) translationsElement.asJsonObject else JsonObject()
SyncLog.d("CACHE", "saveTranslations: ${translations.keySet().size} language(s)")
writeCacheText(translationsFile(context), gson.toJson(translations))
LanguageManager.setApiTranslations(parseTranslationMap(translations))
}
fun applyTranslations(context: Context) {
val file = translationsFile(context)
if (!file.exists()) return
val parsed = runCatching { JsonParser.parseString(readCacheText(file) ?: return) }.getOrNull() ?: return
val translations = if (parsed.isJsonObject) parsed.asJsonObject else JsonObject()
LanguageManager.setApiTranslations(parseTranslationMap(translations))
}
fun parseTranslationMap(root: JsonObject): Map<String, Map<String, String>> = parseTranslations(root)
fun applyQuestionnaireTranslationsFromJson(jsonString: String) {
val root = runCatching { JsonParser.parseString(jsonString).asJsonObject }.getOrNull() ?: return
val translationsElement = root.get("translations") ?: return
if (!translationsElement.isJsonObject) return
LanguageManager.setQuestionnaireTranslations(parseTranslations(translationsElement.asJsonObject))
}
fun saveQuestionnaireList(context: Context, list: List<QuestionnaireListItem>) {
writeCacheText(questionnairesFile(context), gson.toJson(list))
}
fun getQuestionnaireList(context: Context): List<QuestionnaireListItem> {
val file = questionnairesFile(context)
if (!file.exists()) return emptyList()
return runCatching {
gson.fromJson(readCacheText(file), Array<QuestionnaireListItem>::class.java).toList()
}.getOrDefault(emptyList())
}
fun saveQuestionnaireDetail(context: Context, questionnaireId: String, detail: JsonObject) {
writeCacheText(File(detailsDir(context), "$questionnaireId.json"), gson.toJson(detail))
}
fun loadQuestionnaireJson(context: Context, fileOrId: String): String? {
val id = fileOrId.removeSuffix(".json")
val file = File(detailsDir(context), "$id.json")
return if (file.exists()) readCacheText(file) else null
}
fun getCachedStructureRevision(context: Context, questionnaireId: String): Int? {
val json = loadQuestionnaireJson(context, questionnaireId) ?: return null
return runCatching {
JsonParser.parseString(json).asJsonObject.get("structureRevision")?.asInt
}.getOrNull()?.coerceAtLeast(1)
}
fun saveAssignedClients(context: Context, clientCodes: List<String>) {
writeCacheText(clientsFile(context), gson.toJson(clientCodes.distinct()))
}
fun clearAssignedClients(context: Context) {
val file = clientsFile(context)
if (file.exists()) file.delete()
GlobalValues.LAST_CLIENT_CODE = null
GlobalValues.LOADED_CLIENT_CODE = null
}
suspend fun clearAllClientData(context: Context) {
clearAssignedClients(context)
ClientRepository.deleteAllClients()
}
/**
* Removes all interview data, questionnaire cache files, and Room tables from the device.
* Call on logout for security; does not clear auth tokens (see [SessionLogout.wipeAllLocalData]).
*/
suspend fun wipeEntireLocalStore(context: Context) {
clearAssignedClients(context)
com.dano.test1.MyApp.database.clearAllTables()
cacheDir(context).deleteRecursively()
com.dano.test1.notification.UploadReminderScheduler.cancel(context)
LanguageManager.setApiTranslations(emptyMap())
LanguageManager.setQuestionnaireTranslations(emptyMap())
}
/**
* Updates assigned clients without wiping local answers/completions for clients that stay assigned.
* (Previously deleteAllClients() CASCADE-deleted all interview data on every sync.)
*/
suspend fun replaceAssignedClients(context: Context, clientCodes: List<String>) {
val codes = clientCodes.distinct()
saveAssignedClients(context, codes)
ClientRepository.replaceClients(codes)
SyncLog.d("CACHE", "replaceAssignedClients: ${codes.size} clients")
}
fun saveScoringProfiles(context: Context, profiles: List<ScoringProfileDefinition>) {
writeCacheText(File(cacheDir(context), SCORING_PROFILES_FILE), gson.toJson(profiles))
}
fun getScoringProfiles(context: Context): List<ScoringProfileDefinition> {
val file = File(cacheDir(context), SCORING_PROFILES_FILE)
if (!file.exists()) return emptyList()
return runCatching {
gson.fromJson(readCacheText(file), Array<ScoringProfileDefinition>::class.java).toList()
}.getOrDefault(emptyList())
}
fun getAssignedClients(context: Context): List<String> {
val file = clientsFile(context)
if (!file.exists()) return emptyList()
return runCatching {
gson.fromJson(readCacheText(file), Array<String>::class.java).toList()
}.getOrDefault(emptyList())
}
private fun parseTranslations(root: JsonObject): Map<String, Map<String, String>> {
return root.entrySet().mapNotNull { (language, values) ->
if (!values.isJsonObject) return@mapNotNull null
val strings = values.asJsonObject.entrySet().mapNotNull { (key, value) ->
if (value.isJsonPrimitive) key to value.asString else null
}.toMap()
language to strings
}.toMap()
}
private fun cacheDir(context: Context): File =
File(context.filesDir, CACHE_DIR).apply { mkdirs() }
private fun detailsDir(context: Context): File =
File(cacheDir(context), DETAILS_DIR).apply { mkdirs() }
private fun translationsFile(context: Context): File =
File(cacheDir(context), TRANSLATIONS_FILE)
private fun questionnairesFile(context: Context): File =
File(cacheDir(context), QUESTIONNAIRES_FILE)
private fun clientsFile(context: Context): File =
File(cacheDir(context), CLIENTS_FILE)
private fun writeCacheText(file: File, plain: String) {
file.writeText(SensitiveDataCrypto.encrypt(plain))
}
/** Reads encrypted cache files; legacy plaintext files are still accepted. */
private fun readCacheText(file: File): String? {
if (!file.exists()) return null
val raw = file.readText()
return if (raw.startsWith("v1:")) SensitiveDataCrypto.decrypt(raw) else raw
}
}

View File

@ -0,0 +1,150 @@
package com.dano.test1.network
import android.content.Context
import com.dano.test1.data.AnswerRepository
import com.dano.test1.data.CompletedQuestionnaireRepository
import com.dano.test1.questionnaire.AnswerKeyUtils
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.google.gson.JsonParser
object QuestionnaireSubmitMapper {
private const val TAG = "SubmitMapper"
suspend fun buildRequest(
context: Context,
clientCode: String,
questionnaireId: String
): SubmitQuestionnaireRequest? {
val detailJson = QuestionnaireCache.loadQuestionnaireJson(context, questionnaireId)
if (detailJson == null) {
SyncLog.w(TAG, "No cached questionnaire JSON for $questionnaireId")
return null
}
val detail = JsonParser.parseString(detailJson).asJsonObject
val questions = detail.getAsJsonArray("questions") ?: return null
val answers = AnswerRepository.getAnswersForClientAndQuestionnaire(clientCode, questionnaireId)
.associateBy(
{ AnswerKeyUtils.lookupKey(questionnaireId, it.questionId) },
{ it.answerValue },
)
val completed = CompletedQuestionnaireRepository.getStatus(clientCode, questionnaireId)
val submitAnswers = mutableListOf<SubmitAnswer>()
questions.forEach { questionElement ->
val question = questionElement.asJsonObject
val questionId = question.get("id")?.asString ?: return@forEach
val layout = question.get("layout")?.asString.orEmpty()
resolveStoredAnswer(answers, question)?.let { value ->
submitAnswers += mapAnswer(questionId, layout, value, optionKeys(question))
}
val symptoms = question.getAsJsonArray("symptoms")
symptoms?.forEach { symptom ->
val symptomKey = symptom.asString
answers[symptomKey]?.let { value ->
submitAnswers += SubmitAnswer(
questionID = symptomKey,
answerOptionKey = value.takeIf { it.isNotBlank() }
)
}
}
}
if (submitAnswers.isEmpty()) {
SyncLog.w(
TAG,
"No mappable answers for ${SyncLog.clientRef(clientCode)} / $questionnaireId " +
"(stored keys count=${answers.size}, questions: ${questions.size()})"
)
return null
}
val structureRevision = detail.get("structureRevision")?.asInt?.coerceAtLeast(1)
return SubmitQuestionnaireRequest(
questionnaireID = questionnaireId,
clientCode = clientCode,
completedAt = completed?.timestamp?.let { it / 1000 },
structureRevision = structureRevision,
answers = submitAnswers
)
}
/**
* Resolve a stored answer for an API question object.
* Saving uses [question] (stable key) in most handlers; some use [id] (short local id).
*/
private fun resolveStoredAnswer(answers: Map<String, String>, question: JsonObject): String? {
val questionKey = question.get("question")?.asString?.takeIf { it.isNotBlank() }
val shortId = question.get("id")?.asString?.takeIf { it.isNotBlank() }
if (questionKey != null && questionKey != shortId) {
return answers[questionKey]
}
return shortId?.let { answers[it] }
}
private fun mapAnswer(
questionId: String,
layout: String,
rawValue: String,
optionKeys: Set<String>
): List<SubmitAnswer> {
val value = rawValue.trim()
if (value.isBlank()) return emptyList()
return when (layout) {
"radio_question" -> listOf(
if (optionKeys.isEmpty() || optionKeys.contains(value)) {
SubmitAnswer(questionID = questionId, answerOptionKey = value)
} else {
SubmitAnswer(questionID = questionId, freeTextValue = value)
}
)
// Country lists and similar spinners store choices in question config.options,
// not in answer_option rows — server accepts freeTextValue (see app_submit_validate).
"string_spinner" -> listOf(
SubmitAnswer(questionID = questionId, freeTextValue = value)
)
"multi_check_box_question" -> parseMultiValue(value).map {
SubmitAnswer(questionID = questionId, answerOptionKey = it)
}
"value_spinner", "slider_question" -> listOf(
SubmitAnswer(questionID = questionId, numericValue = value.toDoubleOrNull())
)
"date_spinner", "free_text" -> listOf(
SubmitAnswer(questionID = questionId, freeTextValue = value)
)
else -> listOf(
if (optionKeys.contains(value)) {
SubmitAnswer(questionID = questionId, answerOptionKey = value)
} else {
SubmitAnswer(
questionID = questionId,
numericValue = value.toDoubleOrNull(),
freeTextValue = value.takeUnless { value.toDoubleOrNull() != null }
)
}
)
}
}
private fun optionKeys(question: JsonObject): Set<String> {
val options = question.getAsJsonArray("options") ?: return emptySet()
return options.mapNotNull { option ->
when {
option.isJsonObject -> option.asJsonObject.get("key")?.asString
option.isJsonPrimitive -> option.asString
else -> null
}
}.toSet()
}
private fun parseMultiValue(value: String): List<String> {
val trimmed = value.trim().removePrefix("[").removeSuffix("]")
return trimmed.split(",", ";")
.map { it.trim().trim('"', '\'') }
.filter { it.isNotBlank() }
}
}

View File

@ -0,0 +1,262 @@
package com.dano.test1.network
import android.content.Context
import com.dano.test1.MyApp
import com.dano.test1.data.CompletedQuestionnaire
import com.dano.test1.data.Questionnaire
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
object QuestionnaireSyncService {
private const val TAG = "SYNC"
private const val DETAIL_FETCH_PARALLELISM = 8
suspend fun refreshAll(
context: Context,
token: String,
assignedClientCodes: List<String> = emptyList(),
pullAnswers: Boolean = true,
forceAnswerPull: Boolean = true,
onProgress: SyncProgressCallback? = null,
) = coroutineScope {
SyncLog.d(TAG, "refreshAll start (${assignedClientCodes.size} fallback clients)")
val pacer = SyncProgressPacer(onProgress)
pacer.emit(SyncPhase.PREPARING)
val translationsDeferred = async(Dispatchers.IO) { QuestionnaireApiClient.getTranslations(token) }
val questionnairesDeferred = async(Dispatchers.IO) { QuestionnaireApiClient.getQuestionnaires(token) }
val translations = translationsDeferred.await()
val questionnaires = questionnairesDeferred.await()
QuestionnaireCache.saveTranslations(context, translations)
QuestionnaireCache.saveQuestionnaireList(context, questionnaires)
SyncLog.d(TAG, "questionnaire list: ${questionnaires.size} items")
val db = MyApp.database
db.questionnaireDao().insertQuestionnaires(
questionnaires.map { Questionnaire(it.id) },
)
pacer.emit(SyncPhase.DOWNLOADING_QUESTIONNAIRES)
val clientsDeferred = async(Dispatchers.IO) {
runCatching { QuestionnaireApiClient.getAssignedClients(token) }
}
val scoringProfilesDeferred = async(Dispatchers.IO) {
runCatching { QuestionnaireApiClient.getScoringProfiles(token) }
}
val detailsDeferred = async {
fetchAndCacheQuestionnaireDetails(context, token, questionnaires)
}
val clientResult = clientsDeferred.await()
scoringProfilesDeferred.await().onSuccess { profiles ->
QuestionnaireCache.saveScoringProfiles(context, profiles)
SyncLog.d(TAG, "scoring profiles cached: ${profiles.size}")
}.onFailure { e ->
SyncLog.w(TAG, "getScoringProfiles failed: ${e.message}")
}
pacer.emit(SyncPhase.DOWNLOADING_CLIENT_INFO)
val clientInfosFromApi = clientResult.getOrElse { e ->
SyncLog.w(TAG, "getAssignedClients failed: ${e.message}")
emptyList()
}
val apiSucceeded = clientResult.isSuccess
val clientCodesToSave: List<String> = when {
apiSucceeded -> clientInfosFromApi.map { it.clientCode }
assignedClientCodes.isNotEmpty() -> assignedClientCodes
else -> emptyList()
}
persistAssignedClients(context, apiSucceeded, assignedClientCodes, clientCodesToSave)
if (!forceAnswerPull || !pullAnswers) {
seedServerCompletions(clientInfosFromApi)
}
detailsDeferred.await()
if (pullAnswers && apiSucceeded && clientCodesToSave.isNotEmpty()) {
val toPull = QuestionnaireAnswerSync.clientsNeedingPull(
context,
clientCodesToSave,
forceAnswerPull,
)
if (toPull.isNotEmpty()) {
pacer.emit(SyncPhase.DOWNLOADING_ANSWERS)
val bulkDeferred = async(Dispatchers.IO) {
runCatching { QuestionnaireApiClient.fetchBulkClientAnswers(token) }
}
val bulkResult = bulkDeferred.await()
val prefetchedBundles = bulkResult.getOrNull()
if (bulkResult.isFailure) {
SyncLog.w(
TAG,
"bulk answer pull failed, import will retry fetch: ${bulkResult.exceptionOrNull()?.message}",
)
}
QuestionnaireAnswerSync.pullForAllAssignedClients(
context,
token,
toPull,
force = forceAnswerPull,
prefetchedBundles = prefetchedBundles,
pacer = pacer,
)
} else {
SyncLog.d(TAG, "all ${clientCodesToSave.size} clients fresh — skip answer pull")
}
}
pacer.emitFinished()
SyncLog.d(TAG, "refreshAll complete")
}
suspend fun refreshAssignedClients(context: Context, token: String): Boolean {
SyncLog.d(TAG, "refreshAssignedClients start")
val apiSucceeded = fetchAndPersistAssignedClients(
context = context,
token = token,
fallbackCodes = emptyList(),
pullAnswers = true,
forceAnswerPull = false,
onProgress = null,
)
val saved = QuestionnaireCache.getAssignedClients(context)
SyncLog.d(TAG, "refreshAssignedClients done: ${saved.size} clients, api=$apiSucceeded")
return apiSucceeded
}
private suspend fun fetchAndPersistAssignedClients(
context: Context,
token: String,
fallbackCodes: List<String>,
pullAnswers: Boolean = false,
forceAnswerPull: Boolean = false,
onProgress: SyncProgressCallback? = null,
): Boolean {
val pacer = SyncProgressPacer(onProgress)
pacer.emit(SyncPhase.DOWNLOADING_CLIENT_INFO)
val clientResult = runCatching {
QuestionnaireApiClient.getAssignedClients(token)
}
val clientInfosFromApi = clientResult.getOrElse { e ->
SyncLog.w(TAG, "getAssignedClients failed: ${e.message}")
emptyList()
}
val apiSucceeded = clientResult.isSuccess
val clientCodesToSave: List<String> = when {
apiSucceeded -> clientInfosFromApi.map { it.clientCode }
fallbackCodes.isNotEmpty() -> fallbackCodes
else -> emptyList()
}
persistAssignedClients(context, apiSucceeded, fallbackCodes, clientCodesToSave)
if (!forceAnswerPull || !pullAnswers) {
seedServerCompletions(clientInfosFromApi)
}
if (pullAnswers && apiSucceeded && clientCodesToSave.isNotEmpty()) {
val toPull = QuestionnaireAnswerSync.clientsNeedingPull(
context,
clientCodesToSave,
forceAnswerPull,
)
if (toPull.isNotEmpty()) {
QuestionnaireAnswerSync.pullForAllAssignedClients(
context,
token,
toPull,
force = forceAnswerPull,
pacer = pacer,
)
}
}
return apiSucceeded
}
private suspend fun persistAssignedClients(
context: Context,
apiSucceeded: Boolean,
fallbackCodes: List<String>,
clientCodesToSave: List<String>,
) {
when {
apiSucceeded -> {
QuestionnaireCache.replaceAssignedClients(context, clientCodesToSave)
SyncLog.d(TAG, "assigned clients from API: ${clientCodesToSave.size}")
}
fallbackCodes.isNotEmpty() -> {
QuestionnaireCache.replaceAssignedClients(context, clientCodesToSave)
SyncLog.d(TAG, "assigned clients from login fallback: ${clientCodesToSave.size}")
}
else -> SyncLog.w(TAG, "client fetch failed; keeping clients from login step only")
}
}
private suspend fun seedServerCompletions(clientInfosFromApi: List<AssignedClientInfo>) {
val toInsert = mutableListOf<CompletedQuestionnaire>()
for (clientInfo in clientInfosFromApi) {
val existingByQn = com.dano.test1.data.CompletedQuestionnaireRepository
.getAllForClient(clientInfo.clientCode)
.associateBy { it.questionnaireId }
for (serverCompletion in clientInfo.completedQuestionnaires) {
val serverTimestampMs = serverCompletion.completedAt * 1000L
val existing = existingByQn[serverCompletion.questionnaireID]
if (existing == null || serverTimestampMs > existing.timestamp) {
toInsert.add(
CompletedQuestionnaire(
clientCode = clientInfo.clientCode,
questionnaireId = serverCompletion.questionnaireID,
timestamp = serverTimestampMs,
isDone = true,
sumPoints = serverCompletion.sumPoints,
uploadedAt = serverTimestampMs,
),
)
}
}
}
if (toInsert.isNotEmpty()) {
runCatching {
com.dano.test1.data.CompletedQuestionnaireRepository.insertAll(toInsert)
}.onSuccess {
SyncLog.d(TAG, "seeded ${toInsert.size} server completion(s)")
}.onFailure { e ->
SyncLog.w(TAG, "batch seed completions failed: ${e.message}")
}
}
}
fun loadCachedTranslations(context: Context) {
QuestionnaireCache.applyTranslations(context)
}
private suspend fun fetchAndCacheQuestionnaireDetails(
context: Context,
token: String,
questionnaires: List<QuestionnaireListItem>,
) = coroutineScope {
if (questionnaires.isEmpty()) return@coroutineScope
val gate = Semaphore(DETAIL_FETCH_PARALLELISM)
questionnaires.map { item ->
async(Dispatchers.IO) {
gate.withPermit {
val cachedRev = QuestionnaireCache.getCachedStructureRevision(context, item.id)
if (cachedRev != null && item.structureRevision <= cachedRev) {
return@withPermit
}
val detail = QuestionnaireApiClient.getQuestionnaire(token, item.id)
QuestionnaireCache.saveQuestionnaireDetail(context, item.id, detail)
}
}
}.awaitAll()
SyncLog.d(TAG, "questionnaire details cached: ${questionnaires.size}")
}
}

View File

@ -0,0 +1,104 @@
package com.dano.test1.network
import android.content.Context
import android.widget.Toast
import com.dano.test1.LanguageManager
sealed class SyncResult {
data object Success : SyncResult()
data class Failure(val error: Throwable?, val usedOfflineCache: Boolean = false) : SyncResult()
data object AuthRequired : SyncResult()
data object Offline : SyncResult()
}
object SyncCoordinator {
fun loadCachedTranslations(context: Context) {
QuestionnaireSyncService.loadCachedTranslations(context)
}
/** Fetches app UI strings without login (login screen + offline cache refresh). */
suspend fun refreshPublicTranslations(context: Context): Boolean {
if (!NetworkUtils.isOnline(context)) return false
return runCatching {
val data = QuestionnaireApiClient.getPublicTranslations()
QuestionnaireCache.saveTranslations(context, data)
}.isSuccess
}
suspend fun refreshAfterLogin(
context: Context,
token: String,
languageId: String,
onProgress: SyncProgressCallback? = null,
): SyncResult {
if (!NetworkUtils.isOnline(context)) {
loadCachedTranslations(context)
return SyncResult.Offline
}
val result = runCatching {
QuestionnaireSyncService.refreshAll(
context = context,
token = token,
onProgress = onProgress,
)
}
val error = result.exceptionOrNull()
if (error != null && TokenStore.isAuthError(error)) {
return SyncResult.AuthRequired
}
if (result.isSuccess) return SyncResult.Success
loadCachedTranslations(context)
return SyncResult.Failure(error, usedOfflineCache = true)
}
suspend fun refreshManual(
context: Context,
token: String,
onProgress: SyncProgressCallback? = null,
): SyncResult {
if (!NetworkUtils.isOnline(context)) return SyncResult.Offline
val result = runCatching {
QuestionnaireSyncService.refreshAll(
context = context,
token = token,
onProgress = onProgress,
)
}
val error = result.exceptionOrNull()
if (error != null && TokenStore.isAuthError(error)) return SyncResult.AuthRequired
return if (result.isSuccess) SyncResult.Success else SyncResult.Failure(error)
}
suspend fun refreshClientsOnly(context: Context, token: String): SyncResult {
if (!NetworkUtils.isOnline(context)) return SyncResult.Offline
val result = runCatching {
QuestionnaireSyncService.refreshAssignedClients(context, token)
}
val error = result.exceptionOrNull()
if (error != null && TokenStore.isAuthError(error)) return SyncResult.AuthRequired
return if (result.isSuccess) SyncResult.Success else SyncResult.Failure(error)
}
fun toastForResult(context: Context, languageId: String, result: SyncResult) {
when (result) {
is SyncResult.Failure -> {
if (result.usedOfflineCache) {
Toast.makeText(
context,
LanguageManager.getText(languageId, "download_failed_use_offline"),
Toast.LENGTH_LONG,
).show()
}
}
SyncResult.Offline -> {
Toast.makeText(
context,
LanguageManager.getText(languageId, "offline"),
Toast.LENGTH_LONG,
).show()
}
else -> {}
}
}
}

View File

@ -0,0 +1,37 @@
package com.dano.test1.network
import android.util.Log
import com.dano.test1.BuildConfig
import java.security.MessageDigest
/** Debug-gated logging for sync/API paths (avoids logging full JSON bodies in production). */
internal object SyncLog {
fun d(tag: String, message: String) {
if (BuildConfig.DEBUG) Log.d(tag, message)
}
fun w(tag: String, message: String) {
if (BuildConfig.DEBUG) Log.w(tag, message)
}
fun e(tag: String, message: String, throwable: Throwable? = null) {
if (!BuildConfig.DEBUG) return
if (throwable != null) Log.e(tag, message, throwable) else Log.e(tag, message)
}
fun apiResponse(tag: String, label: String, httpCode: Int, body: String?) {
if (!BuildConfig.DEBUG) return
val bytes = body?.length ?: 0
Log.d(tag, "$label → HTTP $httpCode (${bytes} bytes)")
}
fun clientRef(clientCode: String?): String {
val normalized = clientCode?.trim().orEmpty()
if (normalized.isEmpty()) return "client=<blank>"
val digest = MessageDigest.getInstance("SHA-256")
.digest(normalized.uppercase().toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
.take(8)
return "clientHash=$digest"
}
}

View File

@ -0,0 +1,18 @@
package com.dano.test1.network
/** User-facing sync step — no counts; shown as friendly status text only. */
enum class SyncPhase {
PREPARING,
DOWNLOADING_QUESTIONNAIRES,
DOWNLOADING_CLIENT_INFO,
DOWNLOADING_ANSWERS,
PROCESSING,
SECURELY_SAVING,
FINISHED,
}
data class SyncProgress(
val phase: SyncPhase,
)
typealias SyncProgressCallback = (SyncProgress) -> Unit

View File

@ -0,0 +1,30 @@
package com.dano.test1.network
import kotlinx.coroutines.delay
/** Keeps each sync phase visible briefly without the old long artificial pause. */
class SyncProgressPacer(
private val delegate: SyncProgressCallback?,
) {
private var lastEmitMs: Long = 0L
suspend fun emit(phase: SyncPhase) {
val callback = delegate ?: return
val now = System.currentTimeMillis()
if (lastEmitMs > 0L) {
val wait = MIN_PHASE_MS - (now - lastEmitMs)
if (wait > 0L) delay(wait)
}
callback(SyncProgress(phase))
lastEmitMs = System.currentTimeMillis()
}
suspend fun emitFinished(phase: SyncPhase = SyncPhase.FINISHED) {
emit(phase)
delay(MIN_PHASE_MS)
}
companion object {
const val MIN_PHASE_MS = 1_000L
}
}

View File

@ -0,0 +1,86 @@
package com.dano.test1.network
import android.content.Context
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
object TokenStore {
private const val TAG = "TokenStore"
private const val PREF = "qdb_secure_prefs"
private const val KEY_TOKEN = "token"
private const val KEY_USER = "user"
private const val KEY_LOGIN_TS = "login_ts"
private const val SESSION_TTL_MS = 30L * 24 * 60 * 60 * 1000
private fun prefs(context: Context) = EncryptedSharedPreferences.create(
context,
PREF,
MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
fun save(context: Context, token: String, username: String) {
val now = System.currentTimeMillis()
prefs(context).edit()
.putString(KEY_TOKEN, token)
.putString(KEY_USER, username)
.putLong(KEY_LOGIN_TS, now)
.apply()
}
fun getToken(context: Context): String? =
prefs(context).getString(KEY_TOKEN, null)
fun getUsername(context: Context): String? =
prefs(context).getString(KEY_USER, null)
fun getLoginTimestamp(context: Context): Long =
prefs(context).getLong(KEY_LOGIN_TS, 0L)
fun clear(context: Context) {
prefs(context).edit().clear().apply()
}
fun hasToken(context: Context): Boolean = !getToken(context).isNullOrBlank()
fun ensureLegacyLoginTimestamp(context: Context) {
if (!hasToken(context)) return
if (getLoginTimestamp(context) != 0L) return
val now = System.currentTimeMillis()
prefs(context).edit().putLong(KEY_LOGIN_TS, now).apply()
Log.d(TAG, "Set login_ts for session without timestamp")
}
fun isSessionValid(context: Context): Boolean {
ensureLegacyLoginTimestamp(context)
val token = getToken(context)
if (token.isNullOrBlank()) return false
val ts = getLoginTimestamp(context)
if (ts <= 0L) return false
return System.currentTimeMillis() - ts < SESSION_TTL_MS
}
fun isAuthError(throwable: Throwable): Boolean {
val msg = throwable.message.orEmpty()
if (throwable is ApiException) {
val code = throwable.code.orEmpty()
if (code == "UNAUTHORIZED"
|| code == "FORBIDDEN"
|| code == "PASSWORD_CHANGE_REQUIRED"
) {
return true
}
}
return msg.contains("401", ignoreCase = true)
|| msg.contains("403", ignoreCase = true)
|| msg.contains("Invalid or expired token", ignoreCase = true)
|| msg.contains("Missing Bearer token", ignoreCase = true)
|| msg.contains("Insufficient permissions", ignoreCase = true)
|| msg.contains("Password change required", ignoreCase = true)
}
}

View File

@ -0,0 +1,85 @@
package com.dano.test1.notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.dano.test1.MainActivity
import com.dano.test1.MyApp
import com.dano.test1.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class UploadReminderReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val pendingResult = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
// Only fire the notification if there are actually pending uploads
val hasPending = UploadReminderScheduler.hasPendingUploads()
if (hasPending) {
withContext(Dispatchers.Main) {
showNotification(context)
}
}
} finally {
pendingResult.finish()
}
}
}
private fun showNotification(context: Context) {
ensureChannel(context)
val tapIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = PendingIntent.getActivity(
context, 0, tapIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_dot_16)
.setContentTitle("Data ready to upload")
.setContentText("You have completed questionnaires that have not been uploaded yet.")
.setStyle(NotificationCompat.BigTextStyle()
.bigText("You have completed questionnaires that have not been uploaded yet. Tap to open the app and upload."))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()
try {
NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification)
} catch (e: SecurityException) {
// POST_NOTIFICATIONS not granted — silently skip
}
}
private fun ensureChannel(context: Context) {
val manager = context.getSystemService(NotificationManager::class.java)
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
val channel = NotificationChannel(
CHANNEL_ID,
"Upload Reminders",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Reminds you to upload completed questionnaires"
}
manager.createNotificationChannel(channel)
}
companion object {
const val CHANNEL_ID = "upload_reminder"
const val NOTIFICATION_ID = 1001
}
}

View File

@ -0,0 +1,68 @@
package com.dano.test1.notification
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
object UploadReminderScheduler {
private const val TAG = "UploadReminder"
private const val DELAY_MS = 2 * 60 * 60 * 1000L // 2 hours
/**
* Schedule (or re-schedule) a reminder alarm for 2 hours from now.
* Call this every time a questionnaire is marked as completed.
* Safe to call multiple times — replaces any existing alarm.
*/
fun schedule(context: Context) {
val alarmManager = context.getSystemService(AlarmManager::class.java) ?: return
val triggerAt = System.currentTimeMillis() + DELAY_MS
// On API 31+ we need canScheduleExactAlarms(); fall back to inexact if not granted
val pi = buildPendingIntent(context)
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) {
alarmManager.set(AlarmManager.RTC_WAKEUP, triggerAt, pi)
Log.d(TAG, "Inexact alarm scheduled (no SCHEDULE_EXACT_ALARM permission)")
} else {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi)
Log.d(TAG, "Exact alarm scheduled for +2h")
}
} catch (e: SecurityException) {
Log.w(TAG, "Could not schedule alarm: ${e.message}")
}
}
/**
* Cancel any pending upload reminder. Call after a successful upload.
*/
fun cancel(context: Context) {
val alarmManager = context.getSystemService(AlarmManager::class.java) ?: return
alarmManager.cancel(buildPendingIntent(context))
Log.d(TAG, "Alarm cancelled")
}
/**
* Returns true if any assigned client has a pending (completed but not uploaded) questionnaire.
* Runs on the calling thread — call from a background coroutine.
*/
suspend fun hasPendingUploads(): Boolean {
return try {
com.dano.test1.data.PendingUploadRepository.hasAny()
} catch (e: Exception) {
Log.w(TAG, "hasPendingUploads check failed: ${e.message}")
false
}
}
private fun buildPendingIntent(context: Context): PendingIntent {
val intent = Intent(context, UploadReminderReceiver::class.java)
return PendingIntent.getBroadcast(
context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
}

View File

@ -0,0 +1,24 @@
package com.dano.test1.questionnaire
/**
* Stable keys for Room answer rows and submit mapping.
*/
object AnswerKeyUtils {
/** Room `questions.questionId` / `answers.questionId` format. */
fun roomQuestionId(questionnaireId: String, answerKey: String): String =
"$questionnaireId-$answerKey"
/**
* Lookup key inside a per-client answer map.
* Accepts full room ids (`questionnaireId-key`) or short ids.
*/
fun lookupKey(questionnaireId: String, storedQuestionId: String): String {
val prefix = "$questionnaireId-"
return if (storedQuestionId.startsWith(prefix)) {
storedQuestionId.removePrefix(prefix)
} else {
storedQuestionId.substringAfterLast('-')
}
}
}

View File

@ -0,0 +1,23 @@
package com.dano.test1.questionnaire
/**
* Resolves stored answers from the in-memory map using the same keys as persistence/sync.
*/
object AnswerLookup {
fun stringValue(answers: Map<String, Any>, question: QuestionItem.StringSpinnerQuestion): String? =
firstString(answers, listOf(question.question, question.id))
fun valueSpinnerValue(answers: Map<String, Any>, question: QuestionItem.ValueSpinnerQuestion): String? =
firstString(answers, listOf(question.question, question.id))
private fun firstString(answers: Map<String, Any>, keys: List<String>): String? {
for (key in keys) {
if (key.isBlank()) continue
val raw = answers[key] ?: continue
val text = raw.toString().trim()
if (text.isNotEmpty()) return text
}
return null
}
}

View File

@ -0,0 +1,37 @@
package com.dano.test1.questionnaire
import com.dano.test1.data.AnswerRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
object AnswerRestoreHelper {
/**
* Loads a prior answer from Room when the in-memory map has no value yet.
*/
fun restoreIfMissing(
scope: CoroutineScope,
questionnaireId: String,
answerMapKey: String,
onRestored: (String) -> Unit,
) {
scope.launch(Dispatchers.IO) {
try {
val clientCode = GlobalValues.LAST_CLIENT_CODE
if (clientCode.isNullOrBlank() || answerMapKey.isBlank()) return@launch
val roomId = AnswerKeyUtils.roomQuestionId(questionnaireId, answerMapKey)
val dbAnswer = AnswerRepository.findAnswerValue(
AnswerRepository.getAnswersForClient(clientCode),
roomId,
)
if (!dbAnswer.isNullOrBlank()) {
withContext(Dispatchers.Main) { onRestored(dbAnswer) }
}
} catch (_: Exception) {
}
}
}
}

View File

@ -0,0 +1,69 @@
package com.dano.test1.questionnaire
import com.dano.test1.LanguageManager
/**
* Single source for coach language selection across opening screen and questionnaires.
*/
object AppLanguages {
const val DEFAULT_ID = "GERMAN"
val ids: List<String> = listOf(
"GERMAN", "ENGLISH", "FRENCH", "ROMANIAN", "ARABIC",
"POLISH", "TURKISH", "UKRAINIAN", "RUSSIAN", "SPANISH",
)
/** Short labels shown on in-questionnaire language spinners. */
val shortLabels: List<String> = listOf(
"DE", "EN", "FR", "RO", "AR",
"PL", "TR", "UA", "RU", "ES",
)
/** Human-readable names for language spinners (opening screen, upload). */
fun spinnerLabels(uiLanguageId: String): List<String> =
ids.map { displayName(uiLanguageId, it) }
fun displayName(uiLanguageId: String, languageId: String): String {
val apiKey = "language_${languageId.lowercase()}"
val fromApi = LanguageManager.getText(uiLanguageId, apiKey)
if (fromApi != apiKey) return fromApi
return nativeNamesFor(uiLanguageId)[languageId]
?: nativeNamesFor("ENGLISH")[languageId]
?: shortLabels.getOrElse(indexOf(languageId)) { languageId }
}
fun indexOf(languageId: String): Int =
ids.indexOf(languageId).coerceAtLeast(0)
private fun nativeNamesFor(uiLanguageId: String): Map<String, String> = when (uiLanguageId) {
"GERMAN" -> NAMES_DE
"ENGLISH" -> NAMES_EN
else -> NAMES_EN
}
private val NAMES_DE = mapOf(
"GERMAN" to "Deutsch",
"ENGLISH" to "Englisch",
"FRENCH" to "Französisch",
"ROMANIAN" to "Rumänisch",
"ARABIC" to "Arabisch",
"POLISH" to "Polnisch",
"TURKISH" to "Türkisch",
"UKRAINIAN" to "Ukrainisch",
"RUSSIAN" to "Russisch",
"SPANISH" to "Spanisch",
)
private val NAMES_EN = mapOf(
"GERMAN" to "German",
"ENGLISH" to "English",
"FRENCH" to "French",
"ROMANIAN" to "Romanian",
"ARABIC" to "Arabic",
"POLISH" to "Polish",
"TURKISH" to "Turkish",
"UKRAINIAN" to "Ukrainian",
"RUSSIAN" to "Russian",
"SPANISH" to "Spanish",
)
}

View File

@ -0,0 +1,19 @@
package com.dano.test1.questionnaire
import android.content.Context
import com.dano.test1.network.QuestionnaireCache
object ClientAssignmentGuard {
fun isAssigned(context: Context, clientCode: String): Boolean {
if (clientCode.isBlank()) return false
val assigned = QuestionnaireCache.getAssignedClients(context)
return assigned.isNotEmpty() &&
assigned.any { it.equals(clientCode, ignoreCase = true) }
}
fun canPersistAnswers(context: Context, answers: Map<String, Any>): Boolean {
val clientCode = answers["client_code"] as? String ?: return false
return isAssigned(context, clientCode)
}
}

View File

@ -0,0 +1,88 @@
package com.dano.test1.questionnaire
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
/**
* Parses questionnaire availability conditions from server [conditionJson] / API `condition` objects.
* Same schema as web dashboard and legacy assets/questionnaire_order.json.
*/
object ConditionJsonParser {
private const val TAG = "ConditionJsonParser"
/** App string key for locked-card subtitle / toast (from conditionJson.messageKey). */
fun messageKey(conditionJson: String?): String? {
if (conditionJson.isNullOrBlank() || conditionJson.trim() == "{}") return null
return try {
JSONObject(conditionJson).optString("messageKey").takeIf { it.isNotBlank() }
} catch (e: Exception) {
Log.w(TAG, "messageKey parse failed: ${e.message}")
null
}
}
fun parse(conditionJson: String?): QuestionItem.Condition? {
if (conditionJson.isNullOrBlank() || conditionJson.trim() == "{}") return null
return try {
parse(JSONObject(conditionJson))
} catch (e: Exception) {
Log.w(TAG, "Invalid condition JSON: ${e.message}")
null
}
}
fun parse(conditionObj: JSONObject?): QuestionItem.Condition? {
if (conditionObj == null) return null
if (conditionObj.has("anyOf")) {
val arr = conditionObj.optJSONArray("anyOf") ?: JSONArray()
val conditions = mutableListOf<QuestionItem.Condition>()
for (i in 0 until arr.length()) {
val sub = arr.optJSONObject(i)
parse(sub)?.let { conditions.add(it) }
}
return QuestionItem.Condition.AnyOf(conditions)
}
if (conditionObj.has("alwaysAvailable") && conditionObj.optBoolean("alwaysAvailable", false)) {
return QuestionItem.Condition.AlwaysAvailable
}
val requiresList = mutableListOf<String>()
if (conditionObj.has("requiresCompleted")) {
val reqArr = conditionObj.optJSONArray("requiresCompleted")
if (reqArr != null) {
for (i in 0 until reqArr.length()) requiresList.add(reqArr.optString(i))
} else {
conditionObj.optString("requiresCompleted")?.let { if (it.isNotBlank()) requiresList.add(it) }
}
}
val questionnaire = conditionObj.optString("questionnaire").takeIf { it.isNotBlank() }
val questionId = conditionObj.optString("questionId").takeIf { it.isNotBlank() }
val operator = conditionObj.optString("operator").takeIf { it.isNotBlank() }
val value = conditionObj.optString("value").takeIf { it.isNotBlank() }
val hasQuestionCheck =
!questionnaire.isNullOrBlank() && !questionId.isNullOrBlank() &&
!operator.isNullOrBlank() && value != null
return when {
requiresList.isNotEmpty() && hasQuestionCheck ->
QuestionItem.Condition.Combined(
requiresList,
QuestionItem.Condition.QuestionCondition(
questionnaire!!,
questionId!!,
operator!!,
value!!
)
)
hasQuestionCheck ->
QuestionItem.Condition.QuestionCondition(
questionnaire!!,
questionId!!,
operator!!,
value!!
)
requiresList.isNotEmpty() ->
QuestionItem.Condition.RequiresCompleted(requiresList)
else -> null
}
}
}

View File

@ -0,0 +1,141 @@
package com.dano.test1.questionnaire
import com.dano.test1.LanguageManager
object QuestionFlowResolver {
fun questionLevelNextId(nextQuestionId: String?): String? =
nextQuestionId?.trim()?.takeIf { it.isNotEmpty() }
fun navigateAfterAnswer(
nextQuestionId: String?,
goToQuestionById: (String) -> Unit,
goToNextQuestion: () -> Unit,
) {
val nextId = questionLevelNextId(nextQuestionId)
if (nextId != null) {
goToQuestionById(nextId)
} else {
goToNextQuestion()
}
}
fun nextQuestionIdForRadio(question: QuestionItem.RadioQuestion, selectedKey: String): String? =
question.options?.find { it.key == selectedKey }?.nextQuestionId
fun nextQuestionIdForValueSpinner(
question: QuestionItem.ValueSpinnerQuestion,
selectedValue: Int?,
): String? {
if (selectedValue != null) {
question.options?.find { it.value == selectedValue }?.nextQuestionId
?.trim()
?.takeIf { it.isNotEmpty() }
?.let { return it }
}
return questionLevelNextId(question.nextQuestionId)
}
fun nextQuestionIdForMultiCheckbox(
question: QuestionItem.MultiCheckboxQuestion,
selectedKeys: Set<String>,
): String? {
val otherKey = question.otherOptionKey?.trim()?.takeIf { it.isNotEmpty() }
val otherNext = question.otherNextQuestionId?.trim()?.takeIf { it.isNotEmpty() }
if (otherKey != null && otherNext != null && selectedKeys.contains(otherKey)) {
return otherNext
}
return questionLevelNextId(question.nextQuestionId)
}
fun nextQuestionIdForStringSpinner(
question: QuestionItem.StringSpinnerQuestion,
languageId: String,
selected: String,
): String? {
val otherKey = question.otherOptionKey?.trim()?.takeIf { it.isNotEmpty() }
val otherNext = question.otherNextQuestionId?.trim()?.takeIf { it.isNotEmpty() }
if (otherKey != null && otherNext != null) {
val otherLabel = LanguageManager.getText(languageId, otherKey)
if (selected == otherLabel || selected == otherKey) return otherNext
}
return questionLevelNextId(question.nextQuestionId)
}
fun hasBranchingOptions(question: QuestionItem): Boolean = when (question) {
is QuestionItem.RadioQuestion ->
question.options?.any { !it.nextQuestionId.isNullOrBlank() } == true
is QuestionItem.ValueSpinnerQuestion ->
question.options?.any { !it.nextQuestionId.isNullOrBlank() } == true ||
!question.nextQuestionId.isNullOrBlank()
is QuestionItem.StringSpinnerQuestion ->
!question.nextQuestionId.isNullOrBlank() || !question.otherNextQuestionId.isNullOrBlank()
is QuestionItem.FreeTextQuestion ->
!question.nextQuestionId.isNullOrBlank()
is QuestionItem.MultiCheckboxQuestion ->
!question.nextQuestionId.isNullOrBlank() || !question.otherNextQuestionId.isNullOrBlank()
is QuestionItem.DateSpinnerQuestion ->
!question.nextQuestionId.isNullOrBlank()
is QuestionItem.SliderQuestion ->
!question.nextQuestionId.isNullOrBlank()
is QuestionItem.GlassScaleQuestion ->
!question.nextQuestionId.isNullOrBlank()
is QuestionItem.ClientCoachCodeQuestion ->
!question.nextQuestionId.isNullOrBlank()
else -> false
}
/**
* Walk questions in order using [answerProvider] for branching decisions (All-in-One visibility).
*/
fun visibleQuestionIndices(
questions: List<QuestionItem>,
answerProvider: (QuestionItem) -> BranchAnswer?,
): Set<Int> {
val visible = mutableSetOf<Int>()
var i = 0
while (i < questions.size) {
val q = questions[i]
if (q is QuestionItem.LastPage) {
i++
continue
}
visible.add(i)
val branch = answerProvider(q)
val nextId = branch?.let { resolveNextId(q, it) }
when {
nextId != null -> {
if (questions.any { it is QuestionItem.LastPage && it.id == nextId }) break
val target = questions.indexOfFirst { it.id == nextId }
i = if (target != -1) target else i + 1
}
hasBranchingOptions(q) && branch == null -> break
else -> i++
}
}
return visible
}
private fun resolveNextId(question: QuestionItem, branch: BranchAnswer): String? = when (question) {
is QuestionItem.RadioQuestion -> branch.selectedKey?.let { nextQuestionIdForRadio(question, it) }
is QuestionItem.ValueSpinnerQuestion -> nextQuestionIdForValueSpinner(question, branch.selectedInt)
is QuestionItem.FreeTextQuestion -> questionLevelNextId(question.nextQuestionId)
is QuestionItem.StringSpinnerQuestion ->
branch.selectedDisplay?.let { nextQuestionIdForStringSpinner(question, branch.languageId, it) }
is QuestionItem.MultiCheckboxQuestion ->
branch.selectedKeys?.let { nextQuestionIdForMultiCheckbox(question, it) }
is QuestionItem.DateSpinnerQuestion -> questionLevelNextId(question.nextQuestionId)
is QuestionItem.SliderQuestion -> questionLevelNextId(question.nextQuestionId)
is QuestionItem.GlassScaleQuestion -> questionLevelNextId(question.nextQuestionId)
is QuestionItem.ClientCoachCodeQuestion -> questionLevelNextId(question.nextQuestionId)
else -> null
}
data class BranchAnswer(
val languageId: String,
val selectedKey: String? = null,
val selectedInt: Int? = null,
val selectedDisplay: String? = null,
val selectedKeys: Set<String>? = null,
)
}

View File

@ -0,0 +1,10 @@
package com.dano.test1.questionnaire
import android.view.View
interface QuestionHandler {
fun bind(layout: View, question: QuestionItem)
fun validate(): Boolean
fun saveAnswer()
}

View File

@ -0,0 +1,73 @@
package com.dano.test1.questionnaire
import android.content.Context
import com.dano.test1.questionnaire.handlers.HandlerClientCoachCode
import com.dano.test1.questionnaire.handlers.HandlerDateSpinner
import com.dano.test1.questionnaire.handlers.HandlerFreeText
import com.dano.test1.questionnaire.handlers.HandlerGlassScaleQuestion
import com.dano.test1.questionnaire.handlers.HandlerLastPage
import com.dano.test1.questionnaire.handlers.HandlerMultiCheckboxQuestion
import com.dano.test1.questionnaire.handlers.HandlerRadioQuestion
import com.dano.test1.questionnaire.handlers.HandlerSliderQuestion
import com.dano.test1.questionnaire.handlers.HandlerStringSpinner
import com.dano.test1.questionnaire.handlers.HandlerValueSpinner
enum class NavigationMode { Step, Embedded }
data class HandlerDependencies(
val context: Context,
val answers: MutableMap<String, Any>,
val languageID: String,
val questionnaireMetaId: String,
val goToNext: () -> Unit,
val goToPrev: () -> Unit,
val goToById: (String) -> Unit,
val showToast: (String) -> Unit,
val saveAnswers: suspend (Map<String, Any>) -> Unit = {},
val onAnswerChanged: () -> Unit = {},
)
object QuestionHandlerFactory {
fun create(question: QuestionItem, mode: NavigationMode, deps: HandlerDependencies): QuestionHandler? {
val next = if (mode == NavigationMode.Embedded) ({}) else deps.goToNext
val prev = if (mode == NavigationMode.Embedded) ({}) else deps.goToPrev
val byId: (String) -> Unit = if (mode == NavigationMode.Embedded) ({ _ -> }) else deps.goToById
return when (question) {
is QuestionItem.RadioQuestion -> HandlerRadioQuestion(
deps.context, deps.answers, deps.languageID,
next, prev, byId, deps.showToast, deps.questionnaireMetaId,
)
is QuestionItem.ClientCoachCodeQuestion -> HandlerClientCoachCode(
deps.answers, deps.languageID, next, prev, byId, deps.showToast,
)
is QuestionItem.DateSpinnerQuestion -> HandlerDateSpinner(
deps.context, deps.answers, deps.languageID, next, prev, byId, deps.showToast, deps.questionnaireMetaId,
)
is QuestionItem.ValueSpinnerQuestion -> HandlerValueSpinner(
deps.context, deps.answers, deps.languageID, next, prev, byId, deps.showToast, deps.questionnaireMetaId,
)
is QuestionItem.SliderQuestion -> HandlerSliderQuestion(
deps.context, deps.answers, deps.languageID, next, prev, byId, deps.showToast, deps.onAnswerChanged,
)
is QuestionItem.GlassScaleQuestion -> HandlerGlassScaleQuestion(
deps.context, deps.answers, deps.languageID, next, prev, byId, deps.showToast, deps.questionnaireMetaId,
onAnswerChanged = deps.onAnswerChanged,
)
is QuestionItem.StringSpinnerQuestion -> HandlerStringSpinner(
deps.context, deps.answers, deps.languageID, next, prev, byId, deps.showToast, deps.questionnaireMetaId,
)
is QuestionItem.MultiCheckboxQuestion -> HandlerMultiCheckboxQuestion(
deps.context, deps.answers, deps.languageID, next, prev, byId, deps.showToast, deps.questionnaireMetaId,
)
is QuestionItem.FreeTextQuestion -> HandlerFreeText(
deps.context, deps.answers, deps.languageID, next, prev, byId, deps.showToast, deps.questionnaireMetaId,
)
is QuestionItem.LastPage -> HandlerLastPage(
deps.answers, deps.languageID, next, prev, deps.saveAnswers,
)
else -> null
}
}
}

View File

@ -0,0 +1,36 @@
package com.dano.test1.questionnaire
import android.view.View
import android.widget.TextView
import com.dano.test1.LanguageManager
import com.dano.test1.R
import com.dano.test1.utils.ViewUtils
/** Renders optional noteBeforeKey / noteAfterKey above and below the main question text. */
object QuestionNotesHelper {
fun bind(layout: View, languageId: String, noteBeforeKey: String?, noteAfterKey: String?) {
val before = layout.findViewById<TextView>(R.id.note_before)
val after = layout.findViewById<TextView>(R.id.note_after)
bindView(before, languageId, noteBeforeKey)
bindView(after, languageId, noteAfterKey)
}
private fun bindView(view: TextView?, languageId: String, key: String?) {
if (view == null) return
val text = key?.takeIf { it.isNotBlank() }?.let { LanguageManager.getText(languageId, it) }.orEmpty()
if (text.isBlank()) {
view.visibility = View.GONE
view.text = ""
} else {
view.visibility = View.VISIBLE
view.text = text
ViewUtils.setTextSizePercentOfScreenHeight(view, 0.025f)
}
}
}
interface QuestionWithNotes {
val noteBeforeKey: String?
val noteAfterKey: String?
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,143 @@
package com.dano.test1.questionnaire
import android.R
import android.app.Activity
import android.util.Log
import android.view.View
import android.widget.*
import com.dano.test1.LanguageManager
import com.dano.test1.MainActivity
import kotlinx.coroutines.*
import java.io.File
import java.util.Calendar
const val MAX_VALUE_AGE = 122
val MAX_VALUE_YEAR = Calendar.getInstance().get(Calendar.YEAR)
object GlobalValues {
var INTEGRATION_INDEX: Int = 0
var LAST_CLIENT_CODE: String? = null
var LOADED_CLIENT_CODE: String? = null
}
data class QuestionnaireMeta(val id: String)
data class QuestionnaireData(val meta: QuestionnaireMeta, val questions: List<QuestionItem>)
abstract class QuestionnaireBase<T> {
abstract fun startQuestionnaire()
abstract fun showCurrentQuestion()
companion object {
val LANGUAGE_IDS: List<String> get() = AppLanguages.ids
val LANGUAGE_LABELS: List<String> get() = AppLanguages.shortLabels
}
fun attach(activity: Activity, language: String) {
this.context = activity
this.languageID = language
}
protected lateinit var questionnaireMeta: QuestionnaireMeta
protected var currentIndex = 0
protected lateinit var questions: List<QuestionItem>
protected val answers = mutableMapOf<String, Any>()
protected var languageID: String = "GERMAN"
protected lateinit var context: Activity
private val navigationHistory = mutableListOf<Int>()
protected fun showToast(message: String) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
protected fun validateEditTexts(vararg fields: EditText): Boolean {
return fields.all { it.text.toString().trim().isNotEmpty() }
}
protected fun updateFileContent(fileName: String, placeholders: Map<String, Any?>) {
val file = File(context.filesDir, fileName)
if (file.exists()) {
var updatedContent = file.readText()
placeholders.forEach { (key, value) ->
updatedContent = updatedContent.replace("{$key}", value.toString())
}
file.writeText(updatedContent)
}
}
protected fun navigateTo(layoutResId: Int, setup: (View) -> Unit) {
context.setContentView(layoutResId)
val contentHost = context.findViewById<android.view.ViewGroup>(android.R.id.content)
val rootView = contentHost?.getChildAt(0) ?: contentHost ?: context.window.decorView
setup(rootView)
}
protected fun setupPrevButton(buttonId: Int, action: () -> Unit) {
context.findViewById<Button>(buttonId)?.setOnClickListener { action() }
}
protected fun showEmptyScreen() {
navigateTo(getLayoutResId("empty")) {
setupPrevButton(com.dano.test1.R.id.Qprev) { goToPreviousQuestion() }
}
}
protected fun getLayoutResId(layoutName: String?): Int {
return layoutName?.let {
context.resources.getIdentifier(it, "layout", context.packageName)
} ?: 0
}
protected fun goToPreviousQuestion() {
if (navigationHistory.isNotEmpty()) {
currentIndex = navigationHistory.removeAt(navigationHistory.size - 1)
showCurrentQuestion()
} else {
(context as? MainActivity)?.finishQuestionnaire()
}
}
protected fun goToNextQuestion() {
if (currentIndex < questions.size - 1) {
navigationHistory.add(currentIndex)
currentIndex++
showCurrentQuestion()
}
}
fun goToQuestionById(questionId: String) {
val index = questions.indexOfFirst { it.id == questionId }
if (index != -1) {
navigationHistory.add(currentIndex)
currentIndex = index
showCurrentQuestion()
}
}
protected fun loadQuestionnaireFromJson(filename: String): Pair<QuestionnaireMeta, List<QuestionItem>> =
QuestionnaireJsonLoader.load(context, filename)
protected fun handlerDependencies(): HandlerDependencies = HandlerDependencies(
context = context,
answers = answers,
languageID = languageID,
questionnaireMetaId = questionnaireMeta.id,
goToNext = ::goToNextQuestion,
goToPrev = ::goToPreviousQuestion,
goToById = ::goToQuestionById,
showToast = ::showToast,
saveAnswers = { map ->
saveAnswersToDatabase(map, questionnaireMeta.id)
},
)
protected open fun createHandlerForQuestion(question: QuestionItem): QuestionHandler? =
QuestionHandlerFactory.create(question, NavigationMode.Step, handlerDependencies())
suspend fun saveAnswersToDatabase(answers: Map<String, Any>, questionnaireId: String) {
QuestionnairePersistence.saveAnswers(context, answers, questionnaireId, languageID, questions)
}
fun endQuestionnaire() {
(context as? MainActivity)?.finishQuestionnaire()
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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"
}
}

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

View File

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

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

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

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

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

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

View File

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

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

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

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

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

View 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