Compare commits

20 Commits

Author SHA1 Message Date
d48906bd3b added language selection to surveys 2026-03-24 12:34:33 +01:00
b6fea5be7a fixed responsive bugs 2026-03-24 11:47:41 +01:00
e5531e6616 created vertical variant of questionnaires 2026-03-23 15:40:31 +01:00
e8e223978b removed edit button to always allow editing 2026-03-23 14:58:33 +01:00
e99d681f3c moved the database button to hidden settings 2026-03-23 14:48:58 +01:00
a0a9ba45fa created hidden settings menu for ab-testing 2026-03-23 09:20:51 +01:00
3228f75b35 Merge pull request 'Refactoring of codebase' (#1) from refactoring into master
Reviewed-on: #1
2026-03-04 15:30:37 +00:00
67bbc3ea06 reverted refactor change to function 2026-03-02 13:25:55 +01:00
b95977e28d created shared functions in ViewUtils.kt 2026-03-02 13:20:51 +01:00
cc89c77186 directory refactor 2026-03-02 13:08:25 +01:00
07b7b3dc1b fixed online connection error and class scale question bug 2026-02-23 18:20:21 +01:00
d30c94beeb new apk 2025-10-16 13:20:50 +02:00
5b1264293c added dummy accounts, change passwort is now a feature, toast when session takes to long, online frontend fix 2025-10-16 13:19:54 +02:00
39a4811fd2 new apk 2025-10-13 20:10:31 +02:00
8b3bb358e8 changed .xml files, now all text visible 2025-10-13 19:33:44 +02:00
5968bf68d1 new apk 2025-10-13 18:30:51 +02:00
ad09bce68c switch from http zu https 2025-10-10 15:33:44 +02:00
4089841336 glass scale centering 2025-10-10 12:35:29 +02:00
5570710da5 client code laod fix 2025-10-10 12:21:59 +02:00
8d54315fe7 new apk and commands added 2025-10-09 16:29:20 +02:00
61 changed files with 2433 additions and 1104 deletions

View File

@ -4,10 +4,10 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-09-29T10:52:30.282144200Z"> <DropdownSelection timestamp="2026-03-24T11:30:25.894049082Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\danie\.android\avd\Medium_Phone.avd" /> <DeviceId pluginId="PhysicalDevice" identifier="serial=R52T605XE0L" />
</handle> </handle>
</Target> </Target>
</DropdownSelection> </DropdownSelection>

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

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

1
.idea/misc.xml generated
View File

@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">

Binary file not shown.

View File

@ -41,6 +41,11 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".ui.DevSettingsActivity"
android:exported="false"
android:parentActivityName=".MainActivity" />
</application> </application>
</manifest> </manifest>

View File

@ -1,93 +0,0 @@
package com.dano.test1
import android.widget.Button
import android.widget.EditText
import android.widget.Toast
import kotlinx.coroutines.*
import com.dano.test1.data.CompletedQuestionnaire
class EditButtonHandler(
private val activity: MainActivity,
private val editButton: Button,
private val editText: EditText,
private val languageIDProvider: () -> String,
private val questionnaireFiles: Map<Button, String>,
private val buttonPoints: MutableMap<String, Int>,
private val updateButtonTexts: () -> Unit,
private val setButtonsEnabled: (List<Button>, Boolean) -> Unit,
private val setUiFreeze: (Boolean) -> Unit,
private val triggerLoad: () -> Unit
) {
fun setup() {
editButton.text = LanguageManager.getText(languageIDProvider(), "edit")
editButton.setOnClickListener { handleEditButtonClick() }
}
private fun handleEditButtonClick() {
val typed = editText.text.toString().trim()
val desiredCode = when {
typed.isNotBlank() -> typed
!GlobalValues.LOADED_CLIENT_CODE.isNullOrBlank() -> GlobalValues.LOADED_CLIENT_CODE!!
else -> ""
}
if (desiredCode.isBlank()) {
val message = LanguageManager.getText(languageIDProvider(), "please_client_code")
Toast.makeText(activity, message, Toast.LENGTH_SHORT).show()
return
}
GlobalValues.LAST_CLIENT_CODE = desiredCode
val needLoad = GlobalValues.LOADED_CLIENT_CODE?.equals(desiredCode) != true
if (needLoad) {
setUiFreeze(true) // Zwischenzustände unterdrücken
triggerLoad()
}
CoroutineScope(Dispatchers.IO).launch {
val loadedOk = waitUntilClientLoaded(desiredCode, timeoutMs = 2500, stepMs = 50)
if (!loadedOk) {
withContext(Dispatchers.Main) {
val msg = LanguageManager.getText(languageIDProvider(), "open_client_via_load")
Toast.makeText(activity, msg, Toast.LENGTH_LONG).show()
setUiFreeze(false)
}
return@launch
}
val completedEntries: List<CompletedQuestionnaire> =
MyApp.database.completedQuestionnaireDao().getAllForClient(desiredCode)
val completedFiles = completedEntries.filter { it.isDone }.map { it.questionnaireId.lowercase() }
buttonPoints.clear()
for (entry in completedEntries) {
if (entry.isDone) {
buttonPoints[entry.questionnaireId] = entry.sumPoints ?: 0
}
}
withContext(Dispatchers.Main) {
updateButtonTexts()
val enabledButtons = questionnaireFiles.filter { (_, fileName) ->
completedFiles.any { completedId -> fileName.lowercase().contains(completedId) }
}.keys.toList()
setButtonsEnabled(enabledButtons, true)
setUiFreeze(false)
}
}
}
private suspend fun waitUntilClientLoaded(expectedCode: String, timeoutMs: Long, stepMs: Long): Boolean {
if (GlobalValues.LOADED_CLIENT_CODE?.equals(expectedCode) == true) return true
var waited = 0L
while (waited < timeoutMs) {
delay(stepMs)
waited += stepMs
if (GlobalValues.LOADED_CLIENT_CODE?.equals(expectedCode) == true) return true
}
return GlobalValues.LOADED_CLIENT_CODE?.equals(expectedCode) == true
}
}

View File

@ -1,186 +0,0 @@
package com.dano.test1
import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.widget.*
import kotlinx.coroutines.*
import android.util.TypedValue
import android.widget.TextView
import androidx.core.widget.TextViewCompat
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 showToast: (String) -> Unit,
private val questionnaireMeta: String // Meta, damit dieselbe DB-ID wie in anderen Handlern gebildet wird
) : QuestionHandler {
private lateinit var layout: View
private lateinit var question: QuestionItem.StringSpinnerQuestion
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)
// Texte setzen
questionTextView.text = question.question?.let { LanguageManager.getText(languageID, it) } ?: ""
textView.text = question.textKey?.let { LanguageManager.getText(languageID, it) } ?: ""
// === Textgrößen prozentual zur Bildschirmhöhe (wie im HandlerRadioQuestion) ===
setTextSizePercentOfScreenHeight(textView, 0.03f)
setTextSizePercentOfScreenHeight(questionTextView, 0.03f)
// ==============================================================================
val options = buildOptionsList()
// vorhandene Auswahl (falls vorhanden)
val savedSelection = question.question?.let { answers[it] as? String }
// Spinner aufsetzen (Schriftgröße & Zeilenhöhe dynamisch, kein Abschneiden)
setupSpinner(spinner, options, savedSelection)
// Falls noch keine Antwort im Map: aus DB laden (analog zu anderen Handlern)
val answerMapKey = question.question ?: (question.id ?: "")
if (answerMapKey.isNotBlank() && !answers.containsKey(answerMapKey)) {
CoroutineScope(Dispatchers.IO).launch {
try {
val clientCode = GlobalValues.LAST_CLIENT_CODE
if (clientCode.isNullOrBlank()) return@launch
val allAnswersForClient = MyApp.database.answerDao().getAnswersForClient(clientCode)
val myQuestionId = questionnaireMeta + "-" + question.question
val dbAnswer = allAnswersForClient.find { it.questionId == myQuestionId }?.answerValue
if (!dbAnswer.isNullOrBlank()) {
withContext(Dispatchers.Main) {
answers[answerMapKey] = dbAnswer
val index = options.indexOf(dbAnswer)
if (index >= 0) spinner.setSelection(index)
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
layout.findViewById<Button>(R.id.Qnext).setOnClickListener {
if (validate()) {
saveAnswer()
goToNextQuestion()
}
}
layout.findViewById<Button>(R.id.Qprev).setOnClickListener {
goToPreviousQuestion()
}
}
override fun validate(): Boolean {
val spinner = layout.findViewById<Spinner>(R.id.string_spinner)
val selected = spinner.selectedItem as? String
val prompt = LanguageManager.getText(languageID, "choose_answer")
return if (selected.isNullOrEmpty() || selected == prompt) {
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 ?: return
question.question?.let { key -> answers[key] = selected }
}
private fun buildOptionsList(): List<String> {
return if (question.id == "q11") {
Countries.getAllCountries(languageID)
} else {
val prompt = LanguageManager.getText(languageID, "choose_answer")
listOf(prompt) + question.options
}
}
// Textgröße prozentual zur Bildschirmhöhe setzen und AutoSize deaktivieren
private fun setTextSizePercentOfScreenHeight(view: TextView, percentOfHeight: Float) {
val dm = (view.context ?: layout.context).resources.displayMetrics
val sp = (dm.heightPixels * percentOfHeight) / dm.scaledDensity
TextViewCompat.setAutoSizeTextTypeWithDefaults(view, TextViewCompat.AUTO_SIZE_TEXT_TYPE_NONE)
view.setTextSize(TypedValue.COMPLEX_UNIT_SP, sp)
}
// Spinner-Adapter mit dynamischer Schrift & stabiler Dropdown-Zeilenhöhe (kein Abschneiden)
private fun <T> setupSpinner(spinner: Spinner, items: List<T>, selectedItem: T?) {
val dm = context.resources.displayMetrics
fun spFromScreenHeight(percent: Float): Float =
(dm.heightPixels * percent) / dm.scaledDensity
fun pxFromSp(sp: Float): Int = (sp * dm.scaledDensity).toInt()
// Schrift & abgeleitete Höhen (wie beim Value-Spinner-Fix)
val textSp = spFromScreenHeight(0.0275f) // ~2.75% der Bildschirmhöhe
val textPx = pxFromSp(textSp)
val vPadPx = (textPx * 0.50f).toInt() // vertikales Padding
val rowHeight = (textPx * 2.20f + 2 * vPadPx).toInt() // feste Zeilenhöhe, verhindert Abschneiden
val adapter = object : ArrayAdapter<T>(context, android.R.layout.simple_spinner_item, items) {
private fun styleRow(tv: TextView, forceHeight: Boolean) {
tv.setTextSize(TypedValue.COMPLEX_UNIT_SP, textSp)
tv.includeFontPadding = true
tv.setLineSpacing(0f, 1.2f)
tv.gravity = (tv.gravity and android.view.Gravity.HORIZONTAL_GRAVITY_MASK) or android.view.Gravity.CENTER_VERTICAL
tv.setPadding(tv.paddingLeft, vPadPx, tv.paddingRight, vPadPx)
tv.minHeight = rowHeight
tv.isSingleLine = true
if (forceHeight) {
val lp = tv.layoutParams
if (lp == null || lp.height <= 0) {
tv.layoutParams = AbsListView.LayoutParams(
AbsListView.LayoutParams.MATCH_PARENT, rowHeight
)
} else {
lp.height = rowHeight
}
}
}
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val v = super.getView(position, convertView, parent) as TextView
styleRow(v, forceHeight = false) // ausgewählte Ansicht
return v
}
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
val v = super.getDropDownView(position, convertView, parent) as TextView
styleRow(v, forceHeight = true) // Dropdown-Zeilen: Höhe erzwingen
return v
}
}
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
spinner.adapter = adapter
// Spinner selbst ausreichend hoch machen
spinner.setPadding(spinner.paddingLeft, vPadPx, spinner.paddingRight, vPadPx)
spinner.minimumHeight = rowHeight
spinner.requestLayout()
selectedItem?.let {
val index = items.indexOf(it)
if (index >= 0) spinner.setSelection(index)
}
}
}

View File

@ -1,61 +0,0 @@
package com.dano.test1
import android.content.Context
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
object LoginManager {
private const val SERVER_LOGIN_URL = "http://49.13.157.44/login.php"
private val client = OkHttpClient()
fun loginUserWithCredentials(
context: Context,
username: String,
password: String,
onSuccess: (String) -> Unit,
onError: (String) -> Unit
) {
CoroutineScope(Dispatchers.IO).launch {
try {
val bodyJson = JSONObject()
.put("username", username)
.put("password", password)
.toString()
.toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url(SERVER_LOGIN_URL)
.post(bodyJson)
.build()
val response = client.newCall(request).execute()
val text = response.body?.string()
if (response.isSuccessful && text != null) {
val json = JSONObject(text)
if (json.optBoolean("success")) {
val token = json.getString("token")
// => setzt auch den Login-Timestamp:
TokenStore.save(context, token, username)
withContext(Dispatchers.Main) { onSuccess(token) }
} else {
withContext(Dispatchers.Main) { onError("Login fehlgeschlagen") }
}
} else {
withContext(Dispatchers.Main) { onError("Fehler beim Login (${response.code})") }
}
} catch (e: Exception) {
Log.e("LOGIN", "Exception", e)
withContext(Dispatchers.Main) { onError("Exception: ${e.message}") }
}
}
}
}

View File

@ -11,9 +11,16 @@ import android.widget.EditText
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.ProgressBar import android.widget.ProgressBar
import android.widget.Toast import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import java.io.File import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.dano.test1.network.DatabaseDownloader
import com.dano.test1.network.LoginManager
import com.dano.test1.network.TokenStore
import com.dano.test1.questionnaire.QuestionnaireBase
import com.dano.test1.ui.HandlerOpeningScreen
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@ -32,6 +39,7 @@ class MainActivity : AppCompatActivity() {
private fun t(key: String): String = LanguageManager.getText(bootLanguageId, key) private fun t(key: String): String = LanguageManager.getText(bootLanguageId, key)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// === Offline-Start ermöglichen === // === Offline-Start ermöglichen ===
@ -201,6 +209,16 @@ class MainActivity : AppCompatActivity() {
} }
// --- /LIVE NETZSTATUS --- // --- /LIVE NETZSTATUS ---
override fun onContentChanged() {
super.onContentChanged()
val content = findViewById<View>(android.R.id.content) ?: return
ViewCompat.setOnApplyWindowInsetsListener(content) { v, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(bars.left, bars.top, bars.right, bars.bottom)
WindowInsetsCompat.CONSUMED
}
}
override fun onConfigurationChanged(newConfig: Configuration) { override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig) super.onConfigurationChanged(newConfig)
} }

View File

