Compare commits

...

11 Commits

Author SHA1 Message Date
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
ac2e0dabd2 changed button visibility 2025-09-30 16:21:20 +02:00
66122dd6c3 languageManager update 2025-09-29 13:58:15 +02:00
37 changed files with 1364 additions and 348 deletions

Binary file not shown.

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

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

View File

@ -23,7 +23,8 @@ class DatabaseButtonHandler(
private val exporter = ExcelExportService(activity, headerRepo) private val exporter = ExcelExportService(activity, headerRepo)
fun setup() { fun setup() {
databaseButton.text = "Datenbank" val lang = safeLang()
databaseButton.text = t(lang, "database") ?: "Datenbank"
databaseButton.setOnClickListener { openDatabaseScreen() } databaseButton.setOnClickListener { openDatabaseScreen() }
} }
@ -82,13 +83,13 @@ class DatabaseButtonHandler(
// Export: Header aller Clients als Excel // Export: Header aller Clients als Excel
// --------------------------- // ---------------------------
private fun onDownloadHeadersClicked(progress: ProgressBar) { private fun onDownloadHeadersClicked(progress: ProgressBar) {
val lang = safeLang()
uiScope.launch { uiScope.launch {
try { try {
progress.visibility = View.VISIBLE progress.visibility = View.VISIBLE
val savedUri = exporter.exportHeadersForAllClients() val savedUri = exporter.exportHeadersForAllClients()
progress.visibility = View.GONE progress.visibility = View.GONE
val lang = safeLang()
if (savedUri != null) { if (savedUri != null) {
Toast.makeText( Toast.makeText(
activity, activity,
@ -101,7 +102,8 @@ class DatabaseButtonHandler(
} catch (e: Exception) { } catch (e: Exception) {
progress.visibility = View.GONE progress.visibility = View.GONE
Log.e(tag, "Download Header Fehler: ${e.message}", e) Log.e(tag, "Download Header Fehler: ${e.message}", e)
Toast.makeText(activity, "Fehler: ${e.message}", Toast.LENGTH_LONG).show() val prefix = t(lang, "error") ?: "Fehler"
Toast.makeText(activity, "$prefix: ${e.message}", Toast.LENGTH_LONG).show()
} }
} }
} }
@ -124,7 +126,7 @@ class DatabaseButtonHandler(
val tableOrdered: TableLayout = requireView(R.id.tableOrdered, "tableOrdered") val tableOrdered: TableLayout = requireView(R.id.tableOrdered, "tableOrdered")
title.text = "${t(lang, "client") ?: "Client"}: $clientCode ${t(lang, "questionnaires") ?: "Fragebögen"}" title.text = "${t(lang, "client") ?: "Client"}: $clientCode ${t(lang, "questionnaires") ?: "Fragebögen"}"
headerLabel.text = t(lang, "headers") ?: "header" headerLabel.text = t(lang, "headers") ?: "Header"
backButton.text = t(lang, "previous") ?: "Zurück" backButton.text = t(lang, "previous") ?: "Zurück"
backButton.setOnClickListener { openDatabaseScreen() } backButton.setOnClickListener { openDatabaseScreen() }
@ -418,9 +420,11 @@ class DatabaseButtonHandler(
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)
if (v == null) { if (v == null) {
val msg = "View with id '$name' not found in current layout." val lang = safeLang()
val prefix = t(lang, "view_missing") ?: "Fehlende View: %s"
val msg = prefix.replace("%s", name)
Log.e(tag, msg) Log.e(tag, msg)
Toast.makeText(activity, "Fehlende View: $name", Toast.LENGTH_LONG).show() Toast.makeText(activity, msg, Toast.LENGTH_LONG).show()
throw IllegalStateException(msg) throw IllegalStateException(msg)
} }
return v return v

View File

@ -14,7 +14,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,4 +1,3 @@
// app/src/main/java/com/dano/test1/DatabaseUploader.kt
package com.dano.test1 package com.dano.test1
import android.content.Context import android.content.Context
@ -21,25 +20,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 +34,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 +68,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 +87,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 +108,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 +117,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 +158,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

@ -42,8 +42,7 @@ class EditButtonHandler(
val needLoad = GlobalValues.LOADED_CLIENT_CODE?.equals(desiredCode) != true val needLoad = GlobalValues.LOADED_CLIENT_CODE?.equals(desiredCode) != true
if (needLoad) { if (needLoad) {
// Zwischenzustände aus dem Load-Handler unterdrücken setUiFreeze(true) // Zwischenzustände unterdrücken
setUiFreeze(true)
triggerLoad() triggerLoad()
} }
@ -51,7 +50,8 @@ class EditButtonHandler(
val loadedOk = waitUntilClientLoaded(desiredCode, timeoutMs = 2500, stepMs = 50) val loadedOk = waitUntilClientLoaded(desiredCode, timeoutMs = 2500, stepMs = 50)
if (!loadedOk) { if (!loadedOk) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
Toast.makeText(activity, "Bitte den Klienten über \"Laden\" öffnen.", Toast.LENGTH_LONG).show() val msg = LanguageManager.getText(languageIDProvider(), "open_client_via_load")
Toast.makeText(activity, msg, Toast.LENGTH_LONG).show()
setUiFreeze(false) setUiFreeze(false)
} }
return@launch return@launch
@ -70,7 +70,6 @@ class EditButtonHandler(
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
// nur den finalen Zustand anzeigen
updateButtonTexts() updateButtonTexts()
val enabledButtons = questionnaireFiles.filter { (_, fileName) -> val enabledButtons = questionnaireFiles.filter { (_, fileName) ->
completedFiles.any { completedId -> fileName.lowercase().contains(completedId) } completedFiles.any { completedId -> fileName.lowercase().contains(completedId) }

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

@ -10,12 +10,29 @@ import android.provider.MediaStore
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
/*
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
@ -78,7 +95,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
@ -108,7 +124,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 +162,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

View File

@ -9,6 +9,11 @@ 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,
@ -48,7 +53,7 @@ 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)
@ -122,7 +127,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,7 +143,6 @@ class HandlerClientCoachCode(
// Not used // Not used
} }
// --- Helfer zum Sperren inkl. optischer Markierung (wie im Opening Screen) ---
private fun lockCoachField(field: EditText) { private fun lockCoachField(field: EditText) {
field.isFocusable = false field.isFocusable = false
field.isFocusableInTouchMode = false field.isFocusableInTouchMode = false

View File

@ -3,6 +3,12 @@ package com.dano.test1
import android.view.View import android.view.View
import android.widget.* import android.widget.*
/*
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>,
private val languageID: String, private val languageID: String,
@ -14,7 +20,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 +31,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 +56,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 +66,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

@ -11,6 +11,11 @@ import android.util.TypedValue
import androidx.core.widget.TextViewCompat import androidx.core.widget.TextViewCompat
import android.widget.AbsListView import android.widget.AbsListView
/*
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,
private val answers: MutableMap<String, Any>, private val answers: MutableMap<String, Any>,
@ -46,13 +51,13 @@ 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 setTextSizePercentOfScreenHeight(textView, 0.03f) // oben
setTextSizePercentOfScreenHeight(questionTextView, 0.03f) // frage setTextSizePercentOfScreenHeight(questionTextView, 0.03f) // frage
setTextSizePercentOfScreenHeight(labelDay, 0.025f) setTextSizePercentOfScreenHeight(labelDay, 0.025f)
setTextSizePercentOfScreenHeight(labelMonth, 0.025f) setTextSizePercentOfScreenHeight(labelMonth, 0.025f)
setTextSizePercentOfScreenHeight(labelYear, 0.025f) 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 {
@ -202,7 +207,7 @@ class HandlerDateSpinner(
return sdf.parse(dateString) return sdf.parse(dateString)
} }
// —— Textgröße prozentual zur Bildschirmhöhe (in sp) —— // Textgröße prozentual zur Bildschirmhöhe (in sp)
private fun setTextSizePercentOfScreenHeight(view: TextView, percentOfHeight: Float) { private fun setTextSizePercentOfScreenHeight(view: TextView, percentOfHeight: Float) {
val dm = (view.context ?: layout.context).resources.displayMetrics val dm = (view.context ?: layout.context).resources.displayMetrics
val sp = (dm.heightPixels * percentOfHeight) / dm.scaledDensity val sp = (dm.heightPixels * percentOfHeight) / dm.scaledDensity
@ -210,7 +215,7 @@ class HandlerDateSpinner(
view.setTextSize(TypedValue.COMPLEX_UNIT_SP, sp) view.setTextSize(TypedValue.COMPLEX_UNIT_SP, sp)
} }
// —— Spinner-Adapter: Schrift & Zeilenhöhe dynamisch, kein Abschneiden —— // Spinner-Adapter: Schrift & Zeilenhöhe dynamisch, kein Abschneiden
private fun <T> setupSpinner(spinner: Spinner, items: List<T>, defaultSelection: T?) { private fun <T> setupSpinner(spinner: Spinner, items: List<T>, defaultSelection: T?) {
val dm = context.resources.displayMetrics val dm = context.resources.displayMetrics
@ -246,13 +251,13 @@ class HandlerDateSpinner(
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val v = super.getView(position, convertView, parent) as TextView val v = super.getView(position, convertView, parent) as TextView
styleRow(v, forceHeight = false) // ausgewählte Ansicht styleRow(v, forceHeight = false)
return v return v
} }
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
val v = super.getDropDownView(position, convertView, parent) as TextView val v = super.getDropDownView(position, convertView, parent) as TextView
styleRow(v, forceHeight = true) // Dropdown-Zeilen: Höhe erzwingen styleRow(v, forceHeight = true)
return v return v
} }
} }
@ -260,7 +265,6 @@ class HandlerDateSpinner(
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
spinner.adapter = adapter spinner.adapter = adapter
// Spinner selbst ausreichend hoch
spinner.setPadding(spinner.paddingLeft, vPadPx, spinner.paddingRight, vPadPx) spinner.setPadding(spinner.paddingLeft, vPadPx, spinner.paddingRight, vPadPx)
spinner.minimumHeight = rowHeight spinner.minimumHeight = rowHeight
spinner.requestLayout() spinner.requestLayout()

View File

@ -8,6 +8,14 @@ import android.widget.*
import androidx.core.widget.TextViewCompat import androidx.core.widget.TextViewCompat
import kotlinx.coroutines.* import kotlinx.coroutines.*
/*
Zweck:
- Stellt eine „Glas-Skala“-Frage dar, bei der pro Symptom (Zeile) genau eine von fünf Antwortstufen gewählt wird: never / little / moderate / much / extreme.
- Die Stufen werden sowohl als RadioButtons als auch über eine feste Icon-Leiste visualisiert.
*/
class HandlerGlassScaleQuestion( class HandlerGlassScaleQuestion(
private val context: Context, private val context: Context,
private val answers: MutableMap<String, Any>, private val answers: MutableMap<String, Any>,
@ -60,7 +68,7 @@ class HandlerGlassScaleQuestion(
setTextSizePercentOfScreenHeight(titleTv, 0.03f) setTextSizePercentOfScreenHeight(titleTv, 0.03f)
setTextSizePercentOfScreenHeight(questionTv, 0.03f) setTextSizePercentOfScreenHeight(questionTv, 0.03f)
// ----- feste Icon-Leiste ----- // feste Icon-Leiste
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 {
@ -80,7 +88,7 @@ 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()
@ -154,21 +162,26 @@ class HandlerGlassScaleQuestion(
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!
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 = savedLabel == labelKey
setPadding(0, 0, 0, 0) setPadding(0, 0, 0, 0)
} }
val lp = RadioGroup.LayoutParams( rb.layoutParams = FrameLayout.LayoutParams(
0, RadioGroup.LayoutParams.WRAP_CONTENT, 1f FrameLayout.LayoutParams.WRAP_CONTENT,
).apply { gravity = Gravity.CENTER } FrameLayout.LayoutParams.WRAP_CONTENT,
rb.layoutParams = lp Gravity.CENTER
radioGroup.addView(rb) )
cell.addView(rb)
radioGroup.addView(cell)
} }
row.addView(radioGroup) row.addView(radioGroup)
@ -176,6 +189,7 @@ class HandlerGlassScaleQuestion(
} }
} }
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) {
@ -215,7 +229,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

View File

@ -9,6 +9,21 @@ import android.widget.TextView
import androidx.core.widget.TextViewCompat import androidx.core.widget.TextViewCompat
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 +34,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
@ -61,27 +76,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

View File

@ -7,6 +7,11 @@ import kotlinx.coroutines.*
import android.util.TypedValue import android.util.TypedValue
import androidx.core.widget.TextViewCompat import androidx.core.widget.TextViewCompat
/*
Zweck:
- Steuert eine Frage mit mehreren auswählbaren Antwortoptionen (Checkboxen).
*/
class HandlerMultiCheckboxQuestion( class HandlerMultiCheckboxQuestion(
private val context: Context, private val context: Context,
private val answers: MutableMap<String, Any>, private val answers: MutableMap<String, Any>,
@ -15,7 +20,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 +34,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 setTextSizePercentOfScreenHeight(questionTextView, 0.03f) // Überschrift
setTextSizePercentOfScreenHeight(questionTitle, 0.03f) // Frage setTextSizePercentOfScreenHeight(questionTitle, 0.03f) // Frage
// ==========================================================================
container.removeAllViews() container.removeAllViews()
@ -45,13 +48,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 +80,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 +102,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 +177,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 +188,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,7 +204,6 @@ class HandlerMultiCheckboxQuestion(
} }
} }
// Helper: Textgröße prozentual zur Bildschirmhöhe setzen
private fun setTextSizePercentOfScreenHeight(view: TextView, percentOfHeight: Float) { private fun setTextSizePercentOfScreenHeight(view: TextView, percentOfHeight: Float) {
val dm = (view.context ?: layout.context).resources.displayMetrics val dm = (view.context ?: layout.context).resources.displayMetrics
val sp = (dm.heightPixels * percentOfHeight) / dm.scaledDensity val sp = (dm.heightPixels * percentOfHeight) / dm.scaledDensity

View File

@ -13,6 +13,8 @@ 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
var RHS_POINTS: Int? = null var RHS_POINTS: Int? = null
@ -32,6 +34,8 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
private lateinit var databaseButton: 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 +67,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()
@ -75,12 +84,21 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
setupUploadButton() setupUploadButton()
setupDownloadButton() setupDownloadButton()
setupDatabaseButtonHandler() setupDatabaseButtonHandler()
uiHandler.removeCallbacks(statusTicker) uiHandler.removeCallbacks(statusTicker)
updateStatusStrip() updateStatusStrip()
uiHandler.post(statusTicker) uiHandler.post(statusTicker)
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)
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() {
@ -93,7 +111,10 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
saveButton = activity.findViewById(R.id.saveButton) saveButton = activity.findViewById(R.id.saveButton)
editButton = activity.findViewById(R.id.editButton) 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)
downloadButton.visibility = View.GONE
databaseButton = activity.findViewById(R.id.databaseButton) databaseButton = activity.findViewById(R.id.databaseButton)
statusSession = activity.findViewById(R.id.statusSession) statusSession = activity.findViewById(R.id.statusSession)
statusOnline = activity.findViewById(R.id.statusOnline) statusOnline = activity.findViewById(R.id.statusOnline)
@ -240,7 +261,10 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
dynamicButtons.add(btn) dynamicButtons.add(btn)
questionnaireFiles[btn] = entry.file questionnaireFiles[btn] = entry.file
cardParts[btn] = CardParts(tvTitle, tvSubtitle, chip) cardParts[btn] = CardParts(tvTitle, tvSubtitle, chip)
tvTitle.text = "Questionnaire ${index + 1}"
// Fallback-Titel lokalisieren
tvTitle.text = "${t("questionnaire")} ${index + 1}"
if (entry.condition is QuestionItem.Condition.AlwaysAvailable) startEnabled.add(btn) if (entry.condition is QuestionItem.Condition.AlwaysAvailable) startEnabled.add(btn)
} }
applyUpdateButtonTexts(force = false) applyUpdateButtonTexts(force = false)
@ -248,12 +272,14 @@ 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
@ -435,20 +461,61 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
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") ?: "Bitte zuerst einloggen", 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)
} }
} }
private fun setupDownloadButton() { private fun setupDownloadButton() {
downloadButton.text = t("download") downloadButton.text = t("download")
// Bei Setup gleich den aktuellen Zustand anwenden
val hasDb = File("/data/data/com.dano.test1/databases/questionnaire_database").exists()
updateDownloadButtonState(hasDb)
downloadButton.setOnClickListener { downloadButton.setOnClickListener {
Toast.makeText(activity, t("login_required") ?: "Bitte zuerst einloggen", Toast.LENGTH_SHORT).show() // Falls der Button (später) deaktiviert ist, passiert einfach nichts
if (!downloadButton.isEnabled) return@setOnClickListener
Toast.makeText(activity, t("login_required"), Toast.LENGTH_SHORT).show()
} }
} }
@ -466,6 +533,30 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
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
}
private fun updateDownloadButtonState(isDatabaseAvailable: Boolean) {
val mb = downloadButton as? MaterialButton
if (isDatabaseAvailable) {
downloadButton.isEnabled = false
downloadButton.alpha = 0.5f
mb?.apply {
strokeWidth = dp(1)
strokeColor = ColorStateList.valueOf(STROKE_DISABLED)
backgroundTintList = ColorStateList.valueOf(Color.parseColor("#F5F5F5"))
rippleColor = ColorStateList.valueOf(Color.parseColor("#00000000"))
}
} else {
downloadButton.isEnabled = true
downloadButton.alpha = 1.0f
mb?.apply {
strokeWidth = dp(2)
strokeColor = ColorStateList.valueOf(STROKE_ENABLED)
backgroundTintList = ColorStateList.valueOf(Color.WHITE)
rippleColor = ColorStateList.valueOf(Color.parseColor("#22000000"))
}
}
} }
private fun dp(v: Int): Int = (v * activity.resources.displayMetrics.density).toInt() private fun dp(v: Int): Int = (v * activity.resources.displayMetrics.density).toInt()
@ -540,10 +631,19 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
val ageMs = if (ts > 0L) (System.currentTimeMillis() - ts) else 0L val ageMs = if (ts > 0L) (System.currentTimeMillis() - ts) else 0L
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
statusSession.text = if (ts > 0L) "Session: ${h}h ${m}m" else "Session: —" if (ts > 0L) {
// ⚠️ 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 {
statusSession.text = t("session_dash")
}
val online = NetworkUtils.isOnline(activity) val online = NetworkUtils.isOnline(activity)
statusOnline.text = if (online) "Online" else "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() {
@ -563,6 +663,57 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
coachEditText.alpha = 0.95f 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: android.content.Context, message: String) {
val tv = android.widget.TextView(ctx).apply {
text = message
setTextColor(android.graphics.Color.WHITE)
textSize = 16f
setPadding(32, 20, 32, 20)
background = android.graphics.drawable.GradientDrawable().apply {
shape = android.graphics.drawable.GradientDrawable.RECTANGLE
cornerRadius = 24f
setColor(android.graphics.Color.parseColor("#D32F2F")) // kräftiges Rot
}
}
android.widget.Toast(ctx).apply {
duration = android.widget.Toast.LENGTH_LONG
view = tv
setGravity(android.view.Gravity.TOP or android.view.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

@ -8,6 +8,11 @@ import kotlinx.coroutines.*
import android.util.TypedValue import android.util.TypedValue
import androidx.core.widget.TextViewCompat // <— hinzugefügt import androidx.core.widget.TextViewCompat // <— hinzugefügt
/*
Zweck:
- Steuert eine Einzelfrage mit genau einer auswählbaren Antwort (RadioButtons).
*/
class HandlerRadioQuestion( class HandlerRadioQuestion(
private val context: Context, private val context: Context,
private val answers: MutableMap<String, Any>, private val answers: MutableMap<String, Any>,
@ -36,7 +41,7 @@ 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 === //
// Titel/Frage: 3% der Bildschirmhöhe // Titel/Frage: 3% der Bildschirmhöhe
setTextSizePercentOfScreenHeight(questionTextView, 0.03f) setTextSizePercentOfScreenHeight(questionTextView, 0.03f)
setTextSizePercentOfScreenHeight(questionTitle, 0.03f) setTextSizePercentOfScreenHeight(questionTitle, 0.03f)
@ -132,7 +137,7 @@ class HandlerRadioQuestion(
} }
} }
// ——— Helper: setzt Textgröße prozentual zur Bildschirmhöhe (in sp) ——— // setzt Textgröße prozentual zur Bildschirmhöhe (in sp)
private fun setTextSizePercentOfScreenHeight(view: TextView, percentOfHeight: Float) { private fun setTextSizePercentOfScreenHeight(view: TextView, percentOfHeight: Float) {
val dm = (view.context ?: layout.context).resources.displayMetrics val dm = (view.context ?: layout.context).resources.displayMetrics
val sp = (dm.heightPixels * percentOfHeight) / dm.scaledDensity val sp = (dm.heightPixels * percentOfHeight) / dm.scaledDensity

View File

@ -9,6 +9,12 @@ import android.util.TypedValue
import android.widget.TextView import android.widget.TextView
import androidx.core.widget.TextViewCompat import androidx.core.widget.TextViewCompat
/*
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( class HandlerStringSpinner(
private val context: Context, private val context: Context,
private val answers: MutableMap<String, Any>, private val answers: MutableMap<String, Any>,
@ -16,7 +22,7 @@ class HandlerStringSpinner(
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 // Meta, damit dieselbe DB-ID wie in anderen Handlern gebildet wird private val questionnaireMeta: String
) : QuestionHandler { ) : QuestionHandler {
private lateinit var layout: View private lateinit var layout: View
@ -36,20 +42,19 @@ class HandlerStringSpinner(
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) } ?: ""
// === Textgrößen prozentual zur Bildschirmhöhe (wie im HandlerRadioQuestion) === // Textgrößen prozentual zur Bildschirmhöhe (wie im HandlerRadioQuestion)
setTextSizePercentOfScreenHeight(textView, 0.03f) setTextSizePercentOfScreenHeight(textView, 0.03f)
setTextSizePercentOfScreenHeight(questionTextView, 0.03f) setTextSizePercentOfScreenHeight(questionTextView, 0.03f)
// ==============================================================================
val options = buildOptionsList() val options = buildOptionsList()
// vorhandene Auswahl (falls vorhanden) // vorhandene Auswahl (falls vorhanden)
val savedSelection = question.question?.let { answers[it] as? String } val savedSelection = question.question?.let { answers[it] as? String }
// Spinner aufsetzen (Schriftgröße & Zeilenhöhe dynamisch, kein Abschneiden) // Spinner aufsetzen
setupSpinner(spinner, options, savedSelection) setupSpinner(spinner, options, savedSelection)
// Falls noch keine Antwort im Map: aus DB laden (analog zu anderen Handlern) // Falls noch keine Antwort im Map: aus DB laden
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 {

View File

@ -8,6 +8,14 @@ import kotlinx.coroutines.*
import android.util.TypedValue import android.util.TypedValue
import androidx.core.widget.TextViewCompat // <- NEU import androidx.core.widget.TextViewCompat // <- NEU
/*
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,
private val answers: MutableMap<String, Any>, private val answers: MutableMap<String, Any>,
@ -16,7 +24,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 +43,10 @@ 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 === // Schriftgrößen wie im HandlerRadioQuestion
// Titel/Frage: 3% der Bildschirmhöhe // Titel/Frage: 3% der Bildschirmhöhe
setTextSizePercentOfScreenHeight(textView, 0.03f) setTextSizePercentOfScreenHeight(textView, 0.03f)
setTextSizePercentOfScreenHeight(questionTextView, 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) {
@ -51,7 +58,7 @@ class HandlerValueSpinner(
val savedValue = question.question?.let { answers[it] as? String } val savedValue = question.question?.let { answers[it] as? String }
setupSpinner(spinner, spinnerItems, savedValue) setupSpinner(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,14 +127,13 @@ class HandlerValueSpinner(
} }
} }
// ——— Helper: setzt Textgröße prozentual zur Bildschirmhöhe (in sp) ——— // setzt Textgröße prozentual zur Bildschirmhöhe (in sp)
private fun setTextSizePercentOfScreenHeight(view: TextView, percentOfHeight: Float) { private fun setTextSizePercentOfScreenHeight(view: TextView, percentOfHeight: Float) {
val dm = (view.context ?: layout.context).resources.displayMetrics val dm = (view.context ?: layout.context).resources.displayMetrics
val sp = (dm.heightPixels * percentOfHeight) / dm.scaledDensity val sp = (dm.heightPixels * percentOfHeight) / dm.scaledDensity
TextViewCompat.setAutoSizeTextTypeWithDefaults(view, TextViewCompat.AUTO_SIZE_TEXT_TYPE_NONE) TextViewCompat.setAutoSizeTextTypeWithDefaults(view, TextViewCompat.AUTO_SIZE_TEXT_TYPE_NONE)
view.setTextSize(TypedValue.COMPLEX_UNIT_SP, sp) view.setTextSize(TypedValue.COMPLEX_UNIT_SP, sp)
} }
// ————————————————————————————————————————————————————————————————
private fun <T> setupSpinner(spinner: Spinner, items: List<T>, selectedItem: T?) { private fun <T> setupSpinner(spinner: Spinner, items: List<T>, selectedItem: T?) {
val dm = context.resources.displayMetrics val dm = context.resources.displayMetrics

View File

@ -7,11 +7,23 @@ 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
class HeaderOrderRepository(private val context: Context) { /*
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(
private val context: Context,
// Sprache abrufen (Standard: Deutsch, damit es ohne OpeningScreen schon sinnvoll ist)
private val languageIDProvider: () -> String = { "GERMAN" }
) {
private val tag = "HeaderOrderRepository" private val tag = "HeaderOrderRepository"
private var orderedIdsCache: List<String>? = null private var orderedIdsCache: List<String>? = null
private fun t(key: String): String = LanguageManager.getText(languageIDProvider(), key)
fun loadOrderedIds(): List<String> { fun loadOrderedIds(): List<String> {
orderedIdsCache?.let { return it } orderedIdsCache?.let { return it }
@ -33,7 +45,7 @@ class HeaderOrderRepository(private val context: Context) {
list list
} catch (e: Exception) { } catch (e: Exception) {
Log.e(tag, "Weder header_order.xlsx noch header_order.json verfügbar/gültig: ${e.message}") Log.e(tag, "Weder header_order.xlsx noch header_order.json verfügbar/gültig: ${e.message}")
Toast.makeText(context, "Keine Header-Vorlage gefunden", Toast.LENGTH_LONG).show() Toast.makeText(context, t("no_header_template_found"), Toast.LENGTH_LONG).show()
emptyList() emptyList()
} }
} }

View File

@ -354,7 +354,58 @@ object LanguageManager {
"error_generic" to "Fehler", "error_generic" to "Fehler",
"not_done" to "Nicht Fertog", "not_done" to "Nicht Fertog",
"none" to "Keine Angabe", "none" to "Keine Angabe",
"points" to "Punkte" "points" to "Punkte",
"saved_pdf_csv" to "PDF und CSV wurden im Ordner \"Downloads\" gespeichert.",
"no_pdf_viewer" to "Kein PDF-Viewer installiert.",
"save_error" to "Fehler beim Speichern: {message}",
"login_required_title" to "Login erforderlich",
"username_hint" to "Benutzername",
"password_hint" to "Passwort",
"login_btn" to "Login",
"exit_btn" to "Beenden",
"please_username_password" to "Bitte Benutzername und Passwort eingeben.",
"download_failed_no_local_db" to "Download fehlgeschlagen keine lokale Datenbank vorhanden",
"download_failed_use_offline" to "Download fehlgeschlagen arbeite offline mit vorhandener Datenbank",
"login_failed_with_reason" to "Login fehlgeschlagen: {reason}",
"no_header_template_found" to "Keine Header-Vorlage gefunden",
"login_required" to "Bitte zuerst einloggen",
"questionnaire" to "Fragebogen",
"session_label" to "Sitzung",
"session_dash" to "Sitzung: —",
"hours_short" to "h",
"minutes_short" to "m",
"online" to "Online",
"offline" to "Offline",
"open_client_via_load" to "Bitte den Klienten über „Laden“ öffnen.",
"database" to "Datenbank",
"database_clients_title" to "Datenbank Clients",
"no_clients_available" to "Keine Clients vorhanden.",
"previous" to "Zurück",
"download_header" to "Header herunterladen",
"client_code" to "Client-Code",
"export_success_downloads" to "Export erfolgreich: Downloads/ClientHeaders.xlsx",
"export_failed" to "Export fehlgeschlagen.",
"error" to "Fehler",
"client" to "Client",
"questionnaires" to "Fragebögen",
"headers" to "Header",
"questionnaire_id" to "Fragebogen-ID",
"status" to "Status",
"id" to "ID",
"value" to "Wert",
"no_questionnaires" to "Keine Fragebögen vorhanden.",
"no_questions_available" to "Keine Fragen vorhanden.",
"question" to "Frage",
"answer" to "Antwort",
"done" to "Erledigt",
"not_done" to "Nicht erledigt",
"none" to "Keine",
"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(
@ -679,7 +730,58 @@ object LanguageManager {
"done" to "Done", "done" to "Done",
"locked" to "Locked", "locked" to "Locked",
"start" to "Start", "start" to "Start",
"points" to "Points" "points" to "Points",
"saved_pdf_csv" to "PDF and CSV were saved in the \"Downloads\" folder.",
"no_pdf_viewer" to "No PDF viewer installed.",
"save_error" to "Error while saving: {message}",
"login_required_title" to "Login required",
"username_hint" to "Username",
"password_hint" to "Password",
"login_btn" to "Login",
"exit_btn" to "Exit",
"please_username_password" to "Please enter username and password.",
"download_failed_no_local_db" to "Download failed no local database available",
"download_failed_use_offline" to "Download failed working offline with existing database",
"login_failed_with_reason" to "Login failed: {reason}",
"no_header_template_found" to "No header template found",
"login_required" to "Please log in first",
"questionnaire" to "Questionnaire",
"session_label" to "Session",
"session_dash" to "Session: —",
"hours_short" to "h",
"minutes_short" to "min",
"online" to "Online",
"offline" to "Offline",
"open_client_via_load" to "Please open the client via \"Load\".",
"database" to "Database",
"database_clients_title" to "Database Clients",
"no_clients_available" to "No clients available.",
"previous" to "Back",
"download_header" to "Download header",
"client_code" to "Client code",
"export_success_downloads" to "Export successful: Downloads/ClientHeaders.xlsx",
"export_failed" to "Export failed.",
"error" to "Error",
"client" to "Client",
"questionnaires" to "Questionnaires",
"headers" to "Headers",
"questionnaire_id" to "Questionnaire ID",
"status" to "Status",
"id" to "ID",
"value" to "Value",
"no_questionnaires" to "No questionnaires available.",
"no_questions_available" to "No questions available.",
"question" to "Question",
"answer" to "Answer",
"done" to "Done",
"not_done" to "Not done",
"none" to "None",
"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(
@ -1008,7 +1110,58 @@ object LanguageManager {
"done" to "Terminé", "done" to "Terminé",
"locked" to "Verrouillé", "locked" to "Verrouillé",
"start" to "Commencer", "start" to "Commencer",
"points" to "Points" "points" to "Points",
"saved_pdf_csv" to "Les PDF et CSV ont été enregistrés dans le dossier \"Téléchargements\".",
"no_pdf_viewer" to "Aucun lecteur PDF installé.",
"save_error" to "Erreur lors de lenregistrement : {message}",
"login_required_title" to "Connexion requise",
"username_hint" to "Nom dutilisateur",
"password_hint" to "Mot de passe",
"login_btn" to "Se connecter",
"exit_btn" to "Quitter",
"please_username_password" to "Veuillez saisir le nom dutilisateur et le mot de passe.",
"download_failed_no_local_db" to "Échec du téléchargement aucune base de données locale disponible",
"download_failed_use_offline" to "Échec du téléchargement travaillez hors ligne avec la base de données existante",
"login_failed_with_reason" to "Échec de la connexion : {reason}",
"no_header_template_found" to "Aucun modèle den-tête trouvé",
"login_required" to "Veuillez dabord vous connecter",
"questionnaire" to "Questionnaire",
"session_label" to "Session",
"session_dash" to "Session : —",
"hours_short" to "h",
"minutes_short" to "min",
"online" to "En ligne",
"offline" to "Hors ligne",
"open_client_via_load" to "Veuillez ouvrir le client via « Charger ».",
"database" to "Base de données",
"database_clients_title" to "Base de données Clients",
"no_clients_available" to "Aucun client disponible.",
"previous" to "Retour",
"download_header" to "Télécharger len-tête",
"client_code" to "Code client",
"export_success_downloads" to "Export réussi : Téléchargements/ClientHeaders.xlsx",
"export_failed" to "Échec de lexportation.",
"error" to "Erreur",
"client" to "Client",
"questionnaires" to "Questionnaires",
"headers" to "En-têtes",
"questionnaire_id" to "ID du questionnaire",
"status" to "Statut",
"id" to "ID",
"value" to "Valeur",
"no_questionnaires" to "Aucun questionnaire disponible.",
"no_questions_available" to "Aucune question disponible.",
"question" to "Question",
"answer" to "Réponse",
"done" to "Terminé",
"not_done" to "Non terminé",
"none" to "Aucun",
"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(
@ -1333,7 +1486,58 @@ object LanguageManager {
"done" to "Готово", "done" to "Готово",
"locked" to "Заблокировано", "locked" to "Заблокировано",
"start" to "Начать", "start" to "Начать",
"points" to "Баллы" "points" to "Баллы",
"saved_pdf_csv" to "PDF и CSV сохранены в папке «Загрузки».",
"no_pdf_viewer" to "Не установлен просмотрщик PDF.",
"save_error" to "Ошибка при сохранении: {message}",
"login_required_title" to "Требуется вход",
"username_hint" to "Имя пользователя",
"password_hint" to "Пароль",
"login_btn" to "Войти",
"exit_btn" to "Выход",
"please_username_password" to "Введите имя пользователя и пароль.",
"download_failed_no_local_db" to "Сбой загрузки локальная база данных отсутствует",
"download_failed_use_offline" to "Сбой загрузки работа офлайн с имеющейся базой данных",
"login_failed_with_reason" to "Не удалось войти: {reason}",
"no_header_template_found" to "Шаблон заголовков не найден",
"login_required" to "Сначала выполните вход",
"questionnaire" to "Опросник",
"session_label" to "Сессия",
"session_dash" to "Сессия: —",
"hours_short" to "ч",
"minutes_short" to "м",
"online" to "В сети",
"offline" to "Не в сети",
"open_client_via_load" to "Откройте клиента через «Загрузить».",
"database" to "База данных",
"database_clients_title" to "База данных клиенты",
"no_clients_available" to "Клиенты отсутствуют.",
"previous" to "Назад",
"download_header" to "Скачать заголовки",
"client_code" to "Код клиента",
"export_success_downloads" to "Экспорт выполнен: Загрузки/ClientHeaders.xlsx",
"export_failed" to "Ошибка экспорта.",
"error" to "Ошибка",
"client" to "Клиент",
"questionnaires" to "Опросники",
"headers" to "Заголовки",
"questionnaire_id" to "ID опросника",
"status" to "Статус",
"id" to "ID",
"value" to "Значение",
"no_questionnaires" to "Нет доступных опросников.",
"no_questions_available" to "Нет вопросов.",
"question" to "Вопрос",
"answer" to "Ответ",
"done" to "Готово",
"not_done" to "Не выполнено",
"none" to "Нет",
"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(
@ -1662,7 +1866,58 @@ object LanguageManager {
"done" to "Завершено", "done" to "Завершено",
"locked" to "Заблоковано", "locked" to "Заблоковано",
"start" to "Почати", "start" to "Почати",
"points" to "Бали" "points" to "Бали",
"saved_pdf_csv" to "PDF і CSV збережено в папці «Завантаження».",
"no_pdf_viewer" to "Не встановлено переглядач PDF.",
"save_error" to "Помилка збереження: {message}",
"login_required_title" to "Потрібен вхід",
"username_hint" to "Ім’я користувача",
"password_hint" to "Пароль",
"login_btn" to "Увійти",
"exit_btn" to "Вийти",
"please_username_password" to "Введіть ім’я користувача та пароль.",
"download_failed_no_local_db" to "Завантаження не вдалося — немає локальної бази даних",
"download_failed_use_offline" to "Завантаження не вдалося — працюйте офлайн з наявною базою даних",
"login_failed_with_reason" to "Не вдалося увійти: {reason}",
"no_header_template_found" to "Шаблон заголовків не знайдено",
"login_required" to "Спочатку увійдіть",
"questionnaire" to "Анкета",
"session_label" to "Сесія",
"session_dash" to "Сесія: —",
"hours_short" to "год",
"minutes_short" to "хв",
"online" to "Онлайн",
"offline" to "Офлайн",
"open_client_via_load" to "Відкрийте клієнта через «Завантажити».",
"database" to "База даних",
"database_clients_title" to "База даних клієнти",
"no_clients_available" to "Немає клієнтів.",
"previous" to "Назад",
"download_header" to "Завантажити заголовки",
"client_code" to "Код клієнта",
"export_success_downloads" to "Експорт виконано: Завантаження/ClientHeaders.xlsx",
"export_failed" to "Помилка експорту.",
"error" to "Помилка",
"client" to "Клієнт",
"questionnaires" to "Анкети",
"headers" to "Заголовки",
"questionnaire_id" to "ID анкети",
"status" to "Статус",
"id" to "ID",
"value" to "Значення",
"no_questionnaires" to "Немає доступних анкет.",
"no_questions_available" to "Немає запитань.",
"question" to "Питання",
"answer" to "Відповідь",
"done" to "Готово",
"not_done" to "Не виконано",
"none" to "Немає",
"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(
@ -1991,7 +2246,58 @@ object LanguageManager {
"done" to "Tamamlandı", "done" to "Tamamlandı",
"locked" to "Kilitli", "locked" to "Kilitli",
"start" to "Başlat", "start" to "Başlat",
"points" to "Puan" "points" to "Puan",
"saved_pdf_csv" to "PDF ve CSV \"İndirilenler\" klasörüne kaydedildi.",
"no_pdf_viewer" to "Yüklü bir PDF görüntüleyici yok.",
"save_error" to "Kaydetme hatası: {message}",
"login_required_title" to "Giriş gerekli",
"username_hint" to "Kullanıcı adı",
"password_hint" to "Şifre",
"login_btn" to "Giriş yap",
"exit_btn" to "Çıkış",
"please_username_password" to "Lütfen kullanıcı adı ve şifre girin.",
"download_failed_no_local_db" to "İndirme başarısız yerel veritabanı yok",
"download_failed_use_offline" to "İndirme başarısız mevcut veritabanıyla çevrimdışı çalışın",
"login_failed_with_reason" to "Giriş başarısız: {reason}",
"no_header_template_found" to "Başlık şablonu bulunamadı",
"login_required" to "Lütfen önce giriş yapın",
"questionnaire" to "Anket",
"session_label" to "Oturum",
"session_dash" to "Oturum: —",
"hours_short" to "sa",
"minutes_short" to "dk",
"online" to "Çevrimiçi",
"offline" to "Çevrimdışı",
"open_client_via_load" to "Lütfen müşteriyi \"Yükle\" ile açın.",
"database" to "Veritabanı",
"database_clients_title" to "Veritabanı Müşteriler",
"no_clients_available" to "Kullanılabilir müşteri yok.",
"previous" to "Geri",
"download_header" to "Başlığı indir",
"client_code" to "Müşteri kodu",
"export_success_downloads" to "Dışa aktarma başarılı: İndirilenler/ClientHeaders.xlsx",
"export_failed" to "Dışa aktarma başarısız.",
"error" to "Hata",
"client" to "Müşteri",
"questionnaires" to "Anketler",
"headers" to "Başlıklar",
"questionnaire_id" to "Anket ID",
"status" to "Durum",
"id" to "ID",
"value" to "Değer",
"no_questionnaires" to "Mevcut anket yok.",
"no_questions_available" to "Soru yok.",
"question" to "Soru",
"answer" to "Cevap",
"done" to "Tamamlandı",
"not_done" to "Tamamlanmadı",
"none" to "Yok",
"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(
@ -2320,7 +2626,58 @@ object LanguageManager {
"done" to "Gotowe", "done" to "Gotowe",
"locked" to "Zablokowane", "locked" to "Zablokowane",
"start" to "Rozpocznij", "start" to "Rozpocznij",
"points" to "Punkty" "points" to "Punkty",
"saved_pdf_csv" to "Pliki PDF i CSV zapisano w folderze \"Pobrane\".",
"no_pdf_viewer" to "Brak zainstalowanej przeglądarki PDF.",
"save_error" to "Błąd zapisu: {message}",
"login_required_title" to "Wymagane logowanie",
"username_hint" to "Nazwa użytkownika",
"password_hint" to "Hasło",
"login_btn" to "Zaloguj",
"exit_btn" to "Zakończ",
"please_username_password" to "Wprowadź nazwę użytkownika i hasło.",
"download_failed_no_local_db" to "Pobieranie nieudane brak lokalnej bazy danych",
"download_failed_use_offline" to "Pobieranie nieudane pracuj offline z istniejącą bazą danych",
"login_failed_with_reason" to "Logowanie nie powiodło się: {reason}",
"no_header_template_found" to "Nie znaleziono szablonu nagłówków",
"login_required" to "Najpierw się zaloguj",
"questionnaire" to "Kwestionariusz",
"session_label" to "Sesja",
"session_dash" to "Sesja: —",
"hours_short" to "h",
"minutes_short" to "min",
"online" to "Online",
"offline" to "Offline",
"open_client_via_load" to "Otwórz klienta przez „Wczytaj”.",
"database" to "Baza danych",
"database_clients_title" to "Baza danych Klienci",
"no_clients_available" to "Brak dostępnych klientów.",
"previous" to "Wstecz",
"download_header" to "Pobierz nagłówki",
"client_code" to "Kod klienta",
"export_success_downloads" to "Eksport zakończony: Pobrane/ClientHeaders.xlsx",
"export_failed" to "Eksport nieudany.",
"error" to "Błąd",
"client" to "Klient",
"questionnaires" to "Kwestionariusze",
"headers" to "Nagłówki",
"questionnaire_id" to "ID kwestionariusza",
"status" to "Status",
"id" to "ID",
"value" to "Wartość",
"no_questionnaires" to "Brak dostępnych kwestionariuszy.",
"no_questions_available" to "Brak pytań.",
"question" to "Pytanie",
"answer" to "Odpowiedź",
"done" to "Zrobione",
"not_done" to "Niezrobione",
"none" to "Brak",
"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(
@ -2649,7 +3006,58 @@ object LanguageManager {
"done" to "تم", "done" to "تم",
"locked" to "مقفل", "locked" to "مقفل",
"start" to "ابدأ", "start" to "ابدأ",
"points" to "النقاط" "points" to "النقاط",
"saved_pdf_csv" to "تم حفظ ملفات PDF وCSV في مجلد \"التنزيلات\".",
"no_pdf_viewer" to "لا يوجد عارض PDF مثبت.",
"save_error" to "خطأ أثناء الحفظ: {message}",
"login_required_title" to "يلزم تسجيل الدخول",
"username_hint" to "اسم المستخدم",
"password_hint" to "كلمة المرور",
"login_btn" to "تسجيل الدخول",
"exit_btn" to "إنهاء",
"please_username_password" to "يرجى إدخال اسم المستخدم وكلمة المرور.",
"download_failed_no_local_db" to "فشل التنزيل — لا توجد قاعدة بيانات محلية",
"download_failed_use_offline" to "فشل التنزيل — العمل بدون اتصال باستخدام قاعدة البيانات الحالية",
"login_failed_with_reason" to "فشل تسجيل الدخول: {reason}",
"no_header_template_found" to "لم يتم العثور على قالب للرؤوس",
"login_required" to "يرجى تسجيل الدخول أولاً",
"questionnaire" to "استبيان",
"session_label" to "الجلسة",
"session_dash" to "الجلسة: —",
"hours_short" to "س",
"minutes_short" to "د",
"online" to "متصل",
"offline" to "غير متصل",
"open_client_via_load" to "يرجى فتح العميل عبر «تحميل».",
"database" to "قاعدة البيانات",
"database_clients_title" to "قاعدة البيانات العملاء",
"no_clients_available" to "لا يوجد عملاء.",
"previous" to "رجوع",
"download_header" to "تنزيل الرؤوس",
"client_code" to "رمز العميل",
"export_success_downloads" to "تم التصدير بنجاح: التنزيلات/ClientHeaders.xlsx",
"export_failed" to "فشل التصدير.",
"error" to "خطأ",
"client" to "عميل",
"questionnaires" to "استبيانات",
"headers" to "رؤوس",
"questionnaire_id" to "معرّف الاستبيان",
"status" to "الحالة",
"id" to "المعرّف",
"value" to "القيمة",
"no_questionnaires" to "لا توجد استبيانات متاحة.",
"no_questions_available" to "لا توجد أسئلة.",
"question" to "سؤال",
"answer" to "إجابة",
"done" to "منجز",
"not_done" to "غير منجز",
"none" to "لا شيء",
"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(
@ -2978,7 +3386,58 @@ object LanguageManager {
"done" to "Finalizat", "done" to "Finalizat",
"locked" to "Blocat", "locked" to "Blocat",
"start" to "Începe", "start" to "Începe",
"points" to "Puncte" "points" to "Puncte",
"saved_pdf_csv" to "PDF și CSV au fost salvate în folderul „Descărcări”.",
"no_pdf_viewer" to "Nu este instalat niciun vizualizator PDF.",
"save_error" to "Eroare la salvare: {message}",
"login_required_title" to "Autentificare necesară",
"username_hint" to "Nume utilizator",
"password_hint" to "Parolă",
"login_btn" to "Autentificare",
"exit_btn" to "Ieșire",
"please_username_password" to "Introduceți numele de utilizator și parola.",
"download_failed_no_local_db" to "Descărcare eșuată nu există bază de date locală",
"download_failed_use_offline" to "Descărcare eșuată lucrați offline cu baza de date existentă",
"login_failed_with_reason" to "Autentificarea a eșuat: {reason}",
"no_header_template_found" to "Nu s-a găsit șablon de antet",
"login_required" to "Conectați-vă mai întâi",
"questionnaire" to "Chestionar",
"session_label" to "Sesiune",
"session_dash" to "Sesiune: —",
"hours_short" to "h",
"minutes_short" to "min",
"online" to "Online",
"offline" to "Offline",
"open_client_via_load" to "Deschideți clientul prin „Încărcare”.",
"database" to "Bază de date",
"database_clients_title" to "Bază de date Clienți",
"no_clients_available" to "Nu există clienți.",
"previous" to "Înapoi",
"download_header" to "Descarcă antetul",
"client_code" to "Cod client",
"export_success_downloads" to "Export reușit: Descărcări/ClientHeaders.xlsx",
"export_failed" to "Export eșuat.",
"error" to "Eroare",
"client" to "Client",
"questionnaires" to "Chestionare",
"headers" to "Antete",
"questionnaire_id" to "ID chestionar",
"status" to "Stare",
"id" to "ID",
"value" to "Valoare",
"no_questionnaires" to "Nu există chestionare disponibile.",
"no_questions_available" to "Nu există întrebări.",
"question" to "Întrebare",
"answer" to "Răspuns",
"done" to "Finalizat",
"not_done" to "Nefinalizat",
"none" to "Nimic",
"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(
@ -3307,7 +3766,58 @@ object LanguageManager {
"done" to "Completado", "done" to "Completado",
"locked" to "Bloqueado", "locked" to "Bloqueado",
"start" to "Iniciar", "start" to "Iniciar",
"points" to "Puncte" "points" to "Puncte",
"saved_pdf_csv" to "Los archivos PDF y CSV se han guardado en la carpeta \"Descargas\".",
"no_pdf_viewer" to "No hay un visor de PDF instalado.",
"save_error" to "Error al guardar: {message}",
"login_required_title" to "Se requiere inicio de sesión",
"username_hint" to "Nombre de usuario",
"password_hint" to "Contraseña",
"login_btn" to "Iniciar sesión",
"exit_btn" to "Salir",
"please_username_password" to "Introduce nombre de usuario y contraseña.",
"download_failed_no_local_db" to "La descarga falló: no hay base de datos local",
"download_failed_use_offline" to "La descarga falló: trabaja sin conexión con la base de datos existente",
"login_failed_with_reason" to "Error de inicio de sesión: {reason}",
"no_header_template_found" to "No se encontró una plantilla de encabezados",
"login_required" to "Inicia sesión primero",
"questionnaire" to "Cuestionario",
"session_label" to "Sesión",
"session_dash" to "Sesión: —",
"hours_short" to "h",
"minutes_short" to "min",
"online" to "En línea",
"offline" to "Sin conexión",
"open_client_via_load" to "Abre el cliente mediante «Cargar».",
"database" to "Base de datos",
"database_clients_title" to "Base de datos Clientes",
"no_clients_available" to "No hay clientes disponibles.",
"previous" to "Atrás",
"download_header" to "Descargar encabezados",
"client_code" to "Código de cliente",
"export_success_downloads" to "Exportación correcta: Descargas/ClientHeaders.xlsx",
"export_failed" to "La exportación falló.",
"error" to "Error",
"client" to "Cliente",
"questionnaires" to "Cuestionarios",
"headers" to "Encabezados",
"questionnaire_id" to "ID del cuestionario",
"status" to "Estado",
"id" to "ID",
"value" to "Valor",
"no_questionnaires" to "No hay cuestionarios disponibles.",
"no_questions_available" to "No hay preguntas.",
"question" to "Pregunta",
"answer" to "Respuesta",
"done" to "Completado",
"not_done" to "No completado",
"none" to "Ninguno",
"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

@ -1,11 +1,13 @@
package com.dano.test1 package com.dano.test1
import android.app.AlertDialog
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import kotlinx.coroutines.CoroutineScope import android.view.LayoutInflater
import kotlinx.coroutines.Dispatchers import android.widget.EditText
import kotlinx.coroutines.launch import android.widget.LinearLayout
import kotlinx.coroutines.withContext import android.widget.Toast
import kotlinx.coroutines.*
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
@ -13,7 +15,8 @@ import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject import org.json.JSONObject
object LoginManager { object LoginManager {
private const val SERVER_LOGIN_URL = "http://49.13.157.44/login.php" 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() private val client = OkHttpClient()
fun loginUserWithCredentials( fun loginUserWithCredentials(
@ -39,23 +42,177 @@ object LoginManager {
val response = client.newCall(request).execute() val response = client.newCall(request).execute()
val text = response.body?.string() val text = response.body?.string()
if (response.isSuccessful && text != null) { 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})") } 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) { } catch (e: Exception) {
Log.e("LOGIN", "Exception", e) Log.e("LOGIN", "Exception", e)
withContext(Dispatchers.Main) { onError("Exception: ${e.message}") } 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 = android.text.InputType.TYPE_CLASS_TEXT or
android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD
}
val etRepeat = EditText(context).apply {
hint = "Neues Passwort (wiederholen)"
inputType = android.text.InputType.TYPE_CLASS_TEXT or
android.text.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

@ -27,6 +27,10 @@ class MainActivity : AppCompatActivity() {
// LIVE: Network-Callback (optional für Statusleiste) // LIVE: Network-Callback (optional für Statusleiste)
private var netCb: ConnectivityManager.NetworkCallback? = null private var netCb: ConnectivityManager.NetworkCallback? = null
// Wir kennen hier (vor dem OpeningScreen) noch keine Nutzerwahl → Deutsch als Startsprache.
private val bootLanguageId: String get() = "GERMAN"
private fun t(key: String): String = LanguageManager.getText(bootLanguageId, key)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -57,11 +61,11 @@ class MainActivity : AppCompatActivity() {
setPadding(dp(20), dp(8), dp(20), 0) setPadding(dp(20), dp(8), dp(20), 0)
} }
val etUser = EditText(this).apply { val etUser = EditText(this).apply {
hint = "Username" hint = t("username_hint")
setSingleLine() setSingleLine()
} }
val etPass = EditText(this).apply { val etPass = EditText(this).apply {
hint = "Passwort" hint = t("password_hint")
setSingleLine() setSingleLine()
inputType = android.text.InputType.TYPE_CLASS_TEXT or inputType = android.text.InputType.TYPE_CLASS_TEXT or
android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD
@ -70,14 +74,14 @@ class MainActivity : AppCompatActivity() {
container.addView(etPass) container.addView(etPass)
val dialog = AlertDialog.Builder(this) val dialog = AlertDialog.Builder(this)
.setTitle("Login erforderlich") .setTitle(t("login_required_title"))
.setView(container) .setView(container)
.setCancelable(false) .setCancelable(false)
.setPositiveButton("Login") { _, _ -> .setPositiveButton(t("login_btn")) { _, _ ->
val user = etUser.text.toString().trim() val user = etUser.text.toString().trim()
val pass = etPass.text.toString() val pass = etPass.text.toString()
if (user.isEmpty() || pass.isEmpty()) { if (user.isEmpty() || pass.isEmpty()) {
Toast.makeText(this, "Bitte Username & Passwort eingeben", Toast.LENGTH_SHORT).show() Toast.makeText(this, t("please_username_password"), Toast.LENGTH_SHORT).show()
showLoginThenDownload() showLoginThenDownload()
return@setPositiveButton return@setPositiveButton
} }
@ -97,14 +101,14 @@ class MainActivity : AppCompatActivity() {
// Wenn Download fehlgeschlagen ist, aber evtl. schon eine DB lokal liegt, // Wenn Download fehlgeschlagen ist, aber evtl. schon eine DB lokal liegt,
// lassen wir den Nutzer trotzdem weiterarbeiten (Offline). // lassen wir den Nutzer trotzdem weiterarbeiten (Offline).
if (!ok && !hasLocalDb()) { if (!ok && !hasLocalDb()) {
Toast.makeText(this, "Download fehlgeschlagen keine lokale Datenbank vorhanden", Toast.LENGTH_LONG).show() Toast.makeText(this, t("download_failed_no_local_db"), Toast.LENGTH_LONG).show()
// Zurück zum Login, damit man es erneut probieren kann // Zurück zum Login, damit man es erneut probieren kann
showLoginThenDownload() showLoginThenDownload()
return@downloadAndReplaceDatabase return@downloadAndReplaceDatabase
} }
if (!ok) { if (!ok) {
Toast.makeText(this, "Download fehlgeschlagen arbeite offline mit vorhandener DB", Toast.LENGTH_LONG).show() Toast.makeText(this, t("download_failed_use_offline"), Toast.LENGTH_LONG).show()
} }
// Opening-Screen starten // Opening-Screen starten
@ -115,12 +119,13 @@ class MainActivity : AppCompatActivity() {
}, },
onError = { msg -> onError = { msg ->
showBusy(false) showBusy(false)
Toast.makeText(this, msg, Toast.LENGTH_LONG).show() val txt = t("login_failed_with_reason").replace("{reason}", msg ?: "")
Toast.makeText(this, txt, Toast.LENGTH_LONG).show()
showLoginThenDownload() showLoginThenDownload()
} }
) )
} }
.setNegativeButton("Beenden") { _, _ -> finishAffinity() } .setNegativeButton(t("exit_btn")) { _, _ -> finishAffinity() }
.create() .create()
dialog.show() dialog.show()

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

@ -4,6 +4,20 @@ import android.content.Context
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities 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 { object NetworkUtils {
fun isOnline(context: Context): Boolean { fun isOnline(context: Context): Boolean {
return try { return try {

View File

@ -98,7 +98,7 @@ class SaveButtonHandler(
pdfDocument.finishPage(page) pdfDocument.finishPage(page)
} }
Log.d("CSV_OUTPUT", "Generated CSV:\n${csvBuilder.toString()}") Log.d("CSV_OUTPUT", "Generated CSV:\n${csvBuilder}")
val pdfFileName = "DatabaseOutput_${actualClientCode}.pdf" val pdfFileName = "DatabaseOutput_${actualClientCode}.pdf"
val csvFileName = "DatabaseOutput_${actualClientCode}.csv" val csvFileName = "DatabaseOutput_${actualClientCode}.csv"
@ -108,12 +108,17 @@ class SaveButtonHandler(
val projection = arrayOf(android.provider.MediaStore.MediaColumns._ID) val projection = arrayOf(android.provider.MediaStore.MediaColumns._ID)
val selection = "${android.provider.MediaStore.MediaColumns.DISPLAY_NAME} = ?" val selection = "${android.provider.MediaStore.MediaColumns.DISPLAY_NAME} = ?"
val selectionArgs = arrayOf(name) val selectionArgs = arrayOf(name)
val query = resolver.query(android.provider.MediaStore.Downloads.EXTERNAL_CONTENT_URI, projection, selection, selectionArgs, null) val query = resolver.query(
android.provider.MediaStore.Downloads.EXTERNAL_CONTENT_URI,
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(android.provider.MediaStore.MediaColumns._ID)
val id = cursor.getLong(idColumn) val id = cursor.getLong(idColumn)
val deleteUri = android.content.ContentUris.withAppendedId(android.provider.MediaStore.Downloads.EXTERNAL_CONTENT_URI, id) val deleteUri = android.content.ContentUris.withAppendedId(
android.provider.MediaStore.Downloads.EXTERNAL_CONTENT_URI, id
)
resolver.delete(deleteUri, null, null) resolver.delete(deleteUri, null, null)
} }
} }
@ -153,7 +158,8 @@ class SaveButtonHandler(
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
Toast.makeText(activity, "PDF und CSV gespeichert in Downloads", Toast.LENGTH_LONG).show() val msg = LanguageManager.getText(languageIDProvider(), "saved_pdf_csv")
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 = android.content.Intent(android.content.Intent.ACTION_VIEW).apply {
@ -163,14 +169,17 @@ class SaveButtonHandler(
try { try {
activity.startActivity(intent) activity.startActivity(intent)
} catch (e: android.content.ActivityNotFoundException) { } catch (e: android.content.ActivityNotFoundException) {
Toast.makeText(activity, "Kein PDF-Viewer installiert", Toast.LENGTH_SHORT).show() val noViewer = LanguageManager.getText(languageIDProvider(), "no_pdf_viewer")
Toast.makeText(activity, noViewer, Toast.LENGTH_SHORT).show()
} }
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e("SAVE", "Fehler beim Speichern der Dateien", e) Log.e("SAVE", "Fehler beim Speichern der Dateien", e)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
Toast.makeText(activity, "Fehler beim Speichern: ${e.message}", Toast.LENGTH_LONG).show() val errTpl = LanguageManager.getText(languageIDProvider(), "save_error")
val msg = (errTpl ?: "Fehler beim Speichern: {message}").replace("{message}", e.message ?: "")
Toast.makeText(activity, msg, Toast.LENGTH_LONG).show()
} }
} }
} }

View File

@ -2,20 +2,32 @@ package com.dano.test1
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

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

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

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