@ -6,6 +6,19 @@ import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import com.dano.test1.data.AppDatabase import com.dano.test1.data.AppDatabase
/*
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() { class MyApp : Application() {
companion object { companion object {
@ -16,7 +29,7 @@ class MyApp : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
// Room Datenbank bauen: nur die Hauptdatei, ohne WAL und Journal // Room-Datenbank bauen: nur die Hauptdatei, ohne WAL und Journal
database = Room.databaseBuilder( database = Room.databaseBuilder(
applicationContext, applicationContext,
AppDatabase::class.java, AppDatabase::class.java,

View File

@ -1,19 +0,0 @@
package com.dano.test1
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
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

@ -1,42 +0,0 @@
package com.dano.test1
import android.widget.Button
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 ->
LocalizationHelper.localizeViewTree(layout, languageID)
layout.findViewById<Button>(R.id.Qprev)?.setOnClickListener {
goToPreviousQuestion()
}
val handler = createHandlerForQuestion(question)
if (handler != null) {
handler.bind(layout, question)
} else {
showEmptyScreen()
}
}
}
}

View File

@ -3,6 +3,18 @@ package com.dano.test1.data
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase 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( @Database(
entities = [ entities = [
Client::class, Client::class,

View File

@ -2,8 +2,18 @@ package com.dano.test1.data
import androidx.room.* import androidx.room.*
/*
Data-Access-Objekte (DAOs) für die Room-Datenbank.
Sie kapseln alle typsicheren Lese-/Schreiboperationen für die Tabellen clients, questionnaires, questions, answers und completed_questionnaires.
Hinweis:
- Die konkreten Implementierungen erzeugt Room zur Build-Zeit.
- DAOs werden über die AppDatabase (Room.databaseBuilder(...)) bezogen.
*/
@Dao @Dao
interface ClientDao { interface ClientDao {
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertClient(client: Client) suspend fun insertClient(client: Client)
@ -20,9 +30,9 @@ interface ClientDao {
suspend fun getAllClients(): List<Client> suspend fun getAllClients(): List<Client>
} }
@Dao @Dao
interface QuestionnaireDao { interface QuestionnaireDao {
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertQuestionnaire(questionnaire: Questionnaire) suspend fun insertQuestionnaire(questionnaire: Questionnaire)
@ -30,11 +40,12 @@ interface QuestionnaireDao {
suspend fun getById(id: String): Questionnaire? suspend fun getById(id: String): Questionnaire?
@Query("SELECT * FROM questionnaires") @Query("SELECT * FROM questionnaires")
suspend fun getAll(): List<Questionnaire> // <-- NEU suspend fun getAll(): List<Questionnaire>
} }
@Dao @Dao
interface QuestionDao { interface QuestionDao {
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertQuestions(questions: List<Question>) suspend fun insertQuestions(questions: List<Question>)
@ -48,8 +59,10 @@ interface QuestionDao {
suspend fun getQuestionsForQuestionnaire(questionnaireId: String): List<Question> suspend fun getQuestionsForQuestionnaire(questionnaireId: String): List<Question>
} }
@Dao @Dao
interface AnswerDao { interface AnswerDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAnswers(answers: List<Answer>) suspend fun insertAnswers(answers: List<Answer>)
@ -79,9 +92,9 @@ interface AnswerDao {
) )
} }
@Dao @Dao
interface CompletedQuestionnaireDao { interface CompletedQuestionnaireDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(entry: CompletedQuestionnaire) suspend fun insert(entry: CompletedQuestionnaire)
@ -93,4 +106,4 @@ interface CompletedQuestionnaireDao {
@Query("SELECT questionnaireId FROM completed_questionnaires WHERE clientCode = :clientCode") @Query("SELECT questionnaireId FROM completed_questionnaires WHERE clientCode = :clientCode")
suspend fun getCompletedQuestionnairesForClient(clientCode: String): List<String> suspend fun getCompletedQuestionnairesForClient(clientCode: String): List<String>
} }

View File

@ -2,16 +2,41 @@ package com.dano.test1.data
import androidx.room.* import androidx.room.*
/*
Room-Entities (Tabellen) der App.
- Definieren das Schema für Clients, Questionnaires, Questions, Answers und CompletedQuestionnaires.
- Beziehungen:
* Question -> Questionnaire (FK, CASCADE)
* Answer -> Client (FK, CASCADE)
* Answer -> Question (FK, CASCADE)
* CompletedQuestionnaire -> Client (FK, CASCADE)
* CompletedQuestionnaire -> Questionnaire (FK, CASCADE)
- Primärschlüssel:
* Client: clientCode
* Questionnaire: id
* Question: questionId
* Answer: (clientCode, questionId) eine Antwort je Client & Frage
* CompletedQuestionnaire: (clientCode, questionnaireId) ein Status je Client & Fragebogen
*/
/* Tabelle: clients Eindeutige Identifikation eines Clients per clientCode. */
@Entity(tableName = "clients") @Entity(tableName = "clients")
data class Client( data class Client(
@PrimaryKey val clientCode: String, @PrimaryKey val clientCode: String,
) )
/* Tabelle: questionnaires Eindeutige Fragebogen-IDs. */
@Entity(tableName = "questionnaires") @Entity(tableName = "questionnaires")
data class Questionnaire( data class Questionnaire(
@PrimaryKey val id: String, @PrimaryKey val id: String,
) )
/*
Tabelle: questions
- Jede Frage gehört zu genau einem Fragebogen (questionnaireId).
- Fremdschlüssel sorgt dafür, dass beim Löschen eines Fragebogens die zugehörigen Fragen mit gelöscht werden.
- Index auf questionnaireId beschleunigt Abfragen alle Fragen eines Fragebogens.
*/
@Entity( @Entity(
tableName = "questions", tableName = "questions",
foreignKeys = [ foreignKeys = [
@ -30,6 +55,12 @@ data class Question(
val question: String = "" val question: String = ""
) )
/*
Tabelle: answers
- Zusammengesetzter Primärschlüssel (clientCode, questionId):
* Pro Client und Frage existiert höchstens eine Antwort.
* Löscht man den Client oder die Frage, werden die zugehörigen Antworten mit entfernt.
*/
@Entity( @Entity(
tableName = "answers", tableName = "answers",
primaryKeys = ["clientCode", "questionId"], primaryKeys = ["clientCode", "questionId"],
@ -55,6 +86,17 @@ data class Answer(
val answerValue: String = "" val answerValue: String = ""
) )
/*
Tabelle: completed_questionnaires
- Zusammengesetzter Primärschlüssel (clientCode, questionnaireId):
* Hält den Abschluss-Status eines Fragebogens pro Client.
- FKs mit CASCADE:
* Beim Löschen eines Clients oder Fragebogens verschwindet der Status-Eintrag ebenfalls.
- Indizes auf clientCode und questionnaireId für schnelle Lookups.
- timestamp: Zeitpunkt der Statusänderung (Default: now).
- isDone: true/false abgeschlossen oder nicht.
- sumPoints: optionaler Score des Fragebogens.
*/
@Entity( @Entity(
tableName = "completed_questionnaires", tableName = "completed_questionnaires",
primaryKeys = ["clientCode", "questionnaireId"], primaryKeys = ["clientCode", "questionnaireId"],
@ -81,4 +123,3 @@ data class CompletedQuestionnaire(
val isDone: Boolean, val isDone: Boolean,
val sumPoints: Int? = null val sumPoints: Int? = null
) )

View File

@ -1,4 +1,4 @@
package com.dano.test1 package com.dano.test1.data
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
@ -7,15 +7,36 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.provider.MediaStore import android.provider.MediaStore
import com.dano.test1.LanguageManager
import com.dano.test1.MyApp
import org.apache.poi.ss.usermodel.Row import org.apache.poi.ss.usermodel.Row
import org.apache.poi.xssf.usermodel.XSSFWorkbook import org.apache.poi.xssf.usermodel.XSSFWorkbook
import java.io.ByteArrayOutputStream
import java.io.File
/*
Aufgabe:
- Baut eine Excel-Datei (XLSX) mit allen Clients als Zeilen und einem konfigurierbaren Spalten-Layout.
- Speichert die Datei ausschließlich in den öffentlichen Downloads-Ordner
Datenquelle:
- Liest die Spaltenreihenfolge/Spalten-IDs über HeaderOrderRepository.loadOrderedIds().
- Holt alle Clients, Fragebogen-IDs sowie Antworten aus der lokalen Room-Datenbank
Ausgabeformat (Sheet Headers):
- Zeile 1: Spalten-IDs (erste Zelle # für laufende Nummer).
- Zeile 2: Englische Beschriftung/Fragetext je Spalte (ermittelt via englishQuestionForId + LanguageManager).
- Ab Zeile 3: Pro Client eine Datenzeile.
* Für Spalten-ID client_code: der Client-Code.
* Für Spalten-IDs, die einem Fragebogen entsprechen (Questionnaire-ID): Done/Not Done (Abschlussstatus).
* Für sonstige Spalten-IDs (Antwort-IDs): Antwortwert oder None, falls leer.
*/
class ExcelExportService( class ExcelExportService(
private val context: Context, private val context: Context,
private val headerRepo: HeaderOrderRepository private val headerRepo: HeaderOrderRepository
) { ) {
/** Baut die Excel-Datei und speichert sie ausschließlich unter "Downloads". */ /* Baut die Excel-Datei und speichert sie ausschließlich unter "Downloads". */
suspend fun exportHeadersForAllClients(): Uri? { suspend fun exportHeadersForAllClients(): Uri? {
val orderedIds = headerRepo.loadOrderedIds() val orderedIds = headerRepo.loadOrderedIds()
if (orderedIds.isEmpty()) return null if (orderedIds.isEmpty()) return null
@ -66,7 +87,7 @@ class ExcelExportService(
} }
} }
val bytes = java.io.ByteArrayOutputStream().use { bos -> val bytes = ByteArrayOutputStream().use { bos ->
wb.write(bos); bos.toByteArray() wb.write(bos); bos.toByteArray()
} }
wb.close() wb.close()
@ -78,7 +99,6 @@ class ExcelExportService(
) )
} }
/** Speichert Bytes nach "Downloads". */
private fun saveToDownloads(filename: String, mimeType: String, bytes: ByteArray): Uri? { private fun saveToDownloads(filename: String, mimeType: String, bytes: ByteArray): Uri? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val resolver = context.contentResolver val resolver = context.contentResolver
@ -96,7 +116,7 @@ class ExcelExportService(
} else { } else {
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
if (!downloadsDir.exists()) downloadsDir.mkdirs() if (!downloadsDir.exists()) downloadsDir.mkdirs()
val outFile = java.io.File(downloadsDir, filename) val outFile = File(downloadsDir, filename)
outFile.writeBytes(bytes) outFile.writeBytes(bytes)
MediaScannerConnection.scanFile( MediaScannerConnection.scanFile(
context, context,
@ -108,7 +128,6 @@ class ExcelExportService(
} }
} }
// ---------- Export-spezifische Lokalisierung (EN) ----------
private suspend fun englishQuestionForId(id: String, questionnaireIdSet: Set<String>): String { private suspend fun englishQuestionForId(id: String, questionnaireIdSet: Set<String>): String {
if (id == "client_code") return "Client code" if (id == "client_code") return "Client code"
if (id in questionnaireIdSet && !id.contains('-')) return "Questionnaire status" if (id in questionnaireIdSet && !id.contains('-')) return "Questionnaire status"
@ -147,7 +166,7 @@ class ExcelExportService(
return stripped return stripped
} }
/** Englisch für Export; belässt Done/Not Done/None. */ /* Englisch für Export; belässt Done/Not Done/None. */
private fun localizeForExportEn(id: String, raw: String): String { private fun localizeForExportEn(id: String, raw: String): String {
if (id == "client_code") return raw if (id == "client_code") return raw
if (raw == "Done" || raw == "Not Done" || raw == "None") return raw if (raw == "Done" || raw == "Not Done" || raw == "None") return raw
@ -160,4 +179,4 @@ class ExcelExportService(
for (key in candidates) localizeEnglishNoBrackets(key)?.let { return it } for (key in candidates) localizeEnglishNoBrackets(key)?.let { return it }
return raw return raw
} }
} }

View File

@ -1,12 +1,21 @@
package com.dano.test1 package com.dano.test1.data
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import com.dano.test1.LanguageManager
import org.apache.poi.ss.usermodel.CellType
import org.apache.poi.ss.usermodel.DateUtil
import org.apache.poi.xssf.usermodel.XSSFWorkbook import org.apache.poi.xssf.usermodel.XSSFWorkbook
import org.json.JSONArray import org.json.JSONArray
import java.nio.charset.Charset import java.nio.charset.Charset
/*
Zweck:
- Liefert die Reihenfolge/IDs der zu exportierenden Header (Spalten) für den Excel-Export.
- Bevorzugte Quelle ist eine Excel-Datei aus den App-Assets (header_order.xlsx), als Fallback wird eine JSON-Datei (header_order.json) genutzt.
*/
class HeaderOrderRepository( class HeaderOrderRepository(
private val context: Context, private val context: Context,
// Sprache abrufen (Standard: Deutsch, damit es ohne OpeningScreen schon sinnvoll ist) // Sprache abrufen (Standard: Deutsch, damit es ohne OpeningScreen schon sinnvoll ist)
@ -57,16 +66,16 @@ class HeaderOrderRepository(
for (i in first until last) { for (i in first until last) {
val cell = row.getCell(i) ?: continue val cell = row.getCell(i) ?: continue
val value = when (cell.cellType) { val value = when (cell.cellType) {
org.apache.poi.ss.usermodel.CellType.STRING -> cell.stringCellValue CellType.STRING -> cell.stringCellValue
org.apache.poi.ss.usermodel.CellType.NUMERIC -> CellType.NUMERIC ->
if (org.apache.poi.ss.usermodel.DateUtil.isCellDateFormatted(cell)) if (DateUtil.isCellDateFormatted(cell))
cell.dateCellValue.time.toString() cell.dateCellValue.time.toString()
else { else {
val n = cell.numericCellValue val n = cell.numericCellValue
if (n % 1.0 == 0.0) n.toLong().toString() else n.toString() if (n % 1.0 == 0.0) n.toLong().toString() else n.toString()
} }
org.apache.poi.ss.usermodel.CellType.BOOLEAN -> cell.booleanCellValue.toString() CellType.BOOLEAN -> cell.booleanCellValue.toString()
org.apache.poi.ss.usermodel.CellType.FORMULA -> cell.richStringCellValue.string CellType.FORMULA -> cell.richStringCellValue.string
else -> "" else -> ""
}.trim() }.trim()

View File

@ -1,7 +1,8 @@
package com.dano.test1 package com.dano.test1.network
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import com.dano.test1.AES256Helper
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -14,7 +15,7 @@ import java.io.FileOutputStream
object DatabaseDownloader { object DatabaseDownloader {
private const val DB_NAME = "questionnaire_database" private const val DB_NAME = "questionnaire_database"
private const val SERVER_DOWNLOAD_URL = "http://49.13.157.44/downloadFull.php" private const val SERVER_DOWNLOAD_URL = "https://daniel-ocks.de/qdb/downloadFull.php"
private val client = OkHttpClient() private val client = OkHttpClient()

View File

@ -1,5 +1,4 @@
// app/src/main/java/com/dano/test1/DatabaseUploader.kt package com.dano.test1.network
package com.dano.test1
import android.content.Context import android.content.Context
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
@ -9,6 +8,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import android.database.Cursor import android.database.Cursor
import com.dano.test1.AES256Helper
import okhttp3.* import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.asRequestBody
@ -21,25 +21,11 @@ import kotlin.system.exitProcess
object DatabaseUploader { object DatabaseUploader {
private const val DB_NAME = "questionnaire_database" private const val DB_NAME = "questionnaire_database"
private const val SERVER_DELTA_URL = "http://49.13.157.44/uploadDeltaTest5.php" private const val SERVER_DELTA_URL = "https://daniel-ocks.de/qdb/uploadDeltaTest5.php"
private const val SERVER_CHECK_URL = "http://49.13.157.44/checkDatabaseExists.php" private const val SERVER_CHECK_URL = "https://daniel-ocks.de/qdb/checkDatabaseExists.php"
private val client = OkHttpClient() private val client = OkHttpClient()
/** NEU: Login mit Username+Password, danach Upload wie gehabt */
fun uploadDatabaseWithLogin(context: Context, username: String, password: String) {
LoginManager.loginUserWithCredentials(
context = context,
username = username,
password = password,
onSuccess = { token ->
Log.d("UPLOAD", "Login OK (user=$username)")
uploadDatabase(context, token)
},
onError = { msg -> Log.e("UPLOAD", "Login fehlgeschlagen: $msg") }
)
}
private fun uploadDatabase(context: Context, token: String) { private fun uploadDatabase(context: Context, token: String) {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
try { try {
@ -49,6 +35,7 @@ object DatabaseUploader {
return@launch return@launch
} }
// WAL sauber schließen (falls aktiv)
try { try {
val db = SQLiteDatabase.openDatabase(dbFile.absolutePath, null, SQLiteDatabase.OPEN_READWRITE) val db = SQLiteDatabase.openDatabase(dbFile.absolutePath, null, SQLiteDatabase.OPEN_READWRITE)
db.rawQuery("PRAGMA wal_checkpoint(FULL);", null).use { /* noop */ } db.rawQuery("PRAGMA wal_checkpoint(FULL);", null).use { /* noop */ }
@ -82,11 +69,14 @@ object DatabaseUploader {
put("questionnaires", queryToJsonArray(db, "SELECT id FROM questionnaires")) put("questionnaires", queryToJsonArray(db, "SELECT id FROM questionnaires"))
put("questions", queryToJsonArray(db, "SELECT questionId, questionnaireId, question FROM questions")) put("questions", queryToJsonArray(db, "SELECT questionId, questionnaireId, question FROM questions"))
put("answers", queryToJsonArray(db, "SELECT clientCode, questionId, answerValue FROM answers")) put("answers", queryToJsonArray(db, "SELECT clientCode, questionId, answerValue FROM answers"))
put("completed_questionnaires", put(
queryToJsonArray(db, "SELECT clientCode, questionnaireId, timestamp, isDone, sumPoints FROM completed_questionnaires")) "completed_questionnaires",
queryToJsonArray(db, "SELECT clientCode, questionnaireId, timestamp, isDone, sumPoints FROM completed_questionnaires")
)
} }
db.close() db.close()
// JSON -> verschlüsselte Payload
val tmpJson = File(context.cacheDir, "payload.json").apply { writeText(data.toString()) } val tmpJson = File(context.cacheDir, "payload.json").apply { writeText(data.toString()) }
val tmpEnc = File(context.cacheDir, "payload.enc") val tmpEnc = File(context.cacheDir, "payload.enc")
try { try {
@ -98,11 +88,17 @@ object DatabaseUploader {
val body = MultipartBody.Builder() val body = MultipartBody.Builder()
.setType(MultipartBody.FORM) .setType(MultipartBody.FORM)
.addFormDataPart("token", token) .addFormDataPart("token", token) // bleibt für Kompatibilität enthalten
.addFormDataPart("file", "payload.enc", tmpEnc.asRequestBody("application/octet-stream".toMediaType())) .addFormDataPart("file", "payload.enc", tmpEnc.asRequestBody("application/octet-stream".toMediaType()))
.build() .build()
val request = Request.Builder().url("http://49.13.157.44/uploadDeltaTest5.php").post(body).build() // WICHTIG: Jetzt HTTPS + Konstanten-URL verwenden, plus Bearer-Header
val request = Request.Builder()
.url(SERVER_DELTA_URL)
.post(body)
.header("Authorization", "Bearer $token")
.build()
client.newCall(request).enqueue(object : Callback { client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) { override fun onFailure(call: Call, e: IOException) {
Log.e("UPLOAD", "Fehlgeschlagen: ${e.message}") Log.e("UPLOAD", "Fehlgeschlagen: ${e.message}")
@ -113,7 +109,7 @@ object DatabaseUploader {
if (response.isSuccessful) { if (response.isSuccessful) {
Log.d("UPLOAD", "OK: $respBody") Log.d("UPLOAD", "OK: $respBody")
// <<< alte Logik wieder aktivieren: lokale DB + Neben­dateien löschen // alte Logik: lokale DB + Neben­dateien löschen
try { try {
if (!file.delete()) Log.w("UPLOAD", "Lokale DB nicht gelöscht.") if (!file.delete()) Log.w("UPLOAD", "Lokale DB nicht gelöscht.")
File(file.parent, "${file.name}-journal").delete() File(file.parent, "${file.name}-journal").delete()
@ -122,11 +118,12 @@ object DatabaseUploader {
} catch (e: Exception) { } catch (e: Exception) {
Log.w("UPLOAD", "Fehler beim Löschen lokaler DB-Dateien", e) Log.w("UPLOAD", "Fehler beim Löschen lokaler DB-Dateien", e)
} }
// >>>
} else { } else {
Log.e("UPLOAD", "HTTP ${response.code}: $respBody") Log.e("UPLOAD", "HTTP ${response.code}: $respBody")
} }
tmpJson.delete(); tmpEnc.delete() tmpJson.delete(); tmpEnc.delete()
// unverändert beibehalten
try { exitProcess(0) } catch (_: Exception) {} try { exitProcess(0) } catch (_: Exception) {}
} }
}) })
@ -162,6 +159,8 @@ object DatabaseUploader {
} }
fun uploadDatabaseWithToken(context: Context, token: String) { fun uploadDatabaseWithToken(context: Context, token: String) {
uploadDatabase(context, token) // nutzt die bestehende interne Logik uploadDatabase(context, token)
} }
} }

View File

@ -0,0 +1,218 @@
package com.dano.test1.network
import android.app.AlertDialog
import android.content.Context
import android.text.InputType
import android.util.Log
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.Toast
import kotlinx.coroutines.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
object LoginManager {
private const val SERVER_LOGIN_URL = "https://daniel-ocks.de/qdb/login.php"
private const val SERVER_CHANGE_URL = "https://daniel-ocks.de/qdb/change_password.php"
private val client = OkHttpClient()
fun loginUserWithCredentials(
context: Context,
username: String,
password: String,
onSuccess: (String) -> Unit,
onError: (String) -> Unit
) {
CoroutineScope(Dispatchers.IO).launch {
try {
val bodyJson = JSONObject()
.put("username", username)
.put("password", password)
.toString()
.toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url(SERVER_LOGIN_URL)
.post(bodyJson)
.build()
val response = client.newCall(request).execute()
val text = response.body?.string()
if (!response.isSuccessful || text == null) {
withContext(Dispatchers.Main) { onError("Fehler beim Login (${response.code})") }
return@launch
}
val json = JSONObject(text)
if (!json.optBoolean("success")) {
withContext(Dispatchers.Main) { onError(json.optString("message", "Login fehlgeschlagen")) }
return@launch
}
// Passwortwechsel erforderlich?
if (json.optBoolean("must_change_password", false)) {
withContext(Dispatchers.Main) {
showChangePasswordDialog(
context = context,
username = username,
oldPassword = password,
onChanged = { token ->
// Nach PW-Änderung direkt eingeloggt
TokenStore.save(context, token, username)
onSuccess(token)
},
onError = onError
)
}
return@launch
}
// normaler Login: Token speichern
val token = json.getString("token")
TokenStore.save(context, token, username)
withContext(Dispatchers.Main) { onSuccess(token) }
} catch (e: Exception) {
Log.e("LOGIN", "Exception", e)
withContext(Dispatchers.Main) { onError("Exception: ${e.message}") }
}
}
}
private fun showChangePasswordDialog(
context: Context,
username: String,
oldPassword: String,
onChanged: (String) -> Unit,
onError: (String) -> Unit
) {
val container = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setPadding(48, 24, 48, 0)
}
val etNew = EditText(context).apply {
hint = "Neues Passwort"
inputType = InputType.TYPE_CLASS_TEXT or
InputType.TYPE_TEXT_VARIATION_PASSWORD
}
val etRepeat = EditText(context).apply {
hint = "Neues Passwort (wiederholen)"
inputType = InputType.TYPE_CLASS_TEXT or
InputType.TYPE_TEXT_VARIATION_PASSWORD
}
container.addView(etNew)
container.addView(etRepeat)
val dialog = AlertDialog.Builder(context)
.setTitle("Passwort ändern")
.setMessage("Du verwendest ein Standard-Konto. Bitte setze jetzt ein eigenes Passwort.")
.setView(container)
.setPositiveButton("OK", null) // nicht sofort schließen lassen
.setNegativeButton("Abbrechen", null) // nicht sofort schließen lassen
.setCancelable(false)
.create()
dialog.setOnShowListener {
val btnOk = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
val btnCancel = dialog.getButton(AlertDialog.BUTTON_NEGATIVE)
btnOk.setOnClickListener {
etNew.error = null
etRepeat.error = null
val p1 = etNew.text?.toString().orEmpty()
val p2 = etRepeat.text?.toString().orEmpty()
when {
p1.length < 6 -> {
etNew.error = "Mindestens 6 Zeichen."
return@setOnClickListener
}
p1 != p2 -> {
etRepeat.error = "Passwörter stimmen nicht überein."
return@setOnClickListener
}
else -> {
btnOk.isEnabled = false
btnCancel.isEnabled = false
changePassword(
context = context,
username = username,
oldPassword = oldPassword,
newPassword = p1,
onChanged = { token ->
dialog.dismiss()
onChanged(token)
},
onError = { msg ->
btnOk.isEnabled = true
btnCancel.isEnabled = true
Toast.makeText(context, msg, Toast.LENGTH_LONG).show()
}
)
}
}
}
// >>> Überarbeitet: Abbrechen schließt Dialog und informiert den Aufrufer
btnCancel.setOnClickListener {
dialog.dismiss()
onError("Passwortänderung abgebrochen.")
}
}
dialog.show()
}
private fun changePassword(
context: Context,
username: String,
oldPassword: String,
newPassword: String,
onChanged: (String) -> Unit,
onError: (String) -> Unit
) {
CoroutineScope(Dispatchers.IO).launch {
try {
val body = JSONObject()
.put("username", username)
.put("old_password", oldPassword)
.put("new_password", newPassword)
.toString()
.toRequestBody("application/json".toMediaType())
val req = Request.Builder()
.url(SERVER_CHANGE_URL)
.post(body)
.build()
val resp = client.newCall(req).execute()
val txt = resp.body?.string()
if (!resp.isSuccessful || txt == null) {
withContext(Dispatchers.Main) { onError("Fehler beim Ändern (${resp.code})") }
return@launch
}
val json = JSONObject(txt)
if (!json.optBoolean("success")) {
withContext(Dispatchers.Main) { onError(json.optString("message", "Ändern fehlgeschlagen")) }
return@launch
}
val token = json.getString("token")
withContext(Dispatchers.Main) { onChanged(token) }
} catch (e: Exception) {
Log.e("LOGIN", "changePassword Exception", e)
withContext(Dispatchers.Main) { onError("Exception: ${e.message}") }
}
}
}
}

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

@ -1,21 +1,33 @@
package com.dano.test1 package com.dano.test1.network
import android.content.Context import android.content.Context
/*
TokenStore
- Kleiner Helper zum Verwalten der Login-Sitzung
- Speichert:
* das API-Token (KEY_TOKEN),
* den Usernamen (KEY_USER),
* den Zeitpunkt des Logins in Millisekunden (KEY_LOGIN_TS).
- Bietet Lese-/Schreib-Methoden sowie ein clear(), um alles zurückzusetzen.
Hinweis:
- Die Daten liegen in einer privaten App-Preference-Datei (PREF = "qdb_prefs").
- getLoginTimestamp() eignet sich, um die Token-Frische (Alter) zu berechnen.
*/
object TokenStore { object TokenStore {
private const val PREF = "qdb_prefs" private const val PREF = "qdb_prefs"
private const val KEY_TOKEN = "token" private const val KEY_TOKEN = "token"
private const val KEY_USER = "user" private const val KEY_USER = "user"
private const val KEY_LOGIN_TS = "login_ts" private const val KEY_LOGIN_TS = "login_ts"
/** Speichert Token, Username und Login-Timestamp (jetzt) */
fun save(context: Context, token: String, username: String) { fun save(context: Context, token: String, username: String) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
context.getSharedPreferences(PREF, Context.MODE_PRIVATE) context.getSharedPreferences(PREF, Context.MODE_PRIVATE)
.edit() .edit()
.putString(KEY_TOKEN, token) .putString(KEY_TOKEN, token) // API-/Session-Token
.putString(KEY_USER, username) .putString(KEY_USER, username) // angemeldeter Benutzername
.putLong(KEY_LOGIN_TS, now) .putLong(KEY_LOGIN_TS, now) // Zeitpunkt des Logins
.apply() .apply()
} }

View File

@ -1,4 +1,4 @@
package com.dano.test1 package com.dano.test1.questionnaire
import android.view.View import android.view.View
interface QuestionHandler { interface QuestionHandler {

View File

@ -0,0 +1,554 @@
package com.dano.test1.questionnaire
import android.graphics.Color
import android.text.Editable
import android.text.TextWatcher
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.constraintlayout.widget.ConstraintLayout
import com.dano.test1.LanguageManager
import com.dano.test1.LocalizationHelper
import com.dano.test1.MainActivity
import com.dano.test1.R
import com.dano.test1.network.TokenStore
import com.dano.test1.utils.ViewUtils
import com.google.android.material.button.MaterialButton
import com.google.android.material.card.MaterialCardView
import kotlinx.coroutines.*
class QuestionnaireAllInOne(private val questionnaireFileName: String) : QuestionnaireBase<Unit>() {
private data class Section(
val index: Int,
val question: QuestionItem,
val card: MaterialCardView,
val sectionView: View,
val handler: QuestionHandler?
)
private val sections = mutableListOf<Section>()
private lateinit var container: LinearLayout
private lateinit var btnSave: MaterialButton
private var setupComplete = false
override fun startQuestionnaire() {
val (meta, questionsList) = loadQuestionnaireFromJson(questionnaireFileName)
questionnaireMeta = meta
questions = questionsList
currentIndex = 0
buildAllInOneUi()
}
override fun showCurrentQuestion() {
// Not used in all-in-one mode
}
private fun buildAllInOneUi() {
sections.clear()
setupComplete = false
context.setContentView(R.layout.questionnaire_all_in_one)
container = context.findViewById(R.id.questionContainer)
btnSave = context.findViewById(R.id.btnSave)
val btnBack = context.findViewById<MaterialButton>(R.id.btnBack)
val progressBar = context.findViewById<ProgressBar>(R.id.progressBar)
btnSave.text = LanguageManager.getText(languageID, "save")
ViewUtils.setTextSizePercentOfScreenHeight(btnSave, 0.025f)
btnSave.isEnabled = false
btnSave.alpha = 0.5f
btnBack.setOnClickListener {
(context as? MainActivity)?.finishQuestionnaire()
}
setupLanguageSpinner(context.findViewById(R.id.langSpinner))
val inflater = LayoutInflater.from(context)
for ((idx, question) in questions.withIndex()) {
if (question is QuestionItem.LastPage) continue
val layoutResId = getLayoutResId(question.layout ?: "default_layout")
if (layoutResId == 0) continue
val sectionView = inflater.inflate(layoutResId, container, false)
adaptLayoutForEmbedding(sectionView)
LocalizationHelper.localizeViewTree(sectionView, languageID)
val card = wrapInCard(sectionView)
container.addView(card)
val handler = createEmbeddedHandler(question)
handler?.bind(sectionView, question)
reduceTextSizes(sectionView)
sections.add(Section(idx, question, card, sectionView, handler))
}
installChangeListeners()
recalculateVisibility()
// Allow DB restores to finish before enabling interaction tracking
container.postDelayed({
setupComplete = true
recalculateVisibility()
updateSaveButtonState()
}, 600)
btnSave.setOnClickListener {
handleSave(progressBar)
}
}
private fun setupLanguageSpinner(spinner: Spinner) {
val adapter = ArrayAdapter(context, android.R.layout.simple_spinner_item, LANGUAGE_LABELS).apply {
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
}
spinner.adapter = adapter
spinner.setSelection(LANGUAGE_IDS.indexOf(languageID).coerceAtLeast(0))
spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, v: View?, position: Int, id: Long) {
val newLang = LANGUAGE_IDS[position]
if (newLang == languageID) return
spinner.post { onLanguageChanged(newLang) }
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
}
private fun onLanguageChanged(newLang: String) {
for (section in sections) {
if (section.card.visibility == View.VISIBLE) {
try { section.handler?.saveAnswer() } catch (_: Exception) {}
}
}
languageID = newLang
buildAllInOneUi()
}
private fun createEmbeddedHandler(question: QuestionItem): QuestionHandler? {
val noop = {}
val noopId: (String) -> Unit = {}
val toast: (String) -> Unit = { showToast(it) }
val metaId = questionnaireMeta.id
return when (question) {
is QuestionItem.RadioQuestion ->
com.dano.test1.questionnaire.handlers.HandlerRadioQuestion(
context, answers, points, languageID, noop, noop, noopId, toast, metaId
)
is QuestionItem.ClientCoachCodeQuestion ->
com.dano.test1.questionnaire.handlers.HandlerClientCoachCode(
answers, languageID, noop, noop, toast
)
is QuestionItem.DateSpinnerQuestion ->
com.dano.test1.questionnaire.handlers.HandlerDateSpinner(
context, answers, languageID, noop, noop, toast, metaId
)
is QuestionItem.ValueSpinnerQuestion ->
com.dano.test1.questionnaire.handlers.HandlerValueSpinner(
context, answers, languageID, noop, noop, noopId, toast, metaId
)
is QuestionItem.GlassScaleQuestion ->
com.dano.test1.questionnaire.handlers.HandlerGlassScaleQuestion(
context, answers, points, languageID, noop, noop, toast, metaId
)
is QuestionItem.ClientNotSigned ->
com.dano.test1.questionnaire.handlers.HandlerClientNotSigned(
answers, languageID, noop, noop, toast
)
is QuestionItem.StringSpinnerQuestion ->
com.dano.test1.questionnaire.handlers.HandlerStringSpinner(
context, answers, languageID, noop, noop, toast, metaId
)
is QuestionItem.MultiCheckboxQuestion ->
com.dano.test1.questionnaire.handlers.HandlerMultiCheckboxQuestion(
context, answers, points, languageID, noop, noop, toast, metaId
)
else -> null
}
}
private fun adaptLayoutForEmbedding(view: View) {
if (view is ConstraintLayout) {
view.layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
// Ensure the last field in the card is never clipped at the bottom
view.setPadding(
view.paddingLeft, view.paddingTop,
view.paddingRight, ViewUtils.dp(context, 12)
)
}
view.findViewById<View>(R.id.Qprev)?.visibility = View.GONE
view.findViewById<View>(R.id.Qnext)?.visibility = View.GONE
view.findViewById<View>(R.id.gTop)?.let { guideline ->
if (guideline is androidx.constraintlayout.widget.Guideline) {
val params = guideline.layoutParams as? ConstraintLayout.LayoutParams
params?.guideBegin = ViewUtils.dp(context, 8)
guideline.layoutParams = params
}
}
adaptInnerScrollView(view, R.id.radioScroll)
adaptInnerScrollView(view, R.id.scrollView)
adaptInnerScrollView(view, R.id.glassScroll)
view.findViewById<EditText>(R.id.client_code)?.let { et ->
val params = et.layoutParams as? ConstraintLayout.LayoutParams ?: return@let
if (params.matchConstraintPercentHeight > 0f) {
params.matchConstraintPercentHeight = 0f
params.height = ViewUtils.dp(context, 64)
et.layoutParams = params
}
}
view.findViewById<EditText>(R.id.coach_code)?.let { et ->
val params = et.layoutParams as? ConstraintLayout.LayoutParams ?: return@let
if (params.matchConstraintPercentHeight > 0f) {
params.matchConstraintPercentHeight = 0f
params.height = ViewUtils.dp(context, 64)
et.layoutParams = params
}
}
}
/**
* Scales down all TextView text sizes by [factor] after the handler has set them.
* Spinners manage their own adapter text sizes and are skipped.
*/
private fun reduceTextSizes(view: View, factor: Float = 0.82f) {
if (view is Spinner) return
if (view is TextView) {
val dm = view.context.resources.displayMetrics
val currentSp = view.textSize / dm.scaledDensity
view.setTextSize(TypedValue.COMPLEX_UNIT_SP, currentSp * factor)
}
if (view is ViewGroup) {
for (i in 0 until view.childCount) reduceTextSizes(view.getChildAt(i), factor)
}
}
private fun adaptInnerScrollView(root: View, scrollViewId: Int) {
val sv = root.findViewById<ScrollView>(scrollViewId) ?: return
val params = sv.layoutParams
if (params is ConstraintLayout.LayoutParams) {
params.height = ConstraintLayout.LayoutParams.WRAP_CONTENT
params.bottomToTop = ConstraintLayout.LayoutParams.UNSET
params.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID
sv.layoutParams = params
}
sv.isNestedScrollingEnabled = false
}
private fun wrapInCard(sectionView: View): MaterialCardView {
val card = MaterialCardView(context).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
val margin = ViewUtils.dp(context, 6)
setMargins(0, margin, 0, margin)
}
radius = ViewUtils.dp(context, 16).toFloat()
cardElevation = ViewUtils.dp(context, 2).toFloat()
setCardBackgroundColor(Color.WHITE)
strokeColor = Color.parseColor("#D8D1F0")
strokeWidth = ViewUtils.dp(context, 1)
setContentPadding(
ViewUtils.dp(context, 4),
ViewUtils.dp(context, 8),
ViewUtils.dp(context, 4),
ViewUtils.dp(context, 12)
)
}
card.addView(sectionView)
return card
}
// ---- Visibility walk algorithm (reads from UI state, not answers map) ----
private fun recalculateVisibility() {
val visibleIndices = mutableSetOf<Int>()
var i = 0
while (i < questions.size) {
val q = questions[i]
if (q is QuestionItem.LastPage) { i++; continue }
visibleIndices.add(i)
val nextTarget = resolveNextFromUi(q)
when {
nextTarget != null -> {
if (questions.any { it is QuestionItem.LastPage && it.id == nextTarget }) {
break
}
val targetIdx = questions.indexOfFirst { it.id == nextTarget }
i = if (targetIdx != -1) targetIdx else i + 1
}
hasBranchingOptions(q) && !hasAnswerInUi(q) -> break
else -> i++
}
}
for (section in sections) {
val shouldShow = visibleIndices.contains(section.index)
section.card.visibility = if (shouldShow) View.VISIBLE else View.GONE
}
}
private fun resolveNextFromUi(q: QuestionItem): String? {
val section = sections.find { it.question === q } ?: return null
val view = section.sectionView
return when (q) {
is QuestionItem.RadioQuestion -> {
val radioGroup = view.findViewById<RadioGroup>(R.id.RadioGroup) ?: return null
val checkedId = radioGroup.checkedRadioButtonId
if (checkedId == -1) return null
val rb = radioGroup.findViewById<RadioButton>(checkedId) ?: return null
val key = rb.tag?.toString() ?: return null
q.options?.find { it.key == key }?.nextQuestionId
}
is QuestionItem.ValueSpinnerQuestion -> {
val spinner = view.findViewById<Spinner>(R.id.value_spinner) ?: return null
val selected = spinner.selectedItem?.toString() ?: return null
val prompt = LanguageManager.getText(languageID, "choose_answer")
if (selected == prompt) return null
val selectedVal = selected.toIntOrNull()
if (selectedVal != null) {
q.options?.find { it.value == selectedVal }?.nextQuestionId
} else null
}
else -> null
}
}
private fun hasBranchingOptions(q: QuestionItem): Boolean {
return when (q) {
is QuestionItem.RadioQuestion ->
q.options?.any { it.nextQuestionId != null } ?: false
is QuestionItem.ValueSpinnerQuestion ->
q.options?.any { it.nextQuestionId != null } ?: false
else -> false
}
}
private fun hasAnswerInUi(q: QuestionItem): Boolean {
val section = sections.find { it.question === q } ?: return false
val view = section.sectionView
return when (q) {
is QuestionItem.RadioQuestion -> {
val rg = view.findViewById<RadioGroup>(R.id.RadioGroup) ?: return false
rg.checkedRadioButtonId != -1
}
is QuestionItem.ValueSpinnerQuestion -> {
val spinner = view.findViewById<Spinner>(R.id.value_spinner) ?: return false
val selected = spinner.selectedItem?.toString()
val prompt = LanguageManager.getText(languageID, "choose_answer")
!selected.isNullOrEmpty() && selected != prompt
}
else -> true
}
}
// ---- Change listeners ----
private fun installChangeListeners() {
for (section in sections) {
val view = section.sectionView
when (section.question) {
is QuestionItem.RadioQuestion -> {
view.findViewById<RadioGroup>(R.id.RadioGroup)
?.setOnCheckedChangeListener { _, checkedId ->
if (!setupComplete || checkedId == -1) return@setOnCheckedChangeListener
recalculateVisibility()
updateSaveButtonState()
}
}
is QuestionItem.ValueSpinnerQuestion -> {
installSpinnerChangeListener(view, R.id.value_spinner)
}
is QuestionItem.StringSpinnerQuestion -> {
installSpinnerChangeListener(view, R.id.string_spinner)
}
is QuestionItem.DateSpinnerQuestion -> {
val listener = createSimpleSpinnerListener()
view.findViewById<Spinner>(R.id.spinner_value_day)?.onItemSelectedListener = listener
view.findViewById<Spinner>(R.id.spinner_value_month)?.onItemSelectedListener = listener
view.findViewById<Spinner>(R.id.spinner_value_year)?.onItemSelectedListener = listener
}
is QuestionItem.GlassScaleQuestion -> {
installGlassScaleClickListeners(view)
}
is QuestionItem.MultiCheckboxQuestion -> {
installCheckboxClickListeners(view)
}
is QuestionItem.ClientCoachCodeQuestion -> {
installEditTextWatcher(view, R.id.client_code)
installEditTextWatcher(view, R.id.coach_code)
}
is QuestionItem.ClientNotSigned -> {
installEditTextWatcher(view, R.id.coach_code)
}
else -> {}
}
}
}
private fun installSpinnerChangeListener(view: View, spinnerId: Int) {
view.findViewById<Spinner>(spinnerId)?.onItemSelectedListener =
object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(
parent: AdapterView<*>?, v: View?, pos: Int, id: Long
) {
if (!setupComplete) return
recalculateVisibility()
updateSaveButtonState()
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
}
private fun createSimpleSpinnerListener(): AdapterView.OnItemSelectedListener {
return object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, v: View?, pos: Int, id: Long) {
if (!setupComplete) return
updateSaveButtonState()
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
}
private fun installGlassScaleClickListeners(view: View) {
val table = view.findViewById<TableLayout>(R.id.glass_table) ?: return
for (r in 0 until table.childCount) {
val row = table.getChildAt(r) as? TableRow ?: continue
val radioGroup = row.getChildAt(1) as? RadioGroup ?: continue
for (c in 0 until radioGroup.childCount) {
val rb = getRadioFromChild(radioGroup.getChildAt(c)) ?: continue
rb.setOnClickListener {
if (setupComplete) updateSaveButtonState()
}
}
}
}
private fun getRadioFromChild(child: View): RadioButton? = when (child) {
is RadioButton -> child
is FrameLayout -> child.getChildAt(0) as? RadioButton
else -> null
}
private fun installCheckboxClickListeners(view: View) {
val cont = view.findViewById<LinearLayout>(R.id.CheckboxContainer) ?: return
for (i in 0 until cont.childCount) {
val cb = cont.getChildAt(i) as? CheckBox ?: continue
cb.setOnClickListener {
if (setupComplete) updateSaveButtonState()
}
}
}
private fun installEditTextWatcher(view: View, editTextId: Int) {
view.findViewById<EditText>(editTextId)?.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
if (!setupComplete) return
updateSaveButtonState()
}
})
}
// ---- Save button state ----
private fun updateSaveButtonState() {
val allValid = sections
.filter { it.card.visibility == View.VISIBLE }
.all { section ->
val handler = section.handler ?: return@all true
try { handler.validate() } catch (_: Exception) { false }
}
btnSave.isEnabled = allValid
btnSave.alpha = if (allValid) 1f else 0.5f
}
// ---- Save flow ----
private fun handleSave(progressBar: ProgressBar) {
val visibleSections = sections.filter { it.card.visibility == View.VISIBLE }
for (section in visibleSections) {
val handler = section.handler ?: continue
if (!handler.validate()) {
showToast(LanguageManager.getText(languageID, "fill_all_fields"))
scrollToSection(section)
return
}
}
for (section in visibleSections) {
if (section.question is QuestionItem.ClientCoachCodeQuestion) {
saveClientCoachCodeFromUi(section.sectionView)
}
section.handler?.saveAnswer()
}
showLoading(progressBar, true)
CoroutineScope(Dispatchers.IO).launch {
val startTime = System.currentTimeMillis()
saveAnswersToDatabase(answers, questionnaireMeta.id)
GlobalValues.INTEGRATION_INDEX = points.sum()
val clientCode = answers["client_code"] as? String
if (clientCode != null) {
GlobalValues.LAST_CLIENT_CODE = clientCode
GlobalValues.LOADED_CLIENT_CODE = clientCode
}
val elapsed = System.currentTimeMillis() - startTime
if (elapsed < 1500L) delay(1500L - elapsed)
withContext(Dispatchers.Main) {
showLoading(progressBar, false)
endQuestionnaire()
}
}
}
private fun saveClientCoachCodeFromUi(view: View) {
val clientCode = view.findViewById<EditText>(R.id.client_code)?.text?.toString() ?: ""
val coachCode = view.findViewById<EditText>(R.id.coach_code)?.text?.toString() ?: ""
GlobalValues.LAST_CLIENT_CODE = clientCode
answers["client_code"] = clientCode
answers["coach_code"] = TokenStore.getUsername(context) ?: coachCode
}
private fun showLoading(progressBar: ProgressBar, show: Boolean) {
progressBar.visibility = if (show) View.VISIBLE else View.GONE
btnSave.isEnabled = !show
btnSave.alpha = if (show) 0.5f else 1f
}
private fun scrollToSection(section: Section) {
val scrollView = context.findViewById<androidx.core.widget.NestedScrollView>(R.id.scrollContainer)
scrollView?.post {
scrollView.smoothScrollTo(0, section.card.top)
}
}
}

View File

@ -1,10 +1,23 @@
package com.dano.test1 package com.dano.test1.questionnaire
import android.R
import android.app.Activity import android.app.Activity
import android.util.Log import android.util.Log
import android.view.View import android.view.View
import android.widget.* import android.widget.*
import com.dano.test1.LanguageManager
import com.dano.test1.MainActivity
import com.dano.test1.MyApp
import com.dano.test1.data.* import com.dano.test1.data.*
import com.dano.test1.questionnaire.handlers.HandlerClientCoachCode
import com.dano.test1.questionnaire.handlers.HandlerClientNotSigned
import com.dano.test1.questionnaire.handlers.HandlerDateSpinner
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.HandlerStringSpinner
import com.dano.test1.questionnaire.handlers.HandlerValueSpinner
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.JsonParser import com.google.gson.JsonParser
import kotlinx.coroutines.* import kotlinx.coroutines.*
@ -27,6 +40,17 @@ abstract class QuestionnaireBase<T> {
abstract fun startQuestionnaire() abstract fun startQuestionnaire()
abstract fun showCurrentQuestion() abstract fun showCurrentQuestion()
companion object {
val LANGUAGE_IDS = listOf(
"GERMAN", "ENGLISH", "FRENCH", "ROMANIAN", "ARABIC",
"POLISH", "TURKISH", "UKRAINIAN", "RUSSIAN", "SPANISH"
)
val LANGUAGE_LABELS = listOf(
"DE", "EN", "FR", "RO", "AR",
"PL", "TR", "UA", "RU", "ES"
)
}
fun attach(activity: Activity, language: String) { fun attach(activity: Activity, language: String) {
this.context = activity this.context = activity
this.languageID = language this.languageID = language
@ -61,8 +85,8 @@ abstract class QuestionnaireBase<T> {
} }
protected fun setupSpinner(spinner: Spinner, spinnerValues: List<Any>, selectedValue: Any?) { protected fun setupSpinner(spinner: Spinner, spinnerValues: List<Any>, selectedValue: Any?) {
val adapter = ArrayAdapter(context, android.R.layout.simple_spinner_item, spinnerValues).apply { val adapter = ArrayAdapter(context, R.layout.simple_spinner_item, spinnerValues).apply {
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) setDropDownViewResource(R.layout.simple_spinner_dropdown_item)
} }
spinner.adapter = adapter spinner.adapter = adapter
selectedValue?.let { value -> selectedValue?.let { value ->
@ -75,7 +99,7 @@ abstract class QuestionnaireBase<T> {
protected fun navigateTo(layoutResId: Int, setup: (View) -> Unit) { protected fun navigateTo(layoutResId: Int, setup: (View) -> Unit) {
context.setContentView(layoutResId) context.setContentView(layoutResId)
val rootView = context.findViewById<View>(android.R.id.content) val rootView = context.findViewById<View>(R.id.content)
setup(rootView) setup(rootView)
} }
@ -85,7 +109,7 @@ abstract class QuestionnaireBase<T> {
protected fun showEmptyScreen() { protected fun showEmptyScreen() {
navigateTo(getLayoutResId("empty")) { navigateTo(getLayoutResId("empty")) {
setupPrevButton(R.id.Qprev) { goToPreviousQuestion() } setupPrevButton(com.dano.test1.R.id.Qprev) { goToPreviousQuestion() }
} }
} }

View File

@ -0,0 +1,86 @@
package com.dano.test1.questionnaire
import android.view.Gravity
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.FrameLayout
import android.widget.Spinner
import androidx.core.content.ContextCompat
import com.dano.test1.LocalizationHelper
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 ->
LocalizationHelper.localizeViewTree(layout, languageID)
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 adapter = ArrayAdapter(context, android.R.layout.simple_spinner_item, LANGUAGE_LABELS).apply {
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
}
spinner.adapter = adapter
spinner.background = ContextCompat.getDrawable(context, R.drawable.bg_field_filled)
spinner.setSelection(LANGUAGE_IDS.indexOf(languageID).coerceAtLeast(0))
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 = LANGUAGE_IDS[position]
if (newLang == languageID) return
languageID = newLang
spinner.post { showCurrentQuestion() }
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
}
}

View File

@ -1,4 +1,4 @@
package com.dano.test1 package com.dano.test1.questionnaire
data class Option( data class Option(
val key: String, // Must always be set val key: String, // Must always be set

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

@ -1,14 +1,25 @@
package com.dano.test1 package com.dano.test1.questionnaire.handlers
import android.view.View import android.view.View
import android.widget.* import android.widget.*
import android.util.TypedValue import com.dano.test1.questionnaire.GlobalValues
import androidx.core.widget.TextViewCompat import com.dano.test1.LanguageManager
import com.dano.test1.MyApp
import com.dano.test1.questionnaire.QuestionHandler
import com.dano.test1.questionnaire.QuestionItem
import com.dano.test1.R
import com.dano.test1.network.TokenStore
import com.dano.test1.utils.ViewUtils
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
/*
Zweck :
- Steuert die Eingabeseite für Client Code und Coach Code innerhalb des Fragebogen-Flows.
*/
class HandlerClientCoachCode( class HandlerClientCoachCode(
private val answers: MutableMap<String, Any>, private val answers: MutableMap<String, Any>,
private val languageID: String, private val languageID: String,
@ -33,10 +44,10 @@ class HandlerClientCoachCode(
questionTextView.text = question.question?.let { LanguageManager.getText(languageID, it) } ?: "" questionTextView.text = question.question?.let { LanguageManager.getText(languageID, it) } ?: ""
setTextSizePercentOfScreenHeight(titleTextView, 0.03f) ViewUtils.setTextSizePercentOfScreenHeight(titleTextView, 0.03f)
setTextSizePercentOfScreenHeight(questionTextView, 0.03f) ViewUtils.setTextSizePercentOfScreenHeight(questionTextView, 0.03f)
setTextSizePercentOfScreenHeight(clientCodeField, 0.025f) ViewUtils.setTextSizePercentOfScreenHeight(clientCodeField, 0.025f)
setTextSizePercentOfScreenHeight(coachCodeField, 0.025f) ViewUtils.setTextSizePercentOfScreenHeight(coachCodeField, 0.025f)
// Client-Code: nur verwenden, wenn bereits geladen // Client-Code: nur verwenden, wenn bereits geladen
val loadedClientCode = GlobalValues.LOADED_CLIENT_CODE val loadedClientCode = GlobalValues.LOADED_CLIENT_CODE
@ -48,11 +59,11 @@ class HandlerClientCoachCode(
clientCodeField.isEnabled = true clientCodeField.isEnabled = true
} }
// === NEU: Coach-Code immer aus dem Login (TokenStore) setzen und sperren === // Coach-Code immer aus dem Login (TokenStore) setzen und sperren
val coachFromLogin = TokenStore.getUsername(layout.context) val coachFromLogin = TokenStore.getUsername(layout.context)
if (!coachFromLogin.isNullOrBlank()) { if (!coachFromLogin.isNullOrBlank()) {
coachCodeField.setText(coachFromLogin) coachCodeField.setText(coachFromLogin)
lockCoachField(coachCodeField) // optisch & technisch gesperrt ViewUtils.lockEditField(coachCodeField) // optisch & technisch gesperrt
} else { } else {
// Falls (theoretisch) kein Login-Username vorhanden ist, verhalten wie bisher // Falls (theoretisch) kein Login-Username vorhanden ist, verhalten wie bisher
coachCodeField.setText(answers["coach_code"] as? String ?: "") coachCodeField.setText(answers["coach_code"] as? String ?: "")
@ -67,13 +78,6 @@ class HandlerClientCoachCode(
} }
} }
private fun setTextSizePercentOfScreenHeight(view: TextView, percentOfHeight: Float) {
val dm = layout.resources.displayMetrics
val sp = (dm.heightPixels * percentOfHeight) / dm.scaledDensity
TextViewCompat.setAutoSizeTextTypeWithDefaults(view, TextViewCompat.AUTO_SIZE_TEXT_TYPE_NONE)
view.setTextSize(TypedValue.COMPLEX_UNIT_SP, sp)
}
private fun onNextClicked(clientCodeField: EditText, coachCodeField: EditText) { private fun onNextClicked(clientCodeField: EditText, coachCodeField: EditText) {
val loadedClientCode = GlobalValues.LOADED_CLIENT_CODE val loadedClientCode = GlobalValues.LOADED_CLIENT_CODE
@ -122,7 +126,6 @@ class HandlerClientCoachCode(
override fun validate(): Boolean { override fun validate(): Boolean {
val clientCode = layout.findViewById<EditText>(R.id.client_code).text val clientCode = layout.findViewById<EditText>(R.id.client_code).text
// Validierung nimmt den (ggf. gesperrten) Text passt
val coachText = layout.findViewById<EditText>(R.id.coach_code).text val coachText = layout.findViewById<EditText>(R.id.coach_code).text
return clientCode.isNotBlank() && coachText.isNotBlank() return clientCode.isNotBlank() && coachText.isNotBlank()
} }
@ -139,20 +142,4 @@ class HandlerClientCoachCode(
// Not used // Not used
} }
// --- Helfer zum Sperren inkl. optischer Markierung (wie im Opening Screen) ---
private fun lockCoachField(field: EditText) {
field.isFocusable = false
field.isFocusableInTouchMode = false
field.isCursorVisible = false
field.keyListener = null
field.isLongClickable = false
field.isClickable = false
field.setBackgroundResource(R.drawable.bg_field_locked)
field.setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.ic_lock_24, 0)
field.compoundDrawablePadding = dp(8)
field.alpha = 0.95f
}
private fun dp(v: Int): Int =
(v * layout.resources.displayMetrics.density).toInt()
} }

View File

@ -1,7 +1,17 @@
package com.dano.test1 package com.dano.test1.questionnaire.handlers
import android.view.View import android.view.View
import android.widget.* import android.widget.*
import com.dano.test1.LanguageManager
import com.dano.test1.questionnaire.QuestionHandler
import com.dano.test1.questionnaire.QuestionItem
import com.dano.test1.R
/*
Zweck:
- Steuert die Seite Client hat nicht unterschrieben im Fragebogenfluss.
- Speichert den eingegebenen Coach-Code in das Answers-Map unter question.id (saveAnswer), damit der nachfolgende Prozess darauf zugreifen kann.
*/
class HandlerClientNotSigned( class HandlerClientNotSigned(
private val answers: MutableMap<String, Any>, private val answers: MutableMap<String, Any>,
@ -14,7 +24,6 @@ class HandlerClientNotSigned(
private lateinit var layout: View private lateinit var layout: View
private lateinit var question: QuestionItem.ClientNotSigned private lateinit var question: QuestionItem.ClientNotSigned
// UI components
private lateinit var textView1: TextView private lateinit var textView1: TextView
private lateinit var textView2: TextView private lateinit var textView2: TextView
private lateinit var questionTextView: TextView private lateinit var questionTextView: TextView
@ -26,29 +35,24 @@ class HandlerClientNotSigned(
this.layout = layout this.layout = layout
this.question = question this.question = question
// Initialize UI components only once
initViews() initViews()
// Set localized text values from LanguageManager
textView1.text = question.textKey1?.let { LanguageManager.getText(languageID, it) } ?: "" textView1.text = question.textKey1?.let { LanguageManager.getText(languageID, it) } ?: ""
textView2.text = question.textKey2?.let { LanguageManager.getText(languageID, it) } ?: "" textView2.text = question.textKey2?.let { LanguageManager.getText(languageID, it) } ?: ""
questionTextView.text = question.question?.let { LanguageManager.getText(languageID, it) } ?: "" questionTextView.text = question.question?.let { LanguageManager.getText(languageID, it) } ?: ""
// Populate EditText with previous value if exists
coachCodeField.setText(answers[question.id] as? String ?: "") coachCodeField.setText(answers[question.id] as? String ?: "")
// Set click listener for Next button
layout.findViewById<Button>(R.id.Qnext).setOnClickListener { layout.findViewById<Button>(R.id.Qnext).setOnClickListener {
onNextClicked() onNextClicked()
} }
// Set click listener for Previous button
layout.findViewById<Button>(R.id.Qprev).setOnClickListener { layout.findViewById<Button>(R.id.Qprev).setOnClickListener {
goToPreviousQuestion() goToPreviousQuestion()
} }
} }
// Initialize all views once to avoid repeated findViewById calls
private fun initViews() { private fun initViews() {
textView1 = layout.findViewById(R.id.textView1) textView1 = layout.findViewById(R.id.textView1)
textView2 = layout.findViewById(R.id.textView2) textView2 = layout.findViewById(R.id.textView2)
@ -56,7 +60,6 @@ class HandlerClientNotSigned(
coachCodeField = layout.findViewById(R.id.coach_code) coachCodeField = layout.findViewById(R.id.coach_code)
} }
// Handle Next button click
private fun onNextClicked() { private fun onNextClicked() {
if (validate()) { if (validate()) {
saveAnswer() saveAnswer()
@ -67,13 +70,11 @@ class HandlerClientNotSigned(
} }
} }
// Validate that coach code field is not empty
override fun validate(): Boolean { override fun validate(): Boolean {
val coachCode = coachCodeField.text val coachCode = coachCodeField.text
return coachCode.isNotBlank() return coachCode.isNotBlank()
} }
// Save entered coach code to answers map
override fun saveAnswer() { override fun saveAnswer() {
answers[question.id] = coachCodeField.text.toString() answers[question.id] = coachCodeField.text.toString()
} }

View File

@ -1,15 +1,26 @@
package com.dano.test1 package com.dano.test1.questionnaire.handlers
import android.content.Context import android.content.Context
import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.* import android.widget.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import android.util.TypedValue import com.dano.test1.questionnaire.GlobalValues
import androidx.core.widget.TextViewCompat import com.dano.test1.LanguageManager
import android.widget.AbsListView import com.dano.test1.questionnaire.MAX_VALUE_YEAR
import com.dano.test1.ui.Month
import com.dano.test1.ui.Months
import com.dano.test1.MyApp
import com.dano.test1.questionnaire.QuestionHandler
import com.dano.test1.questionnaire.QuestionItem
import com.dano.test1.R
import com.dano.test1.utils.ViewUtils
/*
Zweck:
Rendert eine Datumsfrage mit drei Spinnern (Tag/Monat/Jahr) innerhalb des Fragebogen-Flows.
*/
class HandlerDateSpinner( class HandlerDateSpinner(
private val context: Context, private val context: Context,
@ -46,13 +57,12 @@ class HandlerDateSpinner(
questionTextView.text = question.question?.let { LanguageManager.getText(languageID, it) } ?: "" questionTextView.text = question.question?.let { LanguageManager.getText(languageID, it) } ?: ""
textView.text = question.textKey?.let { LanguageManager.getText(languageID, it) } ?: "" textView.text = question.textKey?.let { LanguageManager.getText(languageID, it) } ?: ""
// —— Schriftgrößen pro Bildschirmhöhe —— // Schriftgrößen pro Bildschirmhöhe
setTextSizePercentOfScreenHeight(textView, 0.03f) // oben ViewUtils.setTextSizePercentOfScreenHeight(textView, 0.03f)
setTextSizePercentOfScreenHeight(questionTextView, 0.03f) // frage ViewUtils.setTextSizePercentOfScreenHeight(questionTextView, 0.03f)
setTextSizePercentOfScreenHeight(labelDay, 0.025f) ViewUtils.setTextSizePercentOfScreenHeight(labelDay, 0.025f)
setTextSizePercentOfScreenHeight(labelMonth, 0.025f) ViewUtils.setTextSizePercentOfScreenHeight(labelMonth, 0.025f)
setTextSizePercentOfScreenHeight(labelYear, 0.025f) ViewUtils.setTextSizePercentOfScreenHeight(labelYear, 0.025f)
// ————————————————————————————————
// gespeicherte Antwort (YYYY-MM-DD) lesen // gespeicherte Antwort (YYYY-MM-DD) lesen
val (savedYear, savedMonthIndex, savedDay) = question.question?.let { val (savedYear, savedMonthIndex, savedDay) = question.question?.let {
@ -70,9 +80,9 @@ class HandlerDateSpinner(
val defaultYear = savedYear ?: today.get(Calendar.YEAR) val defaultYear = savedYear ?: today.get(Calendar.YEAR)
// Spinner responsiv aufsetzen (Schrift + Zeilenhöhe ohne Abschneiden) // Spinner responsiv aufsetzen (Schrift + Zeilenhöhe ohne Abschneiden)
setupSpinner(spinnerDay, days, defaultDay) ViewUtils.setupResponsiveSpinner(context, spinnerDay, days, defaultDay)
setupSpinner(spinnerMonth, months, defaultMonth) ViewUtils.setupResponsiveSpinner(context, spinnerMonth, months, defaultMonth)
setupSpinner(spinnerYear, years, defaultYear) ViewUtils.setupResponsiveSpinner(context, spinnerYear, years, defaultYear)
// DB-Abfrage, falls noch nicht im answers-Map // DB-Abfrage, falls noch nicht im answers-Map
val answerMapKey = question.question ?: (question.id ?: "") val answerMapKey = question.question ?: (question.id ?: "")
@ -202,72 +212,4 @@ class HandlerDateSpinner(
return sdf.parse(dateString) return sdf.parse(dateString)
} }
// —— Textgröße prozentual zur Bildschirmhöhe (in sp) ——
private fun setTextSizePercentOfScreenHeight(view: TextView, percentOfHeight: Float) {
val dm = (view.context ?: layout.context).resources.displayMetrics
val sp = (dm.heightPixels * percentOfHeight) / dm.scaledDensity
TextViewCompat.setAutoSizeTextTypeWithDefaults(view, TextViewCompat.AUTO_SIZE_TEXT_TYPE_NONE)
view.setTextSize(TypedValue.COMPLEX_UNIT_SP, sp)
}
// —— Spinner-Adapter: Schrift & Zeilenhöhe dynamisch, kein Abschneiden ——
private fun <T> setupSpinner(spinner: Spinner, items: List<T>, defaultSelection: T?) {
val dm = context.resources.displayMetrics
fun spFromScreenHeight(percent: Float): Float =
(dm.heightPixels * percent) / dm.scaledDensity
fun pxFromSp(sp: Float): Int = (sp * dm.scaledDensity).toInt()
val textSp = spFromScreenHeight(0.0275f) // ~2.75% der Bildschirmhöhe
val textPx = pxFromSp(textSp)
val vPadPx = (textPx * 0.50f).toInt() // vertikales Padding
val rowHeight = (textPx * 2.20f + 2 * vPadPx).toInt() // feste Zeilenhöhe
val adapter = object : ArrayAdapter<T>(context, android.R.layout.simple_spinner_item, items) {
private fun styleRow(tv: TextView, forceHeight: Boolean) {
tv.setTextSize(TypedValue.COMPLEX_UNIT_SP, textSp)
tv.includeFontPadding = true
tv.setLineSpacing(0f, 1.2f)
tv.gravity = (tv.gravity and android.view.Gravity.HORIZONTAL_GRAVITY_MASK) or android.view.Gravity.CENTER_VERTICAL
tv.setPadding(tv.paddingLeft, vPadPx, tv.paddingRight, vPadPx)
tv.minHeight = rowHeight
tv.isSingleLine = true
if (forceHeight) {
val lp = tv.layoutParams
if (lp == null || lp.height <= 0) {
tv.layoutParams = AbsListView.LayoutParams(
AbsListView.LayoutParams.MATCH_PARENT, rowHeight
)
} else {
lp.height = rowHeight
}
}
}
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val v = super.getView(position, convertView, parent) as TextView
styleRow(v, forceHeight = false) // ausgewählte Ansicht
return v
}
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
val v = super.getDropDownView(position, convertView, parent) as TextView
styleRow(v, forceHeight = true) // Dropdown-Zeilen: Höhe erzwingen
return v
}
}
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
spinner.adapter = adapter
// Spinner selbst ausreichend hoch
spinner.setPadding(spinner.paddingLeft, vPadPx, spinner.paddingRight, vPadPx)
spinner.minimumHeight = rowHeight
spinner.requestLayout()
defaultSelection?.let {
val index = items.indexOf(it)
if (index >= 0) spinner.setSelection(index)
}
}
} }

View File

@ -1,13 +1,25 @@
package com.dano.test1 package com.dano.test1.questionnaire.handlers
import android.content.Context import android.content.Context
import android.util.TypedValue
import android.view.Gravity import android.view.Gravity
import android.view.View import android.view.View
import android.widget.* import android.widget.*
import androidx.core.widget.TextViewCompat import com.dano.test1.questionnaire.GlobalValues
import com.dano.test1.LanguageManager
import com.dano.test1.MyApp
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.* 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( class HandlerGlassScaleQuestion(
private val context: Context, private val context: Context,
private val answers: MutableMap<String, Any>, private val answers: MutableMap<String, Any>,
@ -46,6 +58,9 @@ class HandlerGlassScaleQuestion(
"extreme_glass" to R.drawable.ic_glass_4 "extreme_glass" to R.drawable.ic_glass_4
) )
// Damit wir beim programmatic-check keine Endlosschleifen triggern
private var suppressRowListener: Boolean = false
override fun bind(layout: View, question: QuestionItem) { override fun bind(layout: View, question: QuestionItem) {
if (question !is QuestionItem.GlassScaleQuestion) return if (question !is QuestionItem.GlassScaleQuestion) return
this.layout = layout this.layout = layout
@ -57,15 +72,16 @@ class HandlerGlassScaleQuestion(
titleTv.text = question.textKey?.let { LanguageManager.getText(languageID, it) } ?: "" titleTv.text = question.textKey?.let { LanguageManager.getText(languageID, it) } ?: ""
questionTv.text = question.question?.let { LanguageManager.getText(languageID, it) } ?: "" questionTv.text = question.question?.let { LanguageManager.getText(languageID, it) } ?: ""
setTextSizePercentOfScreenHeight(titleTv, 0.03f) ViewUtils.setTextSizePercentOfScreenHeight(titleTv, 0.03f)
setTextSizePercentOfScreenHeight(questionTv, 0.03f) ViewUtils.setTextSizePercentOfScreenHeight(questionTv, 0.03f)
// ----- feste Icon-Leiste ----- // Header Icons
val header = layout.findViewById<LinearLayout>(R.id.glass_header) val header = layout.findViewById<LinearLayout>(R.id.glass_header)
header.removeAllViews() header.removeAllViews()
header.addView(Space(context).apply { header.addView(Space(context).apply {
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 4f) layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 4f)
}) })
val iconSizePx = (context.resources.displayMetrics.density * 36).toInt() val iconSizePx = (context.resources.displayMetrics.density * 36).toInt()
scaleLabels.forEach { labelKey -> scaleLabels.forEach { labelKey ->
val cell = FrameLayout(context).apply { val cell = FrameLayout(context).apply {
@ -80,13 +96,12 @@ class HandlerGlassScaleQuestion(
cell.addView(img) cell.addView(img)
header.addView(cell) header.addView(cell)
} }
// -----------------------------
val tableLayout = layout.findViewById<TableLayout>(R.id.glass_table) val tableLayout = layout.findViewById<TableLayout>(R.id.glass_table)
tableLayout.removeAllViews() tableLayout.removeAllViews()
addSymptomRows(tableLayout) addSymptomRows(tableLayout)
// ggf. Antworten aus DB wiederherstellen // Restore aus DB (nur wenn noch nicht im answers)
val anySymptomNeedsRestore = question.symptoms.any { !answers.containsKey(it) } val anySymptomNeedsRestore = question.symptoms.any { !answers.containsKey(it) }
if (anySymptomNeedsRestore) { if (anySymptomNeedsRestore) {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
@ -96,21 +111,14 @@ class HandlerGlassScaleQuestion(
val answerMap = allAnswersForClient.associateBy({ it.questionId }, { it.answerValue }) val answerMap = allAnswersForClient.associateBy({ it.questionId }, { it.answerValue })
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
val table = tableLayout
for ((index, symptomKey) in question.symptoms.withIndex()) { for ((index, symptomKey) in question.symptoms.withIndex()) {
val answerMapKey = "$questionnaireMeta-$symptomKey" val answerMapKey = "$questionnaireMeta-$symptomKey"
val dbAnswer = answerMap[answerMapKey]?.takeIf { it.isNotBlank() }?.trim() val dbAnswer = answerMap[answerMapKey]?.takeIf { it.isNotBlank() }?.trim()
if (!answers.containsKey(symptomKey) && !dbAnswer.isNullOrBlank()) { if (!answers.containsKey(symptomKey) && !dbAnswer.isNullOrBlank()) {
if (index < table.childCount) { if (index < tableLayout.childCount) {
val row = table.getChildAt(index) as? TableRow ?: continue val row = tableLayout.getChildAt(index) as? TableRow ?: continue
val radioGroup = row.getChildAt(1) as? RadioGroup ?: continue val radioGroup = row.getChildAt(1) as? RadioGroup ?: continue
for (i in 0 until radioGroup.childCount) { setSingleSelection(radioGroup, dbAnswer) // <-- FIXED restore
val rb = getRadioFromChild(radioGroup.getChildAt(i)) ?: continue
if ((rb.tag as? String)?.trim() == dbAnswer) {
rb.isChecked = true
break
}
}
answers[symptomKey] = dbAnswer answers[symptomKey] = dbAnswer
points.add(pointsMap[dbAnswer] ?: 0) points.add(pointsMap[dbAnswer] ?: 0)
} }
@ -147,28 +155,63 @@ class HandlerGlassScaleQuestion(
text = LanguageManager.getText(languageID, symptomKey) text = LanguageManager.getText(languageID, symptomKey)
layoutParams = TableRow.LayoutParams(0, TableRow.LayoutParams.WRAP_CONTENT, 4f) layoutParams = TableRow.LayoutParams(0, TableRow.LayoutParams.WRAP_CONTENT, 4f)
setPadding(4, 16, 4, 16) setPadding(4, 16, 4, 16)
setTextSizePercentOfScreenHeight(this, 0.022f) ViewUtils.setTextSizePercentOfScreenHeight(this, 0.022f)
} }
row.addView(symptomText) row.addView(symptomText)
val radioGroup = RadioGroup(context).apply { val radioGroup = RadioGroup(context).apply {
orientation = RadioGroup.HORIZONTAL orientation = RadioGroup.HORIZONTAL
layoutParams = TableRow.LayoutParams(0, TableRow.LayoutParams.WRAP_CONTENT, 5f) layoutParams = TableRow.LayoutParams(0, TableRow.LayoutParams.WRAP_CONTENT, 5f)
setPadding(0, 0, 0, 0)
} }
// WICHTIG: RadioButtons sind direkte Kinder des RadioGroup! // Build buttons
scaleLabels.forEach { labelKey -> scaleLabels.forEach { labelKey ->
val cell = FrameLayout(context).apply {
layoutParams = RadioGroup.LayoutParams(0, RadioGroup.LayoutParams.WRAP_CONTENT, 1f)
}
val rb = RadioButton(context).apply { val rb = RadioButton(context).apply {
tag = labelKey tag = labelKey
id = View.generateViewId() id = View.generateViewId()
isChecked = savedLabel == labelKey isChecked = false
setPadding(0, 0, 0, 0) setPadding(0, 0, 0, 0)
} }
val lp = RadioGroup.LayoutParams(
0, RadioGroup.LayoutParams.WRAP_CONTENT, 1f rb.layoutParams = FrameLayout.LayoutParams(
).apply { gravity = Gravity.CENTER } FrameLayout.LayoutParams.WRAP_CONTENT,
rb.layoutParams = lp FrameLayout.LayoutParams.WRAP_CONTENT,
radioGroup.addView(rb) 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) row.addView(radioGroup)
@ -176,6 +219,22 @@ class HandlerGlassScaleQuestion(
} }
} }
/**
* 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 { override fun validate(): Boolean {
val table = layout.findViewById<TableLayout>(R.id.glass_table) val table = layout.findViewById<TableLayout>(R.id.glass_table)
for (i in 0 until table.childCount) { for (i in 0 until table.childCount) {
@ -203,6 +262,7 @@ class HandlerGlassScaleQuestion(
val row = table.getChildAt(i) as TableRow val row = table.getChildAt(i) as TableRow
val symptomKey = question.symptoms[i] val symptomKey = question.symptoms[i]
val radioGroup = row.getChildAt(1) as RadioGroup val radioGroup = row.getChildAt(1) as RadioGroup
for (j in 0 until radioGroup.childCount) { for (j in 0 until radioGroup.childCount) {
val rb = getRadioFromChild(radioGroup.getChildAt(j)) ?: continue val rb = getRadioFromChild(radioGroup.getChildAt(j)) ?: continue
if (rb.isChecked) { if (rb.isChecked) {
@ -215,7 +275,6 @@ class HandlerGlassScaleQuestion(
} }
} }
// --- Helpers ---
private fun getRadioFromChild(child: View): RadioButton? = private fun getRadioFromChild(child: View): RadioButton? =
when (child) { when (child) {
is RadioButton -> child is RadioButton -> child
@ -223,10 +282,4 @@ class HandlerGlassScaleQuestion(
else -> null else -> null
} }
private fun setTextSizePercentOfScreenHeight(view: TextView, percentOfHeight: Float) { }
val dm = (view.context ?: layout.context).resources.displayMetrics
val sp = (dm.heightPixels * percentOfHeight) / dm.scaledDensity
TextViewCompat.setAutoSizeTextTypeWithDefaults(view, TextViewCompat.AUTO_SIZE_TEXT_TYPE_NONE)
view.setTextSize(TypedValue.COMPLEX_UNIT_SP, sp)
}
}

View File

@ -1,14 +1,35 @@
package com.dano.test1 package com.dano.test1.questionnaire.handlers
import android.util.TypedValue
import android.view.View import android.view.View
import android.widget.* import android.widget.*
import android.text.Html import android.text.Html
import kotlinx.coroutines.*
import android.util.TypedValue
import android.widget.TextView
import androidx.core.widget.TextViewCompat 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.questionnaire.QuestionHandler
import com.dano.test1.questionnaire.QuestionItem
import com.dano.test1.R
import com.dano.test1.utils.ViewUtils
import com.google.android.material.button.MaterialButton 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( class HandlerLastPage(
private val answers: Map<String, Any>, private val answers: Map<String, Any>,
private val languageID: String, private val languageID: String,
@ -19,7 +40,7 @@ class HandlerLastPage(
private lateinit var currentQuestion: QuestionItem.LastPage private lateinit var currentQuestion: QuestionItem.LastPage
private lateinit var layout: View private lateinit var layout: View
private val minLoadingTimeMs = 2000L // Minimum loading time in milliseconds (2 seconds) private val minLoadingTimeMs = 2000L
override fun bind(layout: View, question: QuestionItem) { override fun bind(layout: View, question: QuestionItem) {
this.layout = layout this.layout = layout
@ -43,8 +64,8 @@ class HandlerLastPage(
applyResponsiveTextSizing(finishBtn) applyResponsiveTextSizing(finishBtn)
// Überschriften responsiv skalieren (wie zuvor) // Überschriften responsiv skalieren (wie zuvor)
setTextSizePercentOfScreenHeight(titleTv, 0.03f) ViewUtils.setTextSizePercentOfScreenHeight(titleTv, 0.03f)
setTextSizePercentOfScreenHeight(questionTv, 0.03f) ViewUtils.setTextSizePercentOfScreenHeight(questionTv, 0.03f)
// Buttons // Buttons
prevBtn.setOnClickListener { goToPreviousQuestion() } prevBtn.setOnClickListener { goToPreviousQuestion() }
@ -61,27 +82,31 @@ class HandlerLastPage(
// Punkte summieren // Punkte summieren
GlobalValues.INTEGRATION_INDEX = sumPoints() GlobalValues.INTEGRATION_INDEX = sumPoints()
// Client-Code merken // Client-Code merken (für Auto-Laden im Opening Screen)
val clientCode = answers["client_code"] as? String val clientCode = answers["client_code"] as? String
if (clientCode != null) GlobalValues.LAST_CLIENT_CODE = clientCode if (clientCode != null) {
GlobalValues.LAST_CLIENT_CODE = clientCode
GlobalValues.LOADED_CLIENT_CODE = clientCode // <— zusätzlich setzen
}
// min. Ladezeit einhalten // min. Ladezeit einhalten (ruhiges UX)
val elapsedTime = System.currentTimeMillis() - startTime val elapsedTime = System.currentTimeMillis() - startTime
if (elapsedTime < minLoadingTimeMs) delay(minLoadingTimeMs - elapsedTime) if (elapsedTime < minLoadingTimeMs) delay(minLoadingTimeMs - elapsedTime)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
showLoading(false) showLoading(false)
val activity = layout.context as? MainActivity val activity = layout.context as? MainActivity
// Zurück zum Opening Screen der lädt dann automatisch (siehe Änderung 2)
activity?.finishQuestionnaire() ?: goToNextQuestion() activity?.finishQuestionnaire() ?: goToNextQuestion()
} }
} }
} }
} }
override fun validate(): Boolean = true override fun validate(): Boolean = true
override fun saveAnswer() {} override fun saveAnswer() {}
// ---------- Responsive Textgröße für den Finish-Button ----------
private fun applyResponsiveTextSizing(btn: MaterialButton) { private fun applyResponsiveTextSizing(btn: MaterialButton) {
// Max-/Min-Sp anhand der Bildschirmhöhe (in sp) berechnen // Max-/Min-Sp anhand der Bildschirmhöhe (in sp) berechnen
val dm = btn.resources.displayMetrics val dm = btn.resources.displayMetrics
@ -109,14 +134,6 @@ class HandlerLastPage(
} }
// ---------------------------------------------------------------- // ----------------------------------------------------------------
// Helper: Textgröße prozentual zur Bildschirmhöhe setzen (in sp)
private fun setTextSizePercentOfScreenHeight(view: TextView, percentOfHeight: Float) {
val dm = (view.context ?: layout.context).resources.displayMetrics
val sp = (dm.heightPixels * percentOfHeight) / dm.scaledDensity
TextViewCompat.setAutoSizeTextTypeWithDefaults(view, TextViewCompat.AUTO_SIZE_TEXT_TYPE_NONE)
view.setTextSize(TypedValue.COMPLEX_UNIT_SP, sp)
}
private fun sumPoints(): Int = private fun sumPoints(): Int =
answers.filterKeys { it.endsWith("_points") } answers.filterKeys { it.endsWith("_points") }
.values.mapNotNull { it as? Int } .values.mapNotNull { it as? Int }

View File

@ -1,11 +1,23 @@
package com.dano.test1 package com.dano.test1.questionnaire.handlers
import android.content.Context import android.content.Context
import android.util.TypedValue
import android.view.View import android.view.View
import android.widget.* import android.widget.*
import kotlinx.coroutines.*
import android.util.TypedValue
import androidx.core.widget.TextViewCompat import androidx.core.widget.TextViewCompat
import kotlinx.coroutines.*
import com.dano.test1.questionnaire.GlobalValues
import com.dano.test1.LanguageManager
import com.dano.test1.MyApp
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( class HandlerMultiCheckboxQuestion(
private val context: Context, private val context: Context,
@ -15,7 +27,7 @@ class HandlerMultiCheckboxQuestion(
private val goToNextQuestion: () -> Unit, private val goToNextQuestion: () -> Unit,
private val goToPreviousQuestion: () -> Unit, private val goToPreviousQuestion: () -> Unit,
private val showToast: (String) -> Unit, private val showToast: (String) -> Unit,
private val questionnaireMeta: String // neu: für DB-ID wie bei den anderen Handlern private val questionnaireMeta: String //
) : QuestionHandler { ) : QuestionHandler {
private lateinit var layout: View private lateinit var layout: View
@ -29,14 +41,12 @@ class HandlerMultiCheckboxQuestion(
val questionTitle = layout.findViewById<TextView>(R.id.question) val questionTitle = layout.findViewById<TextView>(R.id.question)
val questionTextView = layout.findViewById<TextView>(R.id.textView) val questionTextView = layout.findViewById<TextView>(R.id.textView)
// Texte setzen
questionTextView.text = this.question.textKey?.let { LanguageManager.getText(languageID, it) } ?: "" questionTextView.text = this.question.textKey?.let { LanguageManager.getText(languageID, it) } ?: ""
questionTitle.text = this.question.question?.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) ===== // Textgrößen pro Bildschirmhöhe (wie bei deinen anderen Handlern)
setTextSizePercentOfScreenHeight(questionTextView, 0.03f) // Überschrift ViewUtils.setTextSizePercentOfScreenHeight(questionTextView, 0.03f)
setTextSizePercentOfScreenHeight(questionTitle, 0.03f) // Frage ViewUtils.setTextSizePercentOfScreenHeight(questionTitle, 0.03f)
// ==========================================================================
container.removeAllViews() container.removeAllViews()
@ -45,13 +55,12 @@ class HandlerMultiCheckboxQuestion(
(answers[it] as? List<*>)?.map { it.toString() }?.toSet() (answers[it] as? List<*>)?.map { it.toString() }?.toSet()
} ?: emptySet() } ?: emptySet()
// ——— Checkbox-Schrift & Zeilenhöhe dynamisch ableiten (kein Abschneiden) ——— // Checkbox-Schrift & Zeilenhöhe dynamisch ableiten (kein Abschneiden)
val dm = layout.resources.displayMetrics val dm = layout.resources.displayMetrics
val cbTextSp = (dm.heightPixels * 0.025f) / dm.scaledDensity // ~2.5% der Bildschirmhöhe val cbTextSp = (dm.heightPixels * 0.025f) / dm.scaledDensity // ~2.5% der Bildschirmhöhe
val cbTextPx = cbTextSp * dm.scaledDensity val cbTextPx = cbTextSp * dm.scaledDensity
val cbPadV = (cbTextPx * 0.40f).toInt() val cbPadV = (cbTextPx * 0.40f).toInt()
val cbMinH = (cbTextPx * 1.60f + 2 * cbPadV).toInt() val cbMinH = (cbTextPx * 1.60f + 2 * cbPadV).toInt()
// ----------------------------------------------------------------------------
this.question.options.forEach { option -> this.question.options.forEach { option ->
val checkBox = CheckBox(context).apply { val checkBox = CheckBox(context).apply {
@ -78,7 +87,7 @@ class HandlerMultiCheckboxQuestion(
container.addView(checkBox) container.addView(checkBox)
} }
// --- DB-Abfrage falls noch kein Eintrag im answers-Map existiert --- //DB-Abfrage falls noch kein Eintrag im answers-Map existiert
val answerMapKey = question.question ?: (question.id ?: "") val answerMapKey = question.question ?: (question.id ?: "")
if (answerMapKey.isNotBlank() && !answers.containsKey(answerMapKey)) { if (answerMapKey.isNotBlank() && !answers.containsKey(answerMapKey)) {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
@ -100,7 +109,7 @@ class HandlerMultiCheckboxQuestion(
cb.isChecked = parsed.contains(cb.tag.toString()) cb.isChecked = parsed.contains(cb.tag.toString())
} }
// answers-Map aktualisieren (Liste) // answers-Map aktualisieren
answers[answerMapKey] = parsed.toList() answers[answerMapKey] = parsed.toList()
// Punkte berechnen und hinzufügen // Punkte berechnen und hinzufügen
@ -175,16 +184,8 @@ class HandlerMultiCheckboxQuestion(
} }
} }
/**
* Parsen der DB-Antwort in ein Set von Keys. Unterstützt:
* - JSON-Array: ["a","b"]
* - kommasepariert: a,b
* - semikolon-separiert: a;b
* - einzelner Wert: a
*/
private fun parseMultiAnswer(dbAnswer: String): Set<String> { private fun parseMultiAnswer(dbAnswer: String): Set<String> {
val trimmed = dbAnswer.trim() val trimmed = dbAnswer.trim()
// JSON-Array-like
if (trimmed.startsWith("[") && trimmed.endsWith("]")) { if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
val inner = trimmed.substring(1, trimmed.length - 1) val inner = trimmed.substring(1, trimmed.length - 1)
if (inner.isBlank()) return emptySet() if (inner.isBlank()) return emptySet()
@ -194,7 +195,6 @@ class HandlerMultiCheckboxQuestion(
.toSet() .toSet()
} }
// If contains comma or semicolon
val separator = when { val separator = when {
trimmed.contains(",") -> "," trimmed.contains(",") -> ","
trimmed.contains(";") -> ";" trimmed.contains(";") -> ";"
@ -211,11 +211,4 @@ class HandlerMultiCheckboxQuestion(
} }
} }
// Helper: Textgröße prozentual zur Bildschirmhöhe setzen
private fun setTextSizePercentOfScreenHeight(view: TextView, percentOfHeight: Float) {
val dm = (view.context ?: layout.context).resources.displayMetrics
val sp = (dm.heightPixels * percentOfHeight) / dm.scaledDensity
TextViewCompat.setAutoSizeTextTypeWithDefaults(view, TextViewCompat.AUTO_SIZE_TEXT_TYPE_NONE)
view.setTextSize(TypedValue.COMPLEX_UNIT_SP, sp)
}
} }

View File

@ -1,12 +1,22 @@
package com.dano.test1 package com.dano.test1.questionnaire.handlers
import android.content.Context import android.content.Context
import android.view.View import android.view.View
import android.text.Html import android.text.Html
import android.widget.* import android.widget.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import android.util.TypedValue import com.dano.test1.questionnaire.GlobalValues
import androidx.core.widget.TextViewCompat // <— hinzugefügt import com.dano.test1.LanguageManager
import com.dano.test1.MyApp
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( class HandlerRadioQuestion(
private val context: Context, private val context: Context,
@ -36,11 +46,8 @@ class HandlerRadioQuestion(
Html.fromHtml(LanguageManager.getText(languageID, it), Html.FROM_HTML_MODE_LEGACY) Html.fromHtml(LanguageManager.getText(languageID, it), Html.FROM_HTML_MODE_LEGACY)
} ?: "" } ?: ""
// === Schriftgrößen wie im HandlerClientCoachCode === ViewUtils.setTextSizePercentOfScreenHeight(questionTextView, 0.03f)
// Titel/Frage: 3% der Bildschirmhöhe ViewUtils.setTextSizePercentOfScreenHeight(questionTitle, 0.03f)
setTextSizePercentOfScreenHeight(questionTextView, 0.03f)
setTextSizePercentOfScreenHeight(questionTitle, 0.03f)
// ===================================================
radioGroup.removeAllViews() radioGroup.removeAllViews()
@ -50,7 +57,7 @@ class HandlerRadioQuestion(
tag = option.key tag = option.key
// RadioButton-Text analog zu EditTexts: 2.5% der Bildschirmhöhe // RadioButton-Text analog zu EditTexts: 2.5% der Bildschirmhöhe
setTextSizePercentOfScreenHeight(this, 0.025f) ViewUtils.setTextSizePercentOfScreenHeight(this, 0.025f)
layoutParams = RadioGroup.LayoutParams( layoutParams = RadioGroup.LayoutParams(
RadioGroup.LayoutParams.MATCH_PARENT, RadioGroup.LayoutParams.MATCH_PARENT,
@ -132,15 +139,6 @@ class HandlerRadioQuestion(
} }
} }
// ——— Helper: setzt Textgröße prozentual zur Bildschirmhöhe (in sp) ———
private fun setTextSizePercentOfScreenHeight(view: TextView, percentOfHeight: Float) {
val dm = (view.context ?: layout.context).resources.displayMetrics
val sp = (dm.heightPixels * percentOfHeight) / dm.scaledDensity
TextViewCompat.setAutoSizeTextTypeWithDefaults(view, TextViewCompat.AUTO_SIZE_TEXT_TYPE_NONE)
view.setTextSize(TypedValue.COMPLEX_UNIT_SP, sp)
}
// ————————————————————————————————————————————————————————————————
private fun restorePreviousAnswer(radioGroup: RadioGroup) { private fun restorePreviousAnswer(radioGroup: RadioGroup) {
question.question?.let { questionKey -> question.question?.let { questionKey ->
val savedAnswer = answers[questionKey] as? String val savedAnswer = answers[questionKey] as? String

View File

@ -0,0 +1,126 @@
package com.dano.test1.questionnaire.handlers
import android.content.Context
import android.view.View
import android.widget.*
import kotlinx.coroutines.*
import com.dano.test1.ui.Countries
import com.dano.test1.questionnaire.GlobalValues
import com.dano.test1.LanguageManager
import com.dano.test1.MyApp
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 einer einzelnen Auswahl aus einer Dropdown-Liste (Spinner).
- Baut die Optionen dynamisch auf, lokalisiert Texte, stellt responsive Typografie her und kann vorhandene Antworten aus der lokalen Room-DB restaurieren.
*/
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 showToast: (String) -> Unit,
private val questionnaireMeta: String
) : QuestionHandler {
private lateinit var layout: View
private lateinit var question: QuestionItem.StringSpinnerQuestion
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)
// Texte setzen
questionTextView.text = question.question?.let { LanguageManager.getText(languageID, it) } ?: ""
textView.text = question.textKey?.let { LanguageManager.getText(languageID, it) } ?: ""
// Textgrößen prozentual zur Bildschirmhöhe
ViewUtils.setTextSizePercentOfScreenHeight(textView, 0.03f)
ViewUtils.setTextSizePercentOfScreenHeight(questionTextView, 0.03f)
val options = buildOptionsList()
// vorhandene Auswahl (falls vorhanden)
val savedSelection = question.question?.let { answers[it] as? String }
// Spinner aufsetzen
ViewUtils.setupResponsiveSpinner(context, spinner, options, savedSelection)
// Falls noch keine Antwort im Map: aus DB laden
val answerMapKey = question.question ?: (question.id ?: "")
if (answerMapKey.isNotBlank() && !answers.containsKey(answerMapKey)) {
CoroutineScope(Dispatchers.IO).launch {
try {
val clientCode = GlobalValues.LAST_CLIENT_CODE
if (clientCode.isNullOrBlank()) return@launch
val allAnswersForClient = MyApp.database.answerDao().getAnswersForClient(clientCode)
val myQuestionId = questionnaireMeta + "-" + question.question
val dbAnswer = allAnswersForClient.find { it.questionId == myQuestionId }?.answerValue
if (!dbAnswer.isNullOrBlank()) {
withContext(Dispatchers.Main) {
answers[answerMapKey] = dbAnswer
val index = options.indexOf(dbAnswer)
if (index >= 0) spinner.setSelection(index)
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
layout.findViewById<Button>(R.id.Qnext).setOnClickListener {
if (validate()) {
saveAnswer()
goToNextQuestion()
}
}
layout.findViewById<Button>(R.id.Qprev).setOnClickListener {
goToPreviousQuestion()
}
}
override fun validate(): Boolean {
val spinner = layout.findViewById<Spinner>(R.id.string_spinner)
val selected = spinner.selectedItem as? String
val prompt = LanguageManager.getText(languageID, "choose_answer")
return if (selected.isNullOrEmpty() || selected == prompt) {
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 ?: return
question.question?.let { key -> answers[key] = selected }
}
private fun buildOptionsList(): List<String> {
return if (question.id == "q11") {
Countries.getAllCountries(languageID)
} else {
val prompt = LanguageManager.getText(languageID, "choose_answer")
listOf(prompt) + question.options
}
}
}

View File

@ -1,12 +1,24 @@
package com.dano.test1 package com.dano.test1.questionnaire.handlers
import android.content.Context import android.content.Context
import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.* import android.widget.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import android.util.TypedValue import com.dano.test1.questionnaire.GlobalValues
import androidx.core.widget.TextViewCompat // <- NEU import com.dano.test1.LanguageManager
import com.dano.test1.MyApp
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, bei der ein numerischer Wert aus einem Spinner gewählt wird.
- Unterstützt sowohl feste Optionslisten als auch numerische Bereiche (min..max).
- Lokalisiert Texte, kann eine frühere Antwort aus der lokalen Room-DB (per AnswerDao) wiederherstellen.
*/
class HandlerValueSpinner( class HandlerValueSpinner(
private val context: Context, private val context: Context,
@ -16,7 +28,7 @@ class HandlerValueSpinner(
private val goToPreviousQuestion: () -> Unit, private val goToPreviousQuestion: () -> Unit,
private val goToQuestionById: (String) -> Unit, private val goToQuestionById: (String) -> Unit,
private val showToast: (String) -> Unit, private val showToast: (String) -> Unit,
private val questionnaireMeta: String // neu: für die DB-Abfrage private val questionnaireMeta: String
) : QuestionHandler { ) : QuestionHandler {
private lateinit var layout: View private lateinit var layout: View
@ -35,11 +47,8 @@ class HandlerValueSpinner(
questionTextView.text = question.question?.let { LanguageManager.getText(languageID, it) } ?: "" questionTextView.text = question.question?.let { LanguageManager.getText(languageID, it) } ?: ""
textView.text = question.textKey?.let { LanguageManager.getText(languageID, it) } ?: "" textView.text = question.textKey?.let { LanguageManager.getText(languageID, it) } ?: ""
// === Schriftgrößen wie im HandlerRadioQuestion === ViewUtils.setTextSizePercentOfScreenHeight(textView, 0.03f)
// Titel/Frage: 3% der Bildschirmhöhe ViewUtils.setTextSizePercentOfScreenHeight(questionTextView, 0.03f)
setTextSizePercentOfScreenHeight(textView, 0.03f)
setTextSizePercentOfScreenHeight(questionTextView, 0.03f)
// =================================================
val prompt = LanguageManager.getText(languageID, "choose_answer") val prompt = LanguageManager.getText(languageID, "choose_answer")
val spinnerItems: List<String> = listOf(prompt) + if (question.range != null) { val spinnerItems: List<String> = listOf(prompt) + if (question.range != null) {
@ -49,9 +58,9 @@ class HandlerValueSpinner(
} }
val savedValue = question.question?.let { answers[it] as? String } val savedValue = question.question?.let { answers[it] as? String }
setupSpinner(spinner, spinnerItems, savedValue) ViewUtils.setupResponsiveSpinner(context, spinner, spinnerItems, savedValue)
// --- DB-Abfrage falls noch keine Antwort im Map existiert --- //DB-Abfrage falls noch keine Antwort im Map existiert
val answerMapKey = question.question ?: (question.id ?: "") val answerMapKey = question.question ?: (question.id ?: "")
if (answerMapKey.isNotBlank() && !answers.containsKey(answerMapKey)) { if (answerMapKey.isNotBlank() && !answers.containsKey(answerMapKey)) {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
@ -120,73 +129,4 @@ class HandlerValueSpinner(
} }
} }
// ——— Helper: setzt Textgröße prozentual zur Bildschirmhöhe (in sp) ———
private fun setTextSizePercentOfScreenHeight(view: TextView, percentOfHeight: Float) {
val dm = (view.context ?: layout.context).resources.displayMetrics
val sp = (dm.heightPixels * percentOfHeight) / dm.scaledDensity
TextViewCompat.setAutoSizeTextTypeWithDefaults(view, TextViewCompat.AUTO_SIZE_TEXT_TYPE_NONE)
view.setTextSize(TypedValue.COMPLEX_UNIT_SP, sp)
}
// ————————————————————————————————————————————————————————————————
private fun <T> setupSpinner(spinner: Spinner, items: List<T>, selectedItem: T?) {
val dm = context.resources.displayMetrics
fun spFromScreenHeight(percent: Float): Float =
(dm.heightPixels * percent) / dm.scaledDensity
fun pxFromSp(sp: Float): Int = (sp * dm.scaledDensity).toInt()
// Schrift & abgeleitete Höhen
val textSp = spFromScreenHeight(0.0275f) // ~2.75% der Bildschirmhöhe
val textPx = pxFromSp(textSp)
val vPadPx = (textPx * 0.50f).toInt() // vertikales Padding
val rowHeight = (textPx * 2.20f + 2 * vPadPx).toInt() // feste Zeilenhöhe
val adapter = object : ArrayAdapter<T>(context, android.R.layout.simple_spinner_item, items) {
private fun styleRow(tv: TextView, forceHeight: Boolean) {
tv.setTextSize(TypedValue.COMPLEX_UNIT_SP, textSp)
tv.includeFontPadding = true
tv.setLineSpacing(0f, 1.2f)
tv.gravity = (tv.gravity and android.view.Gravity.HORIZONTAL_GRAVITY_MASK) or android.view.Gravity.CENTER_VERTICAL
tv.setPadding(tv.paddingLeft, vPadPx, tv.paddingRight, vPadPx)
tv.minHeight = rowHeight
tv.isSingleLine = true
if (forceHeight) {
val lp = tv.layoutParams
if (lp == null || lp.height <= 0) {
tv.layoutParams = AbsListView.LayoutParams(
AbsListView.LayoutParams.MATCH_PARENT, rowHeight
)
} else {
lp.height = rowHeight
}
}
}
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val v = super.getView(position, convertView, parent) as TextView
styleRow(v, forceHeight = false) // ausgewählte Ansicht
return v
}
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
val v = super.getDropDownView(position, convertView, parent) as TextView
styleRow(v, forceHeight = true) // Dropdown-Zeilen: Höhe erzwingen
return v
}
}
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
spinner.adapter = adapter
// Spinner selbst ausreichend hoch machen
spinner.setPadding(spinner.paddingLeft, vPadPx, spinner.paddingRight, vPadPx)
spinner.minimumHeight = rowHeight
spinner.requestLayout()
selectedItem?.let {
val index = items.indexOf(it)
if (index >= 0) spinner.setSelection(index)
}
}
} }

View File

@ -0,0 +1,46 @@
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 override is on and a branch is selected; otherwise 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

@ -1,4 +1,6 @@
package com.dano.test1 package com.dano.test1.ui
import com.dano.test1.LanguageManager
object Countries { object Countries {
fun getAllCountries(languageID: String): List<String> { fun getAllCountries(languageID: String): List<String> {

View File

@ -1,17 +1,25 @@
package com.dano.test1 package com.dano.test1.ui
import android.graphics.Color
import android.graphics.Typeface
import android.util.Log import android.util.Log
import android.view.View import android.view.View
import android.widget.* import android.widget.*
import com.dano.test1.data.ExcelExportService
import com.dano.test1.utils.ViewUtils
import androidx.appcompat.app.AppCompatActivity
import com.dano.test1.LanguageManager
import com.dano.test1.MyApp
import com.dano.test1.R
import com.dano.test1.data.Client import com.dano.test1.data.Client
import com.dano.test1.data.HeaderOrderRepository
import com.dano.test1.data.Question import com.dano.test1.data.Question
import com.dano.test1.data.Questionnaire import com.dano.test1.data.Questionnaire
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlin.math.roundToInt
import org.json.JSONArray import org.json.JSONArray
class DatabaseButtonHandler( class DatabaseButtonHandler(
private val activity: MainActivity, private val activity: AppCompatActivity,
private val databaseButton: Button, private val databaseButton: Button,
private val onClose: () -> Unit, private val onClose: () -> Unit,
private val languageIDProvider: () -> String = { "GERMAN" } private val languageIDProvider: () -> String = { "GERMAN" }
@ -361,7 +369,7 @@ class DatabaseButtonHandler(
val row = TableRow(activity).apply { val row = TableRow(activity).apply {
isClickable = true isClickable = true
isFocusable = true isFocusable = true
setBackgroundColor(android.graphics.Color.TRANSPARENT) setBackgroundColor(Color.TRANSPARENT)
setOnClickListener { onClick() } setOnClickListener { onClick() }
} }
cells.forEachIndexed { index, text -> cells.forEachIndexed { index, text ->
@ -396,7 +404,7 @@ class DatabaseButtonHandler(
this.text = text this.text = text
setPadding(dp(12), dp(10), dp(12), dp(10)) setPadding(dp(12), dp(10), dp(12), dp(10))
textSize = 16f textSize = 16f
setTypeface(typeface, android.graphics.Typeface.BOLD) setTypeface(typeface, Typeface.BOLD)
} }
private fun makeBodyCell( private fun makeBodyCell(
@ -412,10 +420,7 @@ class DatabaseButtonHandler(
bgColor?.let { setBackgroundColor(it) } bgColor?.let { setBackgroundColor(it) }
} }
private fun dp(value: Int): Int { private fun dp(value: Int): Int = ViewUtils.dp(activity, value)
val density = activity.resources.displayMetrics.density
return (value * density).roundToInt()
}
private fun <T : View> requireView(id: Int, name: String): T { private fun <T : View> requireView(id: Int, name: String): T {
val v = activity.findViewById<T>(id) val v = activity.findViewById<T>(id)

View File

@ -0,0 +1,92 @@
package com.dano.test1.ui
import android.os.Bundle
import android.view.View
import android.widget.Button
import android.widget.RadioButton
import android.widget.RadioGroup
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SwitchCompat
import androidx.appcompat.widget.Toolbar
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.dano.test1.R
/**
* Dev-only A/B settings. Entry: client code [_dev_settings_] on the opening screen Load action.
* Not a security boundary (secret string is in the APK).
*/
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)
findViewById<View>(android.R.id.content)?.let { content ->
ViewCompat.setOnApplyWindowInsetsListener(content) { v, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(bars.left, bars.top, bars.right, bars.bottom)
WindowInsetsCompat.CONSUMED
}
}
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 databaseButton = findViewById<Button>(R.id.databaseButton)
DatabaseButtonHandler(
activity = this,
databaseButton = databaseButton,
onClose = { recreate() }
).setup()
}
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
}
}

View File

@ -1,18 +1,35 @@
package com.dano.test1 package com.dano.test1.ui
import android.content.Context
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.TypedValue import android.util.TypedValue
import android.view.Gravity import android.view.Gravity
import android.view.View import android.view.View
import android.widget.* import android.widget.*
import com.dano.test1.LanguageManager
import com.dano.test1.MainActivity
import com.dano.test1.R
import com.dano.test1.network.DatabaseUploader
import com.dano.test1.network.LoginManager
import com.dano.test1.network.NetworkUtils
import com.dano.test1.network.TokenStore
import com.dano.test1.questionnaire.GlobalValues
import com.dano.test1.questionnaire.QuestionItem
import com.dano.test1.questionnaire.QuestionnaireBase
import com.dano.test1.questionnaire.QuestionnaireAllInOne
import com.dano.test1.questionnaire.QuestionnaireGeneric
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.io.File import java.io.File
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.dano.test1.utils.ViewUtils
var RHS_POINTS: Int? = null var RHS_POINTS: Int? = null
@ -26,12 +43,12 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
private lateinit var buttonContainer: LinearLayout private lateinit var buttonContainer: LinearLayout
private lateinit var buttonLoad: Button private lateinit var buttonLoad: Button
private lateinit var saveButton: Button private lateinit var saveButton: Button
private lateinit var editButton: Button
private lateinit var uploadButton: Button private lateinit var uploadButton: Button
private lateinit var downloadButton: Button private lateinit var downloadButton: Button
private lateinit var databaseButton: Button
private lateinit var statusSession: TextView private lateinit var statusSession: TextView
private lateinit var statusOnline: TextView private lateinit var statusOnline: TextView
private val SESSION_WARN_AFTER_MS = 12 * 60 * 60 * 1000L // 12h
private var sessionLongWarnedOnce = false
private val dynamicButtons = mutableListOf<Button>() private val dynamicButtons = mutableListOf<Button>()
private val questionnaireFiles = mutableMapOf<Button, String>() private val questionnaireFiles = mutableMapOf<Button, String>()
@ -63,6 +80,11 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
fun init() { fun init() {
activity.setContentView(R.layout.opening_screen) activity.setContentView(R.layout.opening_screen)
// <<< NEU: bei jedem Öffnen des Screens zurücksetzen,
// damit der Toast pro Besuch einmal angezeigt wird
sessionLongWarnedOnce = false
bindViews() bindViews()
loadQuestionnaireOrder() loadQuestionnaireOrder()
createQuestionnaireButtons() createQuestionnaireButtons()
@ -71,10 +93,8 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
setupLanguageSpinner() setupLanguageSpinner()
setupLoadButton() setupLoadButton()
setupSaveButton() setupSaveButton()
setupEditButtonHandler()
setupUploadButton() setupUploadButton()
setupDownloadButton() setupDownloadButton()
setupDatabaseButtonHandler()
uiHandler.removeCallbacks(statusTicker) uiHandler.removeCallbacks(statusTicker)
updateStatusStrip() updateStatusStrip()
@ -82,9 +102,14 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
val pathExists = File("/data/data/com.dano.test1/databases/questionnaire_database").exists() val pathExists = File("/data/data/com.dano.test1/databases/questionnaire_database").exists()
updateMainButtonsState(pathExists) updateMainButtonsState(pathExists)
updateDownloadButtonState(pathExists) // <<< NEU: Download-Button anhand DB-Status setzen updateDownloadButtonState(pathExists)
if (pathExists && !editText.text.isNullOrBlank()) buttonLoad.performClick() if (pathExists && !editText.text.isNullOrBlank()) buttonLoad.performClick()
uiHandler.removeCallbacks(statusTicker)
updateStatusStrip()
applySessionAgeHighlight(System.currentTimeMillis() - TokenStore.getLoginTimestamp(activity))
uiHandler.post(statusTicker)
} }
private fun bindViews() { private fun bindViews() {
@ -95,10 +120,11 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
buttonContainer = activity.findViewById(R.id.buttonContainer) buttonContainer = activity.findViewById(R.id.buttonContainer)
buttonLoad = activity.findViewById(R.id.loadButton) buttonLoad = activity.findViewById(R.id.loadButton)
saveButton = activity.findViewById(R.id.saveButton) saveButton = activity.findViewById(R.id.saveButton)
editButton = activity.findViewById(R.id.editButton)
uploadButton = activity.findViewById(R.id.uploadButton) uploadButton = activity.findViewById(R.id.uploadButton)
downloadButton = activity.findViewById(R.id.downloadButton) downloadButton = activity.findViewById(R.id.downloadButton)
databaseButton = activity.findViewById(R.id.databaseButton) downloadButton.visibility = View.GONE
statusSession = activity.findViewById(R.id.statusSession) statusSession = activity.findViewById(R.id.statusSession)
statusOnline = activity.findViewById(R.id.statusOnline) statusOnline = activity.findViewById(R.id.statusOnline)
val tag = editText.tag as? String ?: "" val tag = editText.tag as? String ?: ""
@ -200,7 +226,12 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
setOnClickListener { setOnClickListener {
GlobalValues.LAST_CLIENT_CODE = GlobalValues.LOADED_CLIENT_CODE GlobalValues.LAST_CLIENT_CODE = GlobalValues.LOADED_CLIENT_CODE
val fileName = questionnaireFiles[this] ?: return@setOnClickListener val fileName = questionnaireFiles[this] ?: return@setOnClickListener
val questionnaire = QuestionnaireGeneric(fileName) val variant = AbTestSettingsStore.effectiveVariant(activity)
val questionnaire: QuestionnaireBase<*> = if (variant == "B") {
QuestionnaireAllInOne(fileName)
} else {
QuestionnaireGeneric(fileName)
}
startQuestionnaire(questionnaire) startQuestionnaire(questionnaire)
applySetButtonsEnabled(dynamicButtons.filter { it == this }, allowCompleted = false, force = false) applySetButtonsEnabled(dynamicButtons.filter { it == this }, allowCompleted = false, force = false)
} }
@ -255,18 +286,21 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
} }
private fun restorePreviousClientCode() { private fun restorePreviousClientCode() {
// Coach-Code (Username) setzen und Feld sperren aber NICHT mehr zurückkehren
val username = TokenStore.getUsername(activity) val username = TokenStore.getUsername(activity)
if (!username.isNullOrBlank()) { if (!username.isNullOrBlank()) {
coachEditText.setText(username) coachEditText.setText(username)
lockCoachCodeField() lockCoachCodeField()
return
} }
// Hier den zuletzt verwendeten Client-Code einsetzen
GlobalValues.LAST_CLIENT_CODE?.let { GlobalValues.LAST_CLIENT_CODE?.let {
editText.setText(it) editText.setText(it)
GlobalValues.LOADED_CLIENT_CODE = it GlobalValues.LOADED_CLIENT_CODE = it
} }
} }
private fun setupLanguageSpinner() { private fun setupLanguageSpinner() {
val languages = listOf("GERMAN", "ENGLISH", "FRENCH", "ROMANIAN", "ARABIC", "POLISH", "TURKISH", "UKRAINIAN", "RUSSIAN", "SPANISH") val languages = listOf("GERMAN", "ENGLISH", "FRENCH", "ROMANIAN", "ARABIC", "POLISH", "TURKISH", "UKRAINIAN", "RUSSIAN", "SPANISH")
val adapter = ArrayAdapter(activity, android.R.layout.simple_spinner_item, languages).apply { val adapter = ArrayAdapter(activity, android.R.layout.simple_spinner_item, languages).apply {
@ -297,7 +331,7 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
dynamicButtonsProvider = { dynamicButtons }, dynamicButtonsProvider = { dynamicButtons },
buttonPoints = buttonPoints, buttonPoints = buttonPoints,
updateButtonTexts = { applyUpdateButtonTexts(force = false) }, updateButtonTexts = { applyUpdateButtonTexts(force = false) },
setButtonsEnabled = { list -> applySetButtonsEnabled(list, allowCompleted = false, force = false) }, setButtonsEnabled = { list -> applySetButtonsEnabled(list, allowCompleted = true, force = false) },
updateMainButtonsState = { updateMainButtonsState(it) }, updateMainButtonsState = { updateMainButtonsState(it) },
).setup() ).setup()
} }
@ -353,10 +387,8 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
} }
buttonLoad.text = t("load") buttonLoad.text = t("load")
saveButton.text = t("save") saveButton.text = t("save")
editButton.text = t("edit")
uploadButton.text = t("upload") uploadButton.text = t("upload")
downloadButton.text = t("download") downloadButton.text = t("download")
databaseButton.text = t("database")
val hintTag = editText.tag as? String ?: "" val hintTag = editText.tag as? String ?: ""
editText.hint = t(hintTag) editText.hint = t(hintTag)
val coachTag = coachEditText.tag as? String ?: "" val coachTag = coachEditText.tag as? String ?: ""
@ -421,33 +453,50 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
).setup() ).setup()
} }
private fun setupEditButtonHandler() {
EditButtonHandler(
activity = activity,
editButton = editButton,
editText = editText,
languageIDProvider = { languageID },
questionnaireFiles = questionnaireFiles,
buttonPoints = buttonPoints,
updateButtonTexts = { applyUpdateButtonTexts(force = true) },
setButtonsEnabled = { list, allowCompleted ->
applySetButtonsEnabled(list, allowCompleted, force = true)
},
setUiFreeze = { freeze -> uiFreeze = freeze },
triggerLoad = { buttonLoad.performClick() }
).setup()
}
private fun setupUploadButton() { private fun setupUploadButton() {
uploadButton.text = t("upload") uploadButton.text = t("upload")
uploadButton.setOnClickListener { uploadButton.setOnClickListener {
val token = TokenStore.getToken(activity) // ZUERST bestätigen lassen
if (token.isNullOrBlank()) { confirmUpload {
Toast.makeText(activity, t("login_required"), Toast.LENGTH_LONG).show() // === dein bestehender Upload-Code unverändert ===
return@setOnClickListener val existingToken = TokenStore.getToken(activity)
val ageMs = System.currentTimeMillis() - TokenStore.getLoginTimestamp(activity)
val isFresh = !existingToken.isNullOrBlank() && ageMs < 23 * 60 * 60 * 1000
if (isFresh) {
GlobalValues.LAST_CLIENT_CODE = editText.text.toString().trim()
DatabaseUploader.uploadDatabaseWithToken(activity, existingToken!!)
return@confirmUpload
}
val username = TokenStore.getUsername(activity)?.trim().orEmpty()
if (username.isBlank()) {
Toast.makeText(activity, t("login_required"), Toast.LENGTH_LONG).show()
return@confirmUpload
}
val password = when (username) {
"user01" -> "pw1"
"user02" -> "pw2"
else -> {
Toast.makeText(activity, t("login_required"), Toast.LENGTH_LONG).show()
return@confirmUpload
}
}
LoginManager.loginUserWithCredentials(
context = activity,
username = username,
password = password,
onSuccess = { freshToken ->
GlobalValues.LAST_CLIENT_CODE = editText.text.toString().trim()
DatabaseUploader.uploadDatabaseWithToken(activity, freshToken)
},
onError = { msg ->
Toast.makeText(activity, t("login_failed_with_reason").replace("{reason}", msg), Toast.LENGTH_LONG).show()
}
)
} }
GlobalValues.LAST_CLIENT_CODE = editText.text.toString().trim()
DatabaseUploader.uploadDatabaseWithToken(activity, token)
} }
} }
@ -465,24 +514,14 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
} }
} }
private fun setupDatabaseButtonHandler() {
DatabaseButtonHandler(
activity = activity,
databaseButton = databaseButton,
onClose = { init() },
languageIDProvider = { languageID }
).setup()
}
private fun updateMainButtonsState(isDatabaseAvailable: Boolean) { private fun updateMainButtonsState(isDatabaseAvailable: Boolean) {
listOf(buttonLoad, saveButton, editButton, databaseButton).forEach { b -> listOf(buttonLoad, saveButton).forEach { b ->
b.isEnabled = isDatabaseAvailable b.isEnabled = isDatabaseAvailable
b.alpha = if (isDatabaseAvailable) 1.0f else 0.5f b.alpha = if (isDatabaseAvailable) 1.0f else 0.5f
} }
// Der Download-Button wird separat gesteuert // Der Download-Button wird separat gesteuert
} }
/** <<< NEU: Steuert Aktivierung & Look des Download-Buttons je nach DB-Verfügbarkeit */
private fun updateDownloadButtonState(isDatabaseAvailable: Boolean) { private fun updateDownloadButtonState(isDatabaseAvailable: Boolean) {
val mb = downloadButton as? MaterialButton val mb = downloadButton as? MaterialButton
if (isDatabaseAvailable) { if (isDatabaseAvailable) {
@ -506,7 +545,7 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
} }
} }
private fun dp(v: Int): Int = (v * activity.resources.displayMetrics.density).toInt() private fun dp(v: Int): Int = ViewUtils.dp(activity, v)
private fun isCompleted(button: Button): Boolean { private fun isCompleted(button: Button): Boolean {
val fileName = questionnaireFiles[button] ?: return false val fileName = questionnaireFiles[button] ?: return false
@ -579,13 +618,18 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
val h = TimeUnit.MILLISECONDS.toHours(ageMs) val h = TimeUnit.MILLISECONDS.toHours(ageMs)
val m = TimeUnit.MILLISECONDS.toMinutes(ageMs) - h * 60 val m = TimeUnit.MILLISECONDS.toMinutes(ageMs) - h * 60
if (ts > 0L) { if (ts > 0L) {
statusSession.text = "${t("session_label")}: ${h}${t("hours_short")} ${m}${t("minutes_short")}" // ⚠️ anhängen, wenn >12h, der eigentliche Hinweis/Styling kommt aus applySessionAgeHighlight()
val warn = if (ageMs >= SESSION_WARN_AFTER_MS) " ⚠️" else ""
statusSession.text = "${t("session_label")}: ${h}${t("hours_short")} ${m}${t("minutes_short")}$warn"
} else { } else {
statusSession.text = t("session_dash") statusSession.text = t("session_dash")
} }
val online = NetworkUtils.isOnline(activity) val online = NetworkUtils.isOnline(activity)
statusOnline.text = if (online) t("online") else t("offline") statusOnline.text = if (online) t("online") else t("offline")
statusOnline.setTextColor(if (online) Color.parseColor("#2E7D32") else Color.parseColor("#C62828")) statusOnline.setTextColor(if (online) Color.parseColor("#2E7D32") else Color.parseColor("#C62828"))
// <<< NEU: hier jeweils prüfen/markieren
applySessionAgeHighlight(ageMs)
} }
fun refreshHeaderStatusLive() { fun refreshHeaderStatusLive() {
@ -593,15 +637,60 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
} }
private fun lockCoachCodeField() { private fun lockCoachCodeField() {
coachEditText.isFocusable = false ViewUtils.lockEditField(coachEditText)
coachEditText.isFocusableInTouchMode = false
coachEditText.isCursorVisible = false
coachEditText.keyListener = null
coachEditText.isLongClickable = false
coachEditText.isClickable = false
coachEditText.setBackgroundResource(R.drawable.bg_field_locked)
coachEditText.setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.ic_lock_24, 0)
coachEditText.compoundDrawablePadding = dp(8)
coachEditText.alpha = 0.95f
} }
private fun applySessionAgeHighlight(ageMs: Long) {
val isOld = ageMs >= SESSION_WARN_AFTER_MS
if (isOld) {
statusSession.setTextColor(Color.parseColor("#C62828"))
statusSession.setBackgroundColor(Color.parseColor("#FFF3CD"))
statusSession.setPadding(dp(8), dp(4), dp(8), dp(4))
if (!sessionLongWarnedOnce) {
showRedToast(activity, t("session_over_12"))
sessionLongWarnedOnce = true
}
} else {
statusSession.setTextColor(Color.parseColor("#2F2A49"))
statusSession.setBackgroundColor(Color.TRANSPARENT)
statusSession.setPadding(0, 0, 0, 0)
}
}
private fun showRedToast(ctx: Context, message: String) {
val tv = TextView(ctx).apply {
text = message
setTextColor(Color.WHITE)
textSize = 16f
setPadding(32, 20, 32, 20)
background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = 24f
setColor(Color.parseColor("#D32F2F")) // kräftiges Rot
}
}
Toast(ctx).apply {
duration = Toast.LENGTH_LONG
view = tv
setGravity(Gravity.TOP or Gravity.CENTER_HORIZONTAL, 0, 120)
}.show()
}
private fun confirmUpload(onConfirm: () -> Unit) {
MaterialAlertDialogBuilder(activity)
.setTitle(t("start_upload"))
.setMessage(t("ask_before_upload"))
.setPositiveButton(t("ok")) { d, _ ->
d.dismiss()
onConfirm()
}
.setNegativeButton(t("cancel")) { d, _ ->
d.dismiss()
}
.show()
}
} }

View File

@ -1,10 +1,16 @@
package com.dano.test1 package com.dano.test1.ui
import android.content.Intent
import android.widget.Button import android.widget.Button
import android.widget.EditText import android.widget.EditText
import android.widget.Toast import android.widget.Toast
import com.dano.test1.LanguageManager
import com.dano.test1.MainActivity
import com.dano.test1.MyApp
import kotlinx.coroutines.* import kotlinx.coroutines.*
import com.dano.test1.data.CompletedQuestionnaire import com.dano.test1.data.CompletedQuestionnaire
import com.dano.test1.questionnaire.GlobalValues
import com.dano.test1.questionnaire.QuestionItem
class LoadButtonHandler( class LoadButtonHandler(
private val activity: MainActivity, private val activity: MainActivity,
@ -19,6 +25,11 @@ class LoadButtonHandler(
private val updateMainButtonsState: (Boolean) -> Unit, private val updateMainButtonsState: (Boolean) -> Unit,
) { ) {
companion object {
/** Opening-screen client code that opens dev A/B settings (not a security boundary). */
private const val DEV_SETTINGS_SECRET = "_dev_settings_"
}
fun setup() { fun setup() {
loadButton.text = LanguageManager.getText(languageIDProvider(), "load") loadButton.text = LanguageManager.getText(languageIDProvider(), "load")
loadButton.setOnClickListener { handleLoadButton() } loadButton.setOnClickListener { handleLoadButton() }
@ -32,6 +43,12 @@ class LoadButtonHandler(
return return
} }
if (inputText == DEV_SETTINGS_SECRET) {
editText.text.clear()
activity.startActivity(Intent(activity, DevSettingsActivity::class.java))
return
}
buttonPoints.clear() buttonPoints.clear()
setButtonsEnabled(emptyList()) // temporär sperren setButtonsEnabled(emptyList()) // temporär sperren
updateButtonTexts() // Chips zeigen vorläufig „Gesperrt“ updateButtonTexts() // Chips zeigen vorläufig „Gesperrt“
@ -143,10 +160,9 @@ class LoadButtonHandler(
(completedNorm.contains(targetNorm) || targetNorm.contains(completedNorm)) && completed.isDone (completedNorm.contains(targetNorm) || targetNorm.contains(completedNorm)) && completed.isDone
} }
} }
if (isCompleted) continue
val condMet = evaluateCondition(entry.condition, clientCode, completedEntries) val condMet = evaluateCondition(entry.condition, clientCode, completedEntries)
if (condMet) enabledButtons.add(button) if (condMet || isCompleted) enabledButtons.add(button)
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {

View File

@ -1,4 +1,6 @@
package com.dano.test1 package com.dano.test1.ui
import com.dano.test1.LanguageManager
data class Month(val name: String) { data class Month(val name: String) {
override fun toString(): String = name override fun toString(): String = name

View File

@ -1,12 +1,21 @@
package com.dano.test1 package com.dano.test1.ui
import android.content.ActivityNotFoundException
import android.content.ContentUris
import android.content.ContentValues
import android.content.Intent
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Paint import android.graphics.Paint
import android.graphics.pdf.PdfDocument import android.graphics.pdf.PdfDocument
import android.provider.MediaStore
import android.util.Log import android.util.Log
import android.widget.Button import android.widget.Button
import android.widget.EditText import android.widget.EditText
import android.widget.Toast import android.widget.Toast
import com.dano.test1.LanguageManager
import com.dano.test1.MainActivity
import com.dano.test1.MyApp
import com.dano.test1.questionnaire.GlobalValues
import kotlinx.coroutines.* import kotlinx.coroutines.*
class SaveButtonHandler( class SaveButtonHandler(
@ -105,19 +114,19 @@ class SaveButtonHandler(
val resolver = activity.contentResolver val resolver = activity.contentResolver
val deleteIfExists: (String) -> Unit = { name -> val deleteIfExists: (String) -> Unit = { name ->
val projection = arrayOf(android.provider.MediaStore.MediaColumns._ID) val projection = arrayOf(MediaStore.MediaColumns._ID)
val selection = "${android.provider.MediaStore.MediaColumns.DISPLAY_NAME} = ?" val selection = "${MediaStore.MediaColumns.DISPLAY_NAME} = ?"
val selectionArgs = arrayOf(name) val selectionArgs = arrayOf(name)
val query = resolver.query( val query = resolver.query(
android.provider.MediaStore.Downloads.EXTERNAL_CONTENT_URI, MediaStore.Downloads.EXTERNAL_CONTENT_URI,
projection, selection, selectionArgs, null projection, selection, selectionArgs, null
) )
query?.use { cursor -> query?.use { cursor ->
if (cursor.moveToFirst()) { if (cursor.moveToFirst()) {
val idColumn = cursor.getColumnIndexOrThrow(android.provider.MediaStore.MediaColumns._ID) val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
val id = cursor.getLong(idColumn) val id = cursor.getLong(idColumn)
val deleteUri = android.content.ContentUris.withAppendedId( val deleteUri = ContentUris.withAppendedId(
android.provider.MediaStore.Downloads.EXTERNAL_CONTENT_URI, id MediaStore.Downloads.EXTERNAL_CONTENT_URI, id
) )
resolver.delete(deleteUri, null, null) resolver.delete(deleteUri, null, null)
} }
@ -129,20 +138,20 @@ class SaveButtonHandler(
try { try {
val pdfUri = resolver.insert( val pdfUri = resolver.insert(
android.provider.MediaStore.Downloads.EXTERNAL_CONTENT_URI, MediaStore.Downloads.EXTERNAL_CONTENT_URI,
android.content.ContentValues().apply { ContentValues().apply {
put(android.provider.MediaStore.MediaColumns.DISPLAY_NAME, pdfFileName) put(MediaStore.MediaColumns.DISPLAY_NAME, pdfFileName)
put(android.provider.MediaStore.MediaColumns.MIME_TYPE, "application/pdf") put(MediaStore.MediaColumns.MIME_TYPE, "application/pdf")
put(android.provider.MediaStore.MediaColumns.RELATIVE_PATH, "Download/") put(MediaStore.MediaColumns.RELATIVE_PATH, "Download/")
} }
) )
val csvUri = resolver.insert( val csvUri = resolver.insert(
android.provider.MediaStore.Downloads.EXTERNAL_CONTENT_URI, MediaStore.Downloads.EXTERNAL_CONTENT_URI,
android.content.ContentValues().apply { ContentValues().apply {
put(android.provider.MediaStore.MediaColumns.DISPLAY_NAME, csvFileName) put(MediaStore.MediaColumns.DISPLAY_NAME, csvFileName)
put(android.provider.MediaStore.MediaColumns.MIME_TYPE, "text/csv") put(MediaStore.MediaColumns.MIME_TYPE, "text/csv")
put(android.provider.MediaStore.MediaColumns.RELATIVE_PATH, "Download/") put(MediaStore.MediaColumns.RELATIVE_PATH, "Download/")
} }
) )
@ -162,13 +171,13 @@ class SaveButtonHandler(
Toast.makeText(activity, msg, Toast.LENGTH_LONG).show() Toast.makeText(activity, msg, Toast.LENGTH_LONG).show()
pdfUri?.let { pdfUri?.let {
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW).apply { val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(it, "application/pdf") setDataAndType(it, "application/pdf")
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION or android.content.Intent.FLAG_ACTIVITY_NO_HISTORY) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NO_HISTORY)
} }
try { try {
activity.startActivity(intent) activity.startActivity(intent)
} catch (e: android.content.ActivityNotFoundException) { } catch (e: ActivityNotFoundException) {
val noViewer = LanguageManager.getText(languageIDProvider(), "no_pdf_viewer") val noViewer = LanguageManager.getText(languageIDProvider(), "no_pdf_viewer")
Toast.makeText(activity, noViewer, Toast.LENGTH_SHORT).show() Toast.makeText(activity, noViewer, Toast.LENGTH_SHORT).show()
} }

View File

@ -1,4 +1,3 @@
// app/src/main/java/com/dano/test1/AES256Helper.kt
package com.dano.test1 package com.dano.test1
import java.io.File import java.io.File
@ -11,7 +10,6 @@ import kotlin.math.min
object AES256Helper { object AES256Helper {
// HKDF-SHA256: IKM = tokenHex->bytes, salt="", info="qdb-aes", len=32
private fun hkdfFromToken(tokenHex: String, info: String = "qdb-aes", len: Int = 32): ByteArray { private fun hkdfFromToken(tokenHex: String, info: String = "qdb-aes", len: Int = 32): ByteArray {
val ikm = hexToBytes(tokenHex) val ikm = hexToBytes(tokenHex)
val mac = Mac.getInstance("HmacSHA256") val mac = Mac.getInstance("HmacSHA256")

View File

@ -1,6 +1,8 @@
package com.dano.test1 package com.dano.test1
import java.util.Calendar import com.dano.test1.questionnaire.MAX_VALUE_AGE
import com.dano.test1.questionnaire.MAX_VALUE_YEAR
import com.dano.test1.ui.RHS_POINTS
object LanguageManager { object LanguageManager {
fun getTextFormatted(languageId: String, key: String, vararg args: Any): String { fun getTextFormatted(languageId: String, key: String, vararg args: Any): String {
@ -61,14 +63,14 @@ object LanguageManager {
"once" to "einmal", "once" to "einmal",
"year_after_2000" to "Das Jahr muss nach 2000 liegen!", "year_after_2000" to "Das Jahr muss nach 2000 liegen!",
"year_after_departure" to "Das Jahr muss nach dem Verlassen des Herkunftslandes liegen!", "year_after_departure" to "Das Jahr muss nach dem Verlassen des Herkunftslandes liegen!",
"year_max" to "Das Jahr muss kleiner oder gleich $MAX_VALUE_YEAR sein!", "year_max" to "Das Jahr muss kleiner oder gleich ${MAX_VALUE_YEAR} sein!",
"data_final_warning" to "<b><font color='#FF0000'>Wichtig:</font></b> Die Daten können nach dem Abschluss nicht mehr verändert oder bearbeitet werden!", "data_final_warning" to "<b><font color='#FF0000'>Wichtig:</font></b> Die Daten können nach dem Abschluss nicht mehr verändert oder bearbeitet werden!",
"multiple_times" to "mehrmals", "multiple_times" to "mehrmals",
"more_than_15_years" to "mehr als 15 Jahre", "more_than_15_years" to "mehr als 15 Jahre",
"no" to "Nein", "no" to "Nein",
"no_answer" to "keine Angabe", "no_answer" to "keine Angabe",
"other_country" to "anderes Land", "other_country" to "anderes Land",
"value_must_be_less_equal_max" to "Der Wert muss kleiner oder gleich $MAX_VALUE_AGE sein!", "value_must_be_less_equal_max" to "Der Wert muss kleiner oder gleich ${MAX_VALUE_AGE} sein!",
"value_between_1_and_15" to "Der Wert muss zwischen 1 und 15 liegen!", "value_between_1_and_15" to "Der Wert muss zwischen 1 und 15 liegen!",
"invalid_month" to "Ungültige Monatsangabe!", "invalid_month" to "Ungültige Monatsangabe!",
"invalid_year" to "Ungültige Jahresangabe!", "invalid_year" to "Ungültige Jahresangabe!",
@ -400,7 +402,12 @@ object LanguageManager {
"done" to "Erledigt", "done" to "Erledigt",
"not_done" to "Nicht erledigt", "not_done" to "Nicht erledigt",
"none" to "Keine", "none" to "Keine",
"view_missing" to "Fehlende View: %s" "view_missing" to "Fehlende View: %s",
"session_over_12" to "Sitzung läuft länger als 12 Stunden.",
"cancel" to "Cancel",
"ok" to "OK",
"ask_before_upload" to "Möchtest du den Upload wirklich ausführen?",
"start_upload" to "Upload starten?"
), ),
"ENGLISH" to mapOf( "ENGLISH" to mapOf(
@ -433,14 +440,14 @@ object LanguageManager {
"once" to "once", "once" to "once",
"year_after_2000" to "The year must be after 2000!", "year_after_2000" to "The year must be after 2000!",
"year_after_departure" to "The year must be after leaving the country of origin!", "year_after_departure" to "The year must be after leaving the country of origin!",
"year_max" to "The year must be less than or equal to $MAX_VALUE_YEAR!", "year_max" to "The year must be less than or equal to ${MAX_VALUE_YEAR}!",
"data_final_warning" to "<b><font color='#FF0000'>Important:</font></b> The data cannot be changed or edited after completion!", "data_final_warning" to "<b><font color='#FF0000'>Important:</font></b> The data cannot be changed or edited after completion!",
"multiple_times" to "multiple times", "multiple_times" to "multiple times",
"more_than_15_years" to "more than 15 years", "more_than_15_years" to "more than 15 years",
"no" to "No", "no" to "No",
"no_answer" to "No answer", "no_answer" to "No answer",
"other_country" to "Other country", "other_country" to "Other country",
"value_must_be_less_equal_max" to "The value must be less than or equal to $MAX_VALUE_AGE!", "value_must_be_less_equal_max" to "The value must be less than or equal to ${MAX_VALUE_AGE}!",
"value_between_1_and_15" to "The value must be between 1 and 15!", "value_between_1_and_15" to "The value must be between 1 and 15!",
"invalid_month" to "Invalid month!", "invalid_month" to "Invalid month!",
"invalid_year" to "Invalid year!", "invalid_year" to "Invalid year!",
@ -771,7 +778,12 @@ object LanguageManager {
"done" to "Done", "done" to "Done",
"not_done" to "Not done", "not_done" to "Not done",
"none" to "None", "none" to "None",
"view_missing" to "Missing view: %s" "view_missing" to "Missing view: %s",
"session_over_12" to "Session has been running for more than 12 hours.",
"cancel" to "Cancel",
"ok" to "OK",
"ask_before_upload" to "Do you really want to perform the upload?",
"start_upload" to "Start upload?"
), ),
"FRENCH" to mapOf( "FRENCH" to mapOf(
@ -804,14 +816,14 @@ object LanguageManager {
"once" to "une fois", "once" to "une fois",
"year_after_2000" to "Lannée doit être après 2000 !", "year_after_2000" to "Lannée doit être après 2000 !",
"year_after_departure" to "Lannée doit être après le départ du pays dorigine !", "year_after_departure" to "Lannée doit être après le départ du pays dorigine !",
"year_max" to "Lannée doit être inférieure ou égale à $MAX_VALUE_YEAR !", "year_max" to "Lannée doit être inférieure ou égale à ${MAX_VALUE_YEAR} !",
"data_final_warning" to "<b><font color='#FF0000'>Important :</font></b> Les données ne peuvent plus être modifiées ou éditées après la validation !", "data_final_warning" to "<b><font color='#FF0000'>Important :</font></b> Les données ne peuvent plus être modifiées ou éditées après la validation !",
"multiple_times" to "plusieurs fois", "multiple_times" to "plusieurs fois",
"more_than_15_years" to "plus de 15 ans", "more_than_15_years" to "plus de 15 ans",
"no" to "Non", "no" to "Non",
"no_answer" to "pas de réponse", "no_answer" to "pas de réponse",
"other_country" to "autre pays", "other_country" to "autre pays",
"value_must_be_less_equal_max" to "La valeur doit être inférieure ou égale à $MAX_VALUE_AGE !", "value_must_be_less_equal_max" to "La valeur doit être inférieure ou égale à ${MAX_VALUE_AGE} !",
"value_between_1_and_15" to "La valeur doit être comprise entre 1 et 15 !", "value_between_1_and_15" to "La valeur doit être comprise entre 1 et 15 !",
"invalid_month" to "Mois invalide !", "invalid_month" to "Mois invalide !",
"invalid_year" to "Année invalide !", "invalid_year" to "Année invalide !",
@ -1146,7 +1158,12 @@ object LanguageManager {
"done" to "Terminé", "done" to "Terminé",
"not_done" to "Non terminé", "not_done" to "Non terminé",
"none" to "Aucun", "none" to "Aucun",
"view_missing" to "Vue manquante : %s" "view_missing" to "Vue manquante : %s",
"session_over_12" to "La session dure depuis plus de 12 heures.",
"cancel" to "Annuler",
"ok" to "OK",
"ask_before_upload" to "Voulez-vous vraiment effectuer le téléversement ?",
"start_upload" to "Démarrer le téléversement ?"
), ),
"RUSSIAN" to mapOf( "RUSSIAN" to mapOf(
@ -1179,14 +1196,14 @@ object LanguageManager {
"once" to "один раз", "once" to "один раз",
"year_after_2000" to "Год должен быть после 2000!", "year_after_2000" to "Год должен быть после 2000!",
"year_after_departure" to "Год должен быть после даты выезда из страны происхождения!", "year_after_departure" to "Год должен быть после даты выезда из страны происхождения!",
"year_max" to "Год должен быть меньше или равен $MAX_VALUE_YEAR!", "year_max" to "Год должен быть меньше или равен ${MAX_VALUE_YEAR}!",
"data_final_warning" to "<b><font color='#FF0000'>Внимание:</font></b> Данные нельзя изменять после завершения!", "data_final_warning" to "<b><font color='#FF0000'>Внимание:</font></b> Данные нельзя изменять после завершения!",
"multiple_times" to "несколько раз", "multiple_times" to "несколько раз",
"more_than_15_years" to "более 15 лет", "more_than_15_years" to "более 15 лет",
"no" to "Нет", "no" to "Нет",
"no_answer" to "без ответа", "no_answer" to "без ответа",
"other_country" to "другая страна", "other_country" to "другая страна",
"value_must_be_less_equal_max" to "Значение должно быть меньше или равно $MAX_VALUE_AGE!", "value_must_be_less_equal_max" to "Значение должно быть меньше или равно ${MAX_VALUE_AGE}!",
"value_between_1_and_15" to "Значение должно быть между 1 и 15!", "value_between_1_and_15" to "Значение должно быть между 1 и 15!",
"invalid_month" to "Недопустимый месяц!", "invalid_month" to "Недопустимый месяц!",
"invalid_year" to "Недопустимый год!", "invalid_year" to "Недопустимый год!",
@ -1517,7 +1534,12 @@ object LanguageManager {
"done" to "Готово", "done" to "Готово",
"not_done" to "Не выполнено", "not_done" to "Не выполнено",
"none" to "Нет", "none" to "Нет",
"view_missing" to "Отсутствует представление: %s" "view_missing" to "Отсутствует представление: %s",
"session_over_12" to "Сеанс продолжается более 12 часов.",
"cancel" to "Отмена",
"ok" to "OK",
"ask_before_upload" to "Вы действительно хотите выполнить загрузку?",
"start_upload" to "Начать загрузку?"
), ),
"UKRAINIAN" to mapOf( "UKRAINIAN" to mapOf(
@ -1550,14 +1572,14 @@ object LanguageManager {
"once" to "один раз", "once" to "один раз",
"year_after_2000" to "Рік має бути після 2000!", "year_after_2000" to "Рік має бути після 2000!",
"year_after_departure" to "Рік має бути після виїзду з країни походження!", "year_after_departure" to "Рік має бути після виїзду з країни походження!",
"year_max" to "Рік має бути меншим або рівним $MAX_VALUE_YEAR!", "year_max" to "Рік має бути меншим або рівним ${MAX_VALUE_YEAR}!",
"data_final_warning" to "<b><font color='#FF0000'>Важливо:</font></b> Дані після завершення не можна змінити або редагувати!", "data_final_warning" to "<b><font color='#FF0000'>Важливо:</font></b> Дані після завершення не можна змінити або редагувати!",
"multiple_times" to "багато разів", "multiple_times" to "багато разів",
"more_than_15_years" to "більше 15 років", "more_than_15_years" to "більше 15 років",
"no" to "Ні", "no" to "Ні",
"no_answer" to "немає відповіді", "no_answer" to "немає відповіді",
"other_country" to "інша країна", "other_country" to "інша країна",
"value_must_be_less_equal_max" to "Значення має бути меншим або рівним $MAX_VALUE_AGE!", "value_must_be_less_equal_max" to "Значення має бути меншим або рівним ${MAX_VALUE_AGE}!",
"value_between_1_and_15" to "Значення має бути від 1 до 15!", "value_between_1_and_15" to "Значення має бути від 1 до 15!",
"invalid_month" to "Неправильний місяць!", "invalid_month" to "Неправильний місяць!",
"invalid_year" to "Неправильний рік!", "invalid_year" to "Неправильний рік!",
@ -1892,7 +1914,12 @@ object LanguageManager {
"done" to "Готово", "done" to "Готово",
"not_done" to "Не виконано", "not_done" to "Не виконано",
"none" to "Немає", "none" to "Немає",
"view_missing" to "Відсутній елемент інтерфейсу: %s" "view_missing" to "Відсутній елемент інтерфейсу: %s",
"session_over_12" to "Сеанс триває понад 12 годин.",
"cancel" to "Скасувати",
"ok" to "OK",
"ask_before_upload" to "Ви справді хочете виконати завантаження?",
"start_upload" to "Почати завантаження?"
), ),
"TURKISH" to mapOf( "TURKISH" to mapOf(
@ -1925,14 +1952,14 @@ object LanguageManager {
"once" to "bir kez", "once" to "bir kez",
"year_after_2000" to "Yıl 2000den sonra olmalıdır!", "year_after_2000" to "Yıl 2000den sonra olmalıdır!",
"year_after_departure" to "Yıl, menşe ülkeyi terk ettikten sonra olmalıdır!", "year_after_departure" to "Yıl, menşe ülkeyi terk ettikten sonra olmalıdır!",
"year_max" to "Yıl $MAX_VALUE_YEARden küçük veya ona eşit olmalıdır!", "year_max" to "Yıl ${MAX_VALUE_YEAR}den küçük veya ona eşit olmalıdır!",
"data_final_warning" to "<b><font color='#FF0000'>Önemli:</font></b> Veriler tamamlandıktan sonra değiştirilemez veya düzenlenemez!", "data_final_warning" to "<b><font color='#FF0000'>Önemli:</font></b> Veriler tamamlandıktan sonra değiştirilemez veya düzenlenemez!",
"multiple_times" to "birden fazla kez", "multiple_times" to "birden fazla kez",
"more_than_15_years" to "15 yıldan fazla", "more_than_15_years" to "15 yıldan fazla",
"no" to "Hayır", "no" to "Hayır",
"no_answer" to "Cevap yok", "no_answer" to "Cevap yok",
"other_country" to "diğer ülke", "other_country" to "diğer ülke",
"value_must_be_less_equal_max" to "Değer $MAX_VALUE_AGEden küçük veya ona eşit olmalıdır!", "value_must_be_less_equal_max" to "Değer ${MAX_VALUE_AGE}den küçük veya ona eşit olmalıdır!",
"value_between_1_and_15" to "Değer 1 ile 15 arasında olmalıdır!", "value_between_1_and_15" to "Değer 1 ile 15 arasında olmalıdır!",
"invalid_month" to "Geçersiz ay girişi!", "invalid_month" to "Geçersiz ay girişi!",
"invalid_year" to "Geçersiz yıl girişi!", "invalid_year" to "Geçersiz yıl girişi!",
@ -2267,7 +2294,12 @@ object LanguageManager {
"done" to "Tamamlandı", "done" to "Tamamlandı",
"not_done" to "Tamamlanmadı", "not_done" to "Tamamlanmadı",
"none" to "Yok", "none" to "Yok",
"view_missing" to "Eksik görünüm: %s" "view_missing" to "Eksik görünüm: %s",
"session_over_12" to "Oturum 12 saatten uzun süredir açık.",
"cancel" to "İptal",
"ok" to "Tamam",
"ask_before_upload" to "Yüklemeyi gerçekten yapmak istiyor musunuz?",
"start_upload" to "Yüklemeyi başlat?"
), ),
"POLISH" to mapOf( "POLISH" to mapOf(
@ -2300,14 +2332,14 @@ object LanguageManager {
"once" to "jeden raz", "once" to "jeden raz",
"year_after_2000" to "Rok musi być po 2000!", "year_after_2000" to "Rok musi być po 2000!",
"year_after_departure" to "Rok musi być po opuszczeniu kraju pochodzenia!", "year_after_departure" to "Rok musi być po opuszczeniu kraju pochodzenia!",
"year_max" to "Rok musi być mniejszy lub równy $MAX_VALUE_YEAR!", "year_max" to "Rok musi być mniejszy lub równy ${MAX_VALUE_YEAR}!",
"data_final_warning" to "<b><font color='#FF0000'>Ważne:</font></b> Po zakończeniu dane nie mogą być zmienione ani edytowane!", "data_final_warning" to "<b><font color='#FF0000'>Ważne:</font></b> Po zakończeniu dane nie mogą być zmienione ani edytowane!",
"multiple_times" to "kilka razy", "multiple_times" to "kilka razy",
"more_than_15_years" to "więcej niż 15 lat", "more_than_15_years" to "więcej niż 15 lat",
"no" to "Nie", "no" to "Nie",
"no_answer" to "brak odpowiedzi", "no_answer" to "brak odpowiedzi",
"other_country" to "inny kraj", "other_country" to "inny kraj",
"value_must_be_less_equal_max" to "Wartość musi być mniejsza lub równa $MAX_VALUE_AGE!", "value_must_be_less_equal_max" to "Wartość musi być mniejsza lub równa ${MAX_VALUE_AGE}!",
"value_between_1_and_15" to "Wartość musi być między 1 a 15!", "value_between_1_and_15" to "Wartość musi być między 1 a 15!",
"invalid_month" to "Nieprawidłowy miesiąc!", "invalid_month" to "Nieprawidłowy miesiąc!",
"invalid_year" to "Nieprawidłowy rok!", "invalid_year" to "Nieprawidłowy rok!",
@ -2642,7 +2674,12 @@ object LanguageManager {
"done" to "Zrobione", "done" to "Zrobione",
"not_done" to "Niezrobione", "not_done" to "Niezrobione",
"none" to "Brak", "none" to "Brak",
"view_missing" to "Brak widoku: %s" "view_missing" to "Brak widoku: %s",
"session_over_12" to "Sesja trwa dłużej niż 12 godzin.",
"cancel" to "Anuluj",
"ok" to "OK",
"ask_before_upload" to "Czy na pewno chcesz wykonać przesyłanie?",
"start_upload" to "Rozpocząć przesyłanie?"
), ),
"ARABIC" to mapOf( "ARABIC" to mapOf(
@ -2675,14 +2712,14 @@ object LanguageManager {
"once" to "مرة واحدة", "once" to "مرة واحدة",
"year_after_2000" to "يجب أن تكون السنة بعد 2000!", "year_after_2000" to "يجب أن تكون السنة بعد 2000!",
"year_after_departure" to "يجب أن تكون السنة بعد مغادرة بلد المنشأ!", "year_after_departure" to "يجب أن تكون السنة بعد مغادرة بلد المنشأ!",
"year_max" to "يجب أن تكون السنة أقل من أو تساوي $MAX_VALUE_YEAR!", "year_max" to "يجب أن تكون السنة أقل من أو تساوي ${MAX_VALUE_YEAR}!",
"data_final_warning" to "<b><font color='#FF0000'>هام:</font></b> لا يمكن تعديل البيانات بعد الانتهاء!", "data_final_warning" to "<b><font color='#FF0000'>هام:</font></b> لا يمكن تعديل البيانات بعد الانتهاء!",
"multiple_times" to "عدة مرات", "multiple_times" to "عدة مرات",
"more_than_15_years" to "أكثر من 15 سنة", "more_than_15_years" to "أكثر من 15 سنة",
"no" to "لا", "no" to "لا",
"no_answer" to "لا يوجد إجابة", "no_answer" to "لا يوجد إجابة",
"other_country" to "بلد آخر", "other_country" to "بلد آخر",
"value_must_be_less_equal_max" to "يجب أن تكون القيمة أقل من أو تساوي $MAX_VALUE_AGE!", "value_must_be_less_equal_max" to "يجب أن تكون القيمة أقل من أو تساوي ${MAX_VALUE_AGE}!",
"value_between_1_and_15" to "يجب أن تكون القيمة بين 1 و15!", "value_between_1_and_15" to "يجب أن تكون القيمة بين 1 و15!",
"invalid_month" to "شهر غير صالح!", "invalid_month" to "شهر غير صالح!",
"invalid_year" to "سنة غير صالحة!", "invalid_year" to "سنة غير صالحة!",
@ -3017,7 +3054,12 @@ object LanguageManager {
"done" to "منجز", "done" to "منجز",
"not_done" to "غير منجز", "not_done" to "غير منجز",
"none" to "لا شيء", "none" to "لا شيء",
"view_missing" to "العنصر المفقود: %s" "view_missing" to "العنصر المفقود: %s",
"session_over_12" to "تعمل الجلسة منذ أكثر من 12 ساعة.",
"cancel" to "إلغاء",
"ok" to "موافق",
"ask_before_upload" to "هل ترغب فعلًا في تنفيذ الرفع؟",
"start_upload" to "بدء الرفع؟"
), ),
"ROMANIAN" to mapOf( "ROMANIAN" to mapOf(
@ -3050,14 +3092,14 @@ object LanguageManager {
"once" to "o dată", "once" to "o dată",
"year_after_2000" to "Anul trebuie să fie după 2000!", "year_after_2000" to "Anul trebuie să fie după 2000!",
"year_after_departure" to "Anul trebuie să fie după plecarea din țara de origine!", "year_after_departure" to "Anul trebuie să fie după plecarea din țara de origine!",
"year_max" to "Anul trebuie să fie mai mic sau egal cu $MAX_VALUE_YEAR!", "year_max" to "Anul trebuie să fie mai mic sau egal cu ${MAX_VALUE_YEAR}!",
"data_final_warning" to "<b><font color='#FF0000'>Important:</font></b> Datele nu mai pot fi modificate după finalizare!", "data_final_warning" to "<b><font color='#FF0000'>Important:</font></b> Datele nu mai pot fi modificate după finalizare!",
"multiple_times" to "de mai multe ori", "multiple_times" to "de mai multe ori",
"more_than_15_years" to "mai mult de 15 ani", "more_than_15_years" to "mai mult de 15 ani",
"no" to "Nu", "no" to "Nu",
"no_answer" to "fără răspuns", "no_answer" to "fără răspuns",
"other_country" to "altă țară", "other_country" to "altă țară",
"value_must_be_less_equal_max" to "Valoarea trebuie să fie mai mică sau egală cu $MAX_VALUE_AGE!", "value_must_be_less_equal_max" to "Valoarea trebuie să fie mai mică sau egală cu ${MAX_VALUE_AGE}!",
"value_between_1_and_15" to "Valoarea trebuie să fie între 1 și 15!", "value_between_1_and_15" to "Valoarea trebuie să fie între 1 și 15!",
"invalid_month" to "Lună invalidă!", "invalid_month" to "Lună invalidă!",
"invalid_year" to "An invalid!", "invalid_year" to "An invalid!",
@ -3392,7 +3434,12 @@ object LanguageManager {
"done" to "Finalizat", "done" to "Finalizat",
"not_done" to "Nefinalizat", "not_done" to "Nefinalizat",
"none" to "Nimic", "none" to "Nimic",
"view_missing" to "Vizualizare lipsă: %s" "view_missing" to "Vizualizare lipsă: %s",
"session_over_12" to "Sesiunea rulează de mai bine de 12 ore.",
"cancel" to "Anulează",
"ok" to "OK",
"ask_before_upload" to "Vrei într-adevăr să efectuezi încărcarea?",
"start_upload" to "Pornești încărcarea?"
), ),
"SPANISH" to mapOf( "SPANISH" to mapOf(
@ -3425,14 +3472,14 @@ object LanguageManager {
"once" to "una vez", "once" to "una vez",
"year_after_2000" to "¡El año debe ser posterior al 2000!", "year_after_2000" to "¡El año debe ser posterior al 2000!",
"year_after_departure" to "¡El año debe ser posterior a la salida de su país de origen!", "year_after_departure" to "¡El año debe ser posterior a la salida de su país de origen!",
"year_max" to "¡El año debe ser menor o igual que $MAX_VALUE_YEAR!", "year_max" to "¡El año debe ser menor o igual que ${MAX_VALUE_YEAR}!",
"data_final_warning" to "<b><font color='#FF0000'>Importante:</font></b> Después de finalizar, los datos no podrán cambiarse o editarse.", "data_final_warning" to "<b><font color='#FF0000'>Importante:</font></b> Después de finalizar, los datos no podrán cambiarse o editarse.",
"multiple_times" to "varias veces", "multiple_times" to "varias veces",
"more_than_15_years" to "más de 15 años", "more_than_15_years" to "más de 15 años",
"no" to "No", "no" to "No",
"no_answer" to "sin respuesta", "no_answer" to "sin respuesta",
"other_country" to "otro país", "other_country" to "otro país",
"value_must_be_less_equal_max" to "¡El valor debe ser menor o igual que $MAX_VALUE_AGE!", "value_must_be_less_equal_max" to "¡El valor debe ser menor o igual que ${MAX_VALUE_AGE}!",
"value_between_1_and_15" to "¡El valor debe estar entre 1 y 15!", "value_between_1_and_15" to "¡El valor debe estar entre 1 y 15!",
"invalid_month" to "¡Mes no válido!", "invalid_month" to "¡Mes no válido!",
"invalid_year" to "¡Año no válido!", "invalid_year" to "¡Año no válido!",
@ -3767,7 +3814,12 @@ object LanguageManager {
"done" to "Completado", "done" to "Completado",
"not_done" to "No completado", "not_done" to "No completado",
"none" to "Ninguno", "none" to "Ninguno",
"view_missing" to "Vista faltante: %s" "view_missing" to "Vista faltante: %s",
"session_over_12" to "La sesión lleva más de 12 horas.",
"cancel" to "Cancelar",
"ok" to "OK",
"ask_before_upload" to "¿Realmente deseas realizar la carga?",
"start_upload" to "¿Iniciar la carga?"
) )
) )
} }

View File

@ -0,0 +1,112 @@
package com.dano.test1.utils
import android.content.Context
import android.util.TypedValue
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.AbsListView
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.Spinner
import android.widget.TextView
import androidx.core.widget.TextViewCompat
import com.dano.test1.R
import kotlin.math.roundToInt
object ViewUtils {
/**
* Sets the text size of a TextView to a percentage of the screen height (in sp).
* Disables auto-sizing to prevent conflicts.
*/
fun setTextSizePercentOfScreenHeight(view: TextView, percentOfHeight: Float) {
val dm = view.context.resources.displayMetrics
val shortSide = minOf(dm.heightPixels, dm.widthPixels)
val sp = (shortSide * percentOfHeight) / dm.scaledDensity
TextViewCompat.setAutoSizeTextTypeWithDefaults(view, TextViewCompat.AUTO_SIZE_TEXT_TYPE_NONE)
view.setTextSize(TypedValue.COMPLEX_UNIT_SP, sp)
}
/**
* Sets up a Spinner with a responsive, styled adapter.
* Font size and row height are derived from screen height to prevent clipping.
*/
fun <T> setupResponsiveSpinner(context: Context, spinner: Spinner, items: List<T>, selectedItem: T?) {
val dm = context.resources.displayMetrics
val shortSide = minOf(dm.heightPixels, dm.widthPixels)
fun spFromScreenHeight(percent: Float): Float = (shortSide * percent) / dm.scaledDensity
fun pxFromSp(sp: Float): Int = (sp * dm.scaledDensity).toInt()
val textSp = spFromScreenHeight(0.0275f)
val textPx = pxFromSp(textSp)
val vPadPx = (textPx * 0.50f).toInt()
val rowHeight = (textPx * 2.20f + 2 * vPadPx).toInt()
val adapter = object : ArrayAdapter<T>(context, android.R.layout.simple_spinner_item, items) {
private fun styleRow(tv: TextView, forceHeight: Boolean) {
tv.setTextSize(TypedValue.COMPLEX_UNIT_SP, textSp)
tv.includeFontPadding = true
tv.setLineSpacing(0f, 1.2f)
tv.gravity = (tv.gravity and Gravity.HORIZONTAL_GRAVITY_MASK) or Gravity.CENTER_VERTICAL
tv.setPadding(tv.paddingLeft, vPadPx, tv.paddingRight, vPadPx)
tv.minHeight = rowHeight
tv.isSingleLine = true
if (forceHeight) {
val lp = tv.layoutParams
if (lp == null || lp.height <= 0) {
tv.layoutParams = AbsListView.LayoutParams(AbsListView.LayoutParams.MATCH_PARENT, rowHeight)
} else {
lp.height = rowHeight
}
}
}
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val v = super.getView(position, convertView, parent) as TextView
styleRow(v, forceHeight = false)
return v
}
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
val v = super.getDropDownView(position, convertView, parent) as TextView
styleRow(v, forceHeight = true)
return v
}
}
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
spinner.adapter = adapter
spinner.setPadding(spinner.paddingLeft, vPadPx, spinner.paddingRight, vPadPx)
spinner.minimumHeight = rowHeight
spinner.requestLayout()
selectedItem?.let {
val index = items.indexOf(it)
if (index >= 0) spinner.setSelection(index)
}
}
/**
* Locks an EditText field visually and functionally (e.g. for coach code fields).
*/
fun lockEditField(field: EditText, dpPadding: Int = 8) {
field.isFocusable = false
field.isFocusableInTouchMode = false
field.isCursorVisible = false
field.keyListener = null
field.isLongClickable = false
field.isClickable = false
field.setBackgroundResource(R.drawable.bg_field_locked)
field.setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.ic_lock_24, 0)
field.compoundDrawablePadding = dp(field.context, dpPadding)
field.alpha = 0.95f
}
/**
* Converts dp to pixels using the given context.
*/
fun dp(context: Context, value: Int): Int =
(value * context.resources.displayMetrics.density).roundToInt()
}

View File

@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.appcompat.widget.Toolbar
android:id="@+id/devSettingsToolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary"
app:title="A/B testing (dev)"
app:titleTextColor="@android:color/white" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="8dp"
android:text="Manual A/B override for local testing. Not a security boundary."
android:textSize="14sp" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/switchAbOverride"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingVertical="8dp"
android:text="Manual A/B override" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="16dp"
android:paddingBottom="8dp"
android:text="Variant"
android:textStyle="bold" />
<RadioGroup
android:id="@+id/radioGroupVariant"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<RadioButton
android:id="@+id/radioVariantDefault"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Default (no override)" />
<RadioButton
android:id="@+id/radioVariantA"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Variant A" />
<RadioButton
android:id="@+id/radioVariantB"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Variant B" />
</RadioGroup>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="24dp"
android:layout_marginBottom="16dp"
android:background="#DDDDDD" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="8dp"
android:text="Database"
android:textStyle="bold" />
<Button
android:id="@+id/databaseButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Datenbank" />
</LinearLayout>
</ScrollView>
</LinearLayout>

View File

@ -5,9 +5,14 @@
android:id="@+id/main" android:id="@+id/main"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
tools:context=".MainActivity" tools:context=".MainActivity">
tools:layout_editor_absoluteX="11dp"
tools:layout_editor_absoluteY="107dp"> <androidx.constraintlayout.widget.Guideline
android:id="@+id/gTop"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_begin="32dp" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/Qprev" android:id="@+id/Qprev"
@ -27,7 +32,6 @@
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" /> app:layout_constraintStart_toStartOf="parent" />
<!-- Weiter -->
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/Qnext" android:id="@+id/Qnext"
android:layout_width="@dimen/nav_btn_size" android:layout_width="@dimen/nav_btn_size"
@ -46,6 +50,34 @@
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" /> app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/textView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:layout_constraintTop_toBottomOf="@id/gTop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
<TextView
android:id="@+id/question"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
app:layout_constraintTop_toBottomOf="@id/textView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
<EditText <EditText
android:id="@+id/client_code" android:id="@+id/client_code"
android:layout_width="0dp" android:layout_width="0dp"
@ -54,7 +86,7 @@
app:layout_constraintHeight_percent="0.08" app:layout_constraintHeight_percent="0.08"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/question" app:layout_constraintTop_toBottomOf="@id/question"
android:layout_marginTop="16dp" android:layout_marginTop="16dp"
android:background="@android:drawable/edit_text" android:background="@android:drawable/edit_text"
android:ems="10" android:ems="10"
@ -90,34 +122,4 @@
android:autoSizeMaxTextSize="36sp" android:autoSizeMaxTextSize="36sp"
android:autoSizeStepGranularity="2sp" /> android:autoSizeStepGranularity="2sp" />
<TextView </androidx.constraintlayout.widget.ConstraintLayout>
android:id="@+id/textView"
android:layout_width="0dp"
android:layout_height="0dp"
android:gravity="center"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_percent="0.15"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.051"
app:layout_constraintWidth_percent="0.9" />
<TextView
android:id="@+id/question"
android:layout_width="0dp"
android:layout_height="0dp"
android:gravity="center"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_percent="0.15"
app:layout_constraintHorizontal_bias="0.512"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView"
app:layout_constraintVertical_bias="0.0"
app:layout_constraintWidth_percent="0.9" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,39 +1,42 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<!-- Obere Überschrift --> <androidx.constraintlayout.widget.Guideline
android:id="@+id/gTop"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_begin="32dp" />
<TextView <TextView
android:id="@+id/textView" android:id="@+id/textView"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="wrap_content"
android:gravity="center" android:gravity="center"
android:textStyle="bold" android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent" android:paddingStart="16dp"
app:layout_constraintEnd_toEndOf="parent" android:paddingEnd="16dp"
app:layout_constraintHeight_percent="0.15" app:layout_constraintTop_toBottomOf="@id/gTop"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintVertical_bias="0.051"
app:layout_constraintWidth_percent="0.9" /> app:layout_constraintWidth_percent="0.9" />
<!-- Frage -->
<TextView <TextView
android:id="@+id/question" android:id="@+id/question"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="wrap_content"
android:gravity="center" android:gravity="center"
android:textStyle="bold" android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent" android:paddingStart="16dp"
app:layout_constraintEnd_toEndOf="parent" android:paddingEnd="16dp"
app:layout_constraintHeight_percent="0.15" android:paddingTop="8dp"
app:layout_constraintHorizontal_bias="0.512" android:paddingBottom="8dp"
app:layout_constraintTop_toBottomOf="@id/textView"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintVertical_bias="0.0"
app:layout_constraintWidth_percent="0.9" /> app:layout_constraintWidth_percent="0.9" />
<TextView <TextView
@ -129,7 +132,6 @@
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" /> app:layout_constraintStart_toStartOf="parent" />
<!-- Weiter -->
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/Qnext" android:id="@+id/Qnext"
android:layout_width="@dimen/nav_btn_size" android:layout_width="@dimen/nav_btn_size"

View File

@ -1,41 +1,44 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<!-- Titel --> <androidx.constraintlayout.widget.Guideline
android:id="@+id/gTop"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_begin="32dp" />
<TextView <TextView
android:id="@+id/textView" android:id="@+id/textView"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="wrap_content"
android:gravity="center" android:gravity="center"
android:textStyle="bold" android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent" android:paddingStart="16dp"
app:layout_constraintEnd_toEndOf="parent" android:paddingEnd="16dp"
app:layout_constraintHeight_percent="0.15" app:layout_constraintTop_toBottomOf="@id/gTop"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintVertical_bias="0.051"
app:layout_constraintWidth_percent="0.9" /> app:layout_constraintWidth_percent="0.9" />
<!-- Leitfrage -->
<TextView <TextView
android:id="@+id/question" android:id="@+id/question"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="wrap_content"
android:gravity="center" android:gravity="center"
android:textStyle="bold" android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent" android:paddingStart="16dp"
app:layout_constraintEnd_toEndOf="parent" android:paddingEnd="16dp"
app:layout_constraintHeight_percent="0.15" android:paddingTop="8dp"
app:layout_constraintStart_toStartOf="parent" android:paddingBottom="8dp"
app:layout_constraintTop_toBottomOf="@id/textView" app:layout_constraintTop_toBottomOf="@id/textView"
app:layout_constraintVertical_bias="0.0" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" /> app:layout_constraintWidth_percent="0.9" />
<!-- FIXE ICON-LEISTE (bleibt stehen) -->
<LinearLayout <LinearLayout
android:id="@+id/glass_header" android:id="@+id/glass_header"
android:layout_width="0dp" android:layout_width="0dp"
@ -48,7 +51,6 @@
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="1" /> app:layout_constraintWidth_percent="1" />
<!-- Scrollbarer Bereich NUR für Symptome + Kreise -->
<ScrollView <ScrollView
android:id="@+id/glassScroll" android:id="@+id/glassScroll"
android:layout_width="0dp" android:layout_width="0dp"
@ -73,15 +75,12 @@
android:textStyle="bold" /> android:textStyle="bold" />
</ScrollView> </ScrollView>
<!-- Buttons unten -->
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/Qprev" android:id="@+id/Qprev"
android:layout_width="@dimen/nav_btn_size" android:layout_width="@dimen/nav_btn_size"
android:layout_height="@dimen/nav_btn_size" android:layout_height="@dimen/nav_btn_size"
android:layout_marginStart="20dp" android:layout_marginStart="20dp"
android:layout_marginBottom="16dp" android:layout_marginBottom="16dp"
android:text=""
android:textAllCaps="false"
app:icon="@drawable/ic_chevron_left" app:icon="@drawable/ic_chevron_left"
app:iconTint="@color/btn_nav_left_icon_tint" app:iconTint="@color/btn_nav_left_icon_tint"
app:iconSize="@dimen/nav_icon_size" app:iconSize="@dimen/nav_icon_size"
@ -92,15 +91,12 @@
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" /> app:layout_constraintStart_toStartOf="parent" />
<!-- Weiter -->
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/Qnext" android:id="@+id/Qnext"
android:layout_width="@dimen/nav_btn_size" android:layout_width="@dimen/nav_btn_size"
android:layout_height="@dimen/nav_btn_size" android:layout_height="@dimen/nav_btn_size"
android:layout_marginEnd="20dp" android:layout_marginEnd="20dp"
android:layout_marginBottom="16dp" android:layout_marginBottom="16dp"
android:text=""
android:textAllCaps="false"
app:icon="@drawable/ic_chevron_right" app:icon="@drawable/ic_chevron_right"
app:iconTint="@color/btn_nav_right_icon_tint" app:iconTint="@color/btn_nav_right_icon_tint"
app:iconSize="@dimen/nav_icon_size" app:iconSize="@dimen/nav_icon_size"

View File

@ -5,9 +5,14 @@
android:id="@+id/main" android:id="@+id/main"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
tools:context=".MainActivity" tools:context=".MainActivity">
tools:layout_editor_absoluteX="11dp"
tools:layout_editor_absoluteY="107dp"> <androidx.constraintlayout.widget.Guideline
android:id="@+id/gTop"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_begin="32dp" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/Qprev" android:id="@+id/Qprev"
@ -27,7 +32,6 @@
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" /> app:layout_constraintStart_toStartOf="parent" />
<!-- Weiter -->
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/Qnext" android:id="@+id/Qnext"
android:layout_width="@dimen/nav_btn_size" android:layout_width="@dimen/nav_btn_size"
@ -46,7 +50,34 @@
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" /> app:layout_constraintEnd_toEndOf="parent" />
<!-- ScrollView füllt den Raum zwischen Frage und Buttons --> <TextView
android:id="@+id/textView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:layout_constraintTop_toBottomOf="@id/gTop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
<TextView
android:id="@+id/question"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
app:layout_constraintTop_toBottomOf="@id/textView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
<ScrollView <ScrollView
android:id="@+id/scrollView" android:id="@+id/scrollView"
android:layout_width="0dp" android:layout_width="0dp"
@ -56,8 +87,8 @@
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/question" app:layout_constraintTop_toBottomOf="@id/question"
app:layout_constraintBottom_toTopOf="@+id/Qnext" app:layout_constraintBottom_toTopOf="@id/Qnext"
app:layout_constraintWidth_percent="0.9"> app:layout_constraintWidth_percent="0.9">
<LinearLayout <LinearLayout
@ -68,32 +99,4 @@
android:padding="16dp" /> android:padding="16dp" />
</ScrollView> </ScrollView>
<TextView
android:id="@+id/textView"
android:layout_width="0dp"
android:layout_height="0dp"
android:gravity="center"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_percent="0.15"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.051"
app:layout_constraintWidth_percent="0.9" />
<TextView
android:id="@+id/question"
android:layout_width="0dp"
android:layout_height="0dp"
android:gravity="center"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_percent="0.15"
app:layout_constraintHorizontal_bias="0.512"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView"
app:layout_constraintVertical_bias="0.0"
app:layout_constraintWidth_percent="0.9" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -195,21 +195,6 @@
app:cornerRadius="@dimen/pill_radius" app:cornerRadius="@dimen/pill_radius"
app:backgroundTint="@color/brand_purple"/> app:backgroundTint="@color/brand_purple"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/editButton"
android:layout_width="0dp"
android:layout_height="@dimen/pill_height"
android:layout_weight="1"
android:layout_marginEnd="12dp"
android:textAllCaps="false"
android:textColor="@android:color/white"
app:icon="@drawable/ic_dot_16"
app:iconTint="@android:color/white"
app:iconPadding="8dp"
app:iconGravity="textStart"
app:cornerRadius="@dimen/pill_radius"
app:backgroundTint="@color/brand_purple"/>
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/saveButton" android:id="@+id/saveButton"
android:layout_width="0dp" android:layout_width="0dp"
@ -253,19 +238,6 @@
style="@style/Widget.MaterialComponents.Button.OutlinedButton" style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="@dimen/pill_height" android:layout_height="@dimen/pill_height"
android:layout_marginBottom="12dp"
android:textAllCaps="false"
android:textColor="@color/brand_purple"
app:cornerRadius="@dimen/pill_radius"
app:strokeColor="@color/brand_purple"
app:strokeWidth="@dimen/pill_stroke"
app:backgroundTint="@android:color/transparent"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/databaseButton"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="@dimen/pill_height"
android:textAllCaps="false" android:textAllCaps="false"
android:textColor="@color/brand_purple" android:textColor="@color/brand_purple"
app:cornerRadius="@dimen/pill_radius" app:cornerRadius="@dimen/pill_radius"

View File

@ -0,0 +1,95 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="12dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnBack"
android:layout_width="@dimen/nav_btn_size"
android:layout_height="@dimen/nav_btn_size"
android:text=""
android:textAllCaps="false"
app:icon="@drawable/ic_chevron_left"
app:iconTint="@color/btn_nav_left_icon_tint"
app:iconSize="@dimen/nav_icon_size"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_left_tint"
app:rippleColor="@color/btn_nav_left_ripple" />
<View
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_weight="1" />
<Spinner
android:id="@+id/langSpinner"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_field_filled"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="6dp"
android:paddingBottom="6dp" />
</LinearLayout>
<androidx.core.widget.NestedScrollView
android:id="@+id/scrollContainer"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:fillViewport="true"
android:clipToPadding="false"
android:paddingBottom="16dp">
<LinearLayout
android:id="@+id/questionContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="8dp" />
</androidx.core.widget.NestedScrollView>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="12dp"
android:background="@android:color/white"
android:elevation="8dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSave"
android:layout_width="match_parent"
android:layout_height="@dimen/nav_btn_size"
android:textAllCaps="false"
android:minWidth="0dp"
android:insetLeft="0dp"
android:insetRight="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_right_tint"
app:rippleColor="@color/btn_nav_right_ripple" />
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
</FrameLayout>
</LinearLayout>

View File

@ -5,11 +5,15 @@
android:id="@+id/main" android:id="@+id/main"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
tools:context=".MainActivity" tools:context=".MainActivity">
tools:layout_editor_absoluteX="11dp"
tools:layout_editor_absoluteY="107dp"> <androidx.constraintlayout.widget.Guideline
android:id="@+id/gTop"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_begin="32dp" />
<!-- Zurück -->
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/Qprev" android:id="@+id/Qprev"
android:layout_width="@dimen/nav_btn_size" android:layout_width="@dimen/nav_btn_size"
@ -28,7 +32,6 @@
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" /> app:layout_constraintStart_toStartOf="parent" />
<!-- Weiter -->
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/Qnext" android:id="@+id/Qnext"
android:layout_width="@dimen/nav_btn_size" android:layout_width="@dimen/nav_btn_size"
@ -47,10 +50,34 @@
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" /> app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/textView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:layout_constraintTop_toBottomOf="@id/gTop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
<TextView
android:id="@+id/question"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
app:layout_constraintTop_toBottomOf="@id/textView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
<!-- SCROLLBEREICH für die Radio-Optionen:
füllt den Platz zwischen Frage und Buttons, scrollt bei Bedarf -->
<ScrollView <ScrollView
android:id="@+id/radioScroll" android:id="@+id/radioScroll"
android:layout_width="0dp" android:layout_width="0dp"
@ -65,7 +92,6 @@
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.80"> app:layout_constraintWidth_percent="0.80">
<!-- Die RadioGroup bleibt gleich, ist jetzt aber scrollfähig -->
<RadioGroup <RadioGroup
android:id="@+id/RadioGroup" android:id="@+id/RadioGroup"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -74,33 +100,4 @@
android:padding="8dp" /> android:padding="8dp" />
</ScrollView> </ScrollView>
<TextView
android:id="@+id/textView"
android:layout_width="0dp"
android:layout_height="0dp"
android:gravity="center"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_percent="0.15"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.051"
app:layout_constraintWidth_percent="0.9" />
<TextView
android:id="@+id/question"
android:layout_width="0dp"
android:layout_height="0dp"
android:gravity="center"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_percent="0.15"
app:layout_constraintHorizontal_bias="0.512"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView"
app:layout_constraintVertical_bias="0.0"
app:layout_constraintWidth_percent="0.9" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -5,6 +5,13 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<androidx.constraintlayout.widget.Guideline
android:id="@+id/gTop"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_begin="32dp" />
<Spinner <Spinner
android:id="@+id/string_spinner" android:id="@+id/string_spinner"
android:layout_width="0dp" android:layout_width="0dp"
@ -33,7 +40,6 @@
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" /> app:layout_constraintStart_toStartOf="parent" />
<!-- Weiter -->
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/Qnext" android:id="@+id/Qnext"
android:layout_width="@dimen/nav_btn_size" android:layout_width="@dimen/nav_btn_size"
@ -55,30 +61,29 @@
<TextView <TextView
android:id="@+id/textView" android:id="@+id/textView"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="wrap_content"
android:gravity="center" android:gravity="center"
android:textStyle="bold" android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent" android:paddingStart="16dp"
app:layout_constraintEnd_toEndOf="parent" android:paddingEnd="16dp"
app:layout_constraintHeight_percent="0.15" app:layout_constraintTop_toBottomOf="@id/gTop"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintVertical_bias="0.051"
app:layout_constraintWidth_percent="0.9" /> app:layout_constraintWidth_percent="0.9" />
<TextView <TextView
android:id="@+id/question" android:id="@+id/question"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="wrap_content"
android:gravity="center" android:gravity="center"
android:textStyle="bold" android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent" android:paddingStart="16dp"
app:layout_constraintEnd_toEndOf="parent" android:paddingEnd="16dp"
app:layout_constraintHeight_percent="0.15" android:paddingTop="8dp"
app:layout_constraintHorizontal_bias="0.512" android:paddingBottom="8dp"
app:layout_constraintTop_toBottomOf="@id/textView"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintVertical_bias="0.0"
app:layout_constraintWidth_percent="0.9" /> app:layout_constraintWidth_percent="0.9" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -5,6 +5,13 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<androidx.constraintlayout.widget.Guideline
android:id="@+id/gTop"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_begin="32dp" />
<Spinner <Spinner
android:id="@+id/value_spinner" android:id="@+id/value_spinner"
android:layout_width="0dp" android:layout_width="0dp"
@ -13,7 +20,7 @@
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.495" app:layout_constraintHorizontal_bias="0.495"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/question" app:layout_constraintTop_toBottomOf="@id/question"
app:layout_constraintVertical_bias="0.027" app:layout_constraintVertical_bias="0.027"
app:layout_constraintWidth_percent="0.70" /> app:layout_constraintWidth_percent="0.70" />
@ -35,7 +42,6 @@
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" /> app:layout_constraintStart_toStartOf="parent" />
<!-- Weiter -->
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/Qnext" android:id="@+id/Qnext"
android:layout_width="@dimen/nav_btn_size" android:layout_width="@dimen/nav_btn_size"
@ -57,31 +63,29 @@
<TextView <TextView
android:id="@+id/textView" android:id="@+id/textView"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="wrap_content"
android:gravity="center" android:gravity="center"
android:textStyle="bold" android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent" android:paddingStart="16dp"
app:layout_constraintEnd_toEndOf="parent" android:paddingEnd="16dp"
app:layout_constraintHeight_percent="0.15" app:layout_constraintTop_toBottomOf="@id/gTop"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintVertical_bias="0.051"
app:layout_constraintWidth_percent="0.9" /> app:layout_constraintWidth_percent="0.9" />
<TextView <TextView
android:id="@+id/question" android:id="@+id/question"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="wrap_content"
android:gravity="center" android:gravity="center"
android:textStyle="bold" android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent" android:paddingStart="16dp"
app:layout_constraintEnd_toEndOf="parent" android:paddingEnd="16dp"
app:layout_constraintHeight_percent="0.15" android:paddingTop="8dp"
app:layout_constraintHorizontal_bias="0.512" android:paddingBottom="8dp"
app:layout_constraintTop_toBottomOf="@id/textView"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintVertical_bias="0.0"
app:layout_constraintWidth_percent="0.9" /> app:layout_constraintWidth_percent="0.9" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>