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
import java.io.File
@ -11,7 +10,6 @@ import kotlin.math.min
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 {
val ikm = hexToBytes(tokenHex)
val mac = Mac.getInstance("HmacSHA256")

View File

@ -3,6 +3,18 @@ package com.dano.test1.data
import androidx.room.Database
import androidx.room.RoomDatabase
/*
Zentrale Room-Datenbank der App. Diese Klasse beschreibt:
- welche Tabellen (entities) es gibt: Client, Questionnaire, Question, Answer, CompletedQuestionnaire
- die Datenbank-Version (version = 1) für Migrations/Schema-Updates
Über die abstrakten DAO-Getter (clientDao(), questionnaireDao(), …) erhält der Rest der App Typsichere Zugriffe auf die jeweiligen Tabellen.
Hinweis:
- Room erzeugt zur Build-Zeit die konkrete Implementierung dieser abstrakten Klasse.
- Eine Instanz der Datenbank wird typischerweise per Room.databaseBuilder(...) erstellt und als Singleton verwendet.
*/
@Database(
entities = [
Client::class,

View File

@ -2,8 +2,18 @@ package com.dano.test1.data
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
interface ClientDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertClient(client: Client)
@ -20,9 +30,9 @@ interface ClientDao {
suspend fun getAllClients(): List<Client>
}
@Dao
interface QuestionnaireDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertQuestionnaire(questionnaire: Questionnaire)
@ -30,11 +40,12 @@ interface QuestionnaireDao {
suspend fun getById(id: String): Questionnaire?
@Query("SELECT * FROM questionnaires")
suspend fun getAll(): List<Questionnaire> // <-- NEU
suspend fun getAll(): List<Questionnaire>
}
@Dao
interface QuestionDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertQuestions(questions: List<Question>)
@ -48,8 +59,10 @@ interface QuestionDao {
suspend fun getQuestionsForQuestionnaire(questionnaireId: String): List<Question>
}
@Dao
interface AnswerDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAnswers(answers: List<Answer>)
@ -79,9 +92,9 @@ interface AnswerDao {
)
}
@Dao
interface CompletedQuestionnaireDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(entry: CompletedQuestionnaire)
@ -93,4 +106,4 @@ interface CompletedQuestionnaireDao {
@Query("SELECT questionnaireId FROM completed_questionnaires WHERE clientCode = :clientCode")
suspend fun getCompletedQuestionnairesForClient(clientCode: String): List<String>
}
}

View File

@ -23,7 +23,8 @@ class DatabaseButtonHandler(
private val exporter = ExcelExportService(activity, headerRepo)
fun setup() {
databaseButton.text = "Datenbank"
val lang = safeLang()
databaseButton.text = t(lang, "database") ?: "Datenbank"
databaseButton.setOnClickListener { openDatabaseScreen() }
}
@ -82,13 +83,13 @@ class DatabaseButtonHandler(
// Export: Header aller Clients als Excel
// ---------------------------
private fun onDownloadHeadersClicked(progress: ProgressBar) {
val lang = safeLang()
uiScope.launch {
try {
progress.visibility = View.VISIBLE
val savedUri = exporter.exportHeadersForAllClients()
progress.visibility = View.GONE
val lang = safeLang()
if (savedUri != null) {
Toast.makeText(
activity,
@ -101,7 +102,8 @@ class DatabaseButtonHandler(
} catch (e: Exception) {
progress.visibility = View.GONE
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")
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.setOnClickListener { openDatabaseScreen() }
@ -418,9 +420,11 @@ class DatabaseButtonHandler(
private fun <T : View> requireView(id: Int, name: String): T {
val v = activity.findViewById<T>(id)
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)
Toast.makeText(activity, "Fehlende View: $name", Toast.LENGTH_LONG).show()
Toast.makeText(activity, msg, Toast.LENGTH_LONG).show()
throw IllegalStateException(msg)
}
return v

View File

@ -14,7 +14,7 @@ import java.io.FileOutputStream
object DatabaseDownloader {
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()

View File

@ -1,4 +1,3 @@
// app/src/main/java/com/dano/test1/DatabaseUploader.kt
package com.dano.test1
import android.content.Context
@ -21,25 +20,11 @@ import kotlin.system.exitProcess
object DatabaseUploader {
private const val DB_NAME = "questionnaire_database"
private const val SERVER_DELTA_URL = "http://49.13.157.44/uploadDeltaTest5.php"
private const val SERVER_CHECK_URL = "http://49.13.157.44/checkDatabaseExists.php"
private const val SERVER_DELTA_URL = "https://daniel-ocks.de/qdb/uploadDeltaTest5.php"
private const val SERVER_CHECK_URL = "https://daniel-ocks.de/qdb/checkDatabaseExists.php"
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) {
CoroutineScope(Dispatchers.IO).launch {
try {
@ -49,6 +34,7 @@ object DatabaseUploader {
return@launch
}
// WAL sauber schließen (falls aktiv)
try {
val db = SQLiteDatabase.openDatabase(dbFile.absolutePath, null, SQLiteDatabase.OPEN_READWRITE)
db.rawQuery("PRAGMA wal_checkpoint(FULL);", null).use { /* noop */ }
@ -82,11 +68,14 @@ object DatabaseUploader {
put("questionnaires", queryToJsonArray(db, "SELECT id FROM questionnaires"))
put("questions", queryToJsonArray(db, "SELECT questionId, questionnaireId, question FROM questions"))
put("answers", queryToJsonArray(db, "SELECT clientCode, questionId, answerValue FROM answers"))
put("completed_questionnaires",
queryToJsonArray(db, "SELECT clientCode, questionnaireId, timestamp, isDone, sumPoints FROM completed_questionnaires"))
put(
"completed_questionnaires",
queryToJsonArray(db, "SELECT clientCode, questionnaireId, timestamp, isDone, sumPoints FROM completed_questionnaires")
)
}
db.close()
// JSON -> verschlüsselte Payload
val tmpJson = File(context.cacheDir, "payload.json").apply { writeText(data.toString()) }
val tmpEnc = File(context.cacheDir, "payload.enc")
try {
@ -98,11 +87,17 @@ object DatabaseUploader {
val body = MultipartBody.Builder()
.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()))
.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 {
override fun onFailure(call: Call, e: IOException) {
Log.e("UPLOAD", "Fehlgeschlagen: ${e.message}")
@ -113,7 +108,7 @@ object DatabaseUploader {
if (response.isSuccessful) {
Log.d("UPLOAD", "OK: $respBody")
// <<< alte Logik wieder aktivieren: lokale DB + Neben­dateien löschen
// alte Logik: lokale DB + Neben­dateien löschen
try {
if (!file.delete()) Log.w("UPLOAD", "Lokale DB nicht gelöscht.")
File(file.parent, "${file.name}-journal").delete()
@ -122,11 +117,12 @@ object DatabaseUploader {
} catch (e: Exception) {
Log.w("UPLOAD", "Fehler beim Löschen lokaler DB-Dateien", e)
}
// >>>
} else {
Log.e("UPLOAD", "HTTP ${response.code}: $respBody")
}
tmpJson.delete(); tmpEnc.delete()
// unverändert beibehalten
try { exitProcess(0) } catch (_: Exception) {}
}
})
@ -162,6 +158,8 @@ object DatabaseUploader {
}
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
if (needLoad) {
// Zwischenzustände aus dem Load-Handler unterdrücken
setUiFreeze(true)
setUiFreeze(true) // Zwischenzustände unterdrücken
triggerLoad()
}
@ -51,7 +50,8 @@ class EditButtonHandler(
val loadedOk = waitUntilClientLoaded(desiredCode, timeoutMs = 2500, stepMs = 50)
if (!loadedOk) {
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)
}
return@launch
@ -70,7 +70,6 @@ class EditButtonHandler(
}
withContext(Dispatchers.Main) {
// nur den finalen Zustand anzeigen
updateButtonTexts()
val enabledButtons = questionnaireFiles.filter { (_, fileName) ->
completedFiles.any { completedId -> fileName.lowercase().contains(completedId) }

View File

@ -2,16 +2,41 @@ package com.dano.test1.data
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")
data class Client(
@PrimaryKey val clientCode: String,
)
/* Tabelle: questionnaires Eindeutige Fragebogen-IDs. */
@Entity(tableName = "questionnaires")
data class Questionnaire(
@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(
tableName = "questions",
foreignKeys = [
@ -30,6 +55,12 @@ data class Question(
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(
tableName = "answers",
primaryKeys = ["clientCode", "questionId"],
@ -55,6 +86,17 @@ data class Answer(
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(
tableName = "completed_questionnaires",
primaryKeys = ["clientCode", "questionnaireId"],
@ -81,4 +123,3 @@ data class CompletedQuestionnaire(
val isDone: Boolean,
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.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(
private val context: Context,
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? {
val orderedIds = headerRepo.loadOrderedIds()
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? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val resolver = context.contentResolver
@ -108,7 +124,6 @@ class ExcelExportService(
}
}
// ---------- Export-spezifische Lokalisierung (EN) ----------
private suspend fun englishQuestionForId(id: String, questionnaireIdSet: Set<String>): String {
if (id == "client_code") return "Client code"
if (id in questionnaireIdSet && !id.contains('-')) return "Questionnaire status"
@ -147,7 +162,7 @@ class ExcelExportService(
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 {
if (id == "client_code") 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.withContext
/*
Zweck :
- Steuert die Eingabeseite für „Client Code“ und „Coach Code“ innerhalb des Fragebogen-Flows.
*/
class HandlerClientCoachCode(
private val answers: MutableMap<String, Any>,
private val languageID: String,
@ -48,7 +53,7 @@ class HandlerClientCoachCode(
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)
if (!coachFromLogin.isNullOrBlank()) {
coachCodeField.setText(coachFromLogin)
@ -122,7 +127,6 @@ class HandlerClientCoachCode(
override fun validate(): Boolean {
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
return clientCode.isNotBlank() && coachText.isNotBlank()
}
@ -139,7 +143,6 @@ class HandlerClientCoachCode(
// Not used
}
// --- Helfer zum Sperren inkl. optischer Markierung (wie im Opening Screen) ---
private fun lockCoachField(field: EditText) {
field.isFocusable = false
field.isFocusableInTouchMode = false

View File

@ -3,6 +3,12 @@ package com.dano.test1
import android.view.View
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(
private val answers: MutableMap<String, Any>,
private val languageID: String,
@ -14,7 +20,6 @@ class HandlerClientNotSigned(
private lateinit var layout: View
private lateinit var question: QuestionItem.ClientNotSigned
// UI components
private lateinit var textView1: TextView
private lateinit var textView2: TextView
private lateinit var questionTextView: TextView
@ -26,29 +31,24 @@ class HandlerClientNotSigned(
this.layout = layout
this.question = question
// Initialize UI components only once
initViews()
// Set localized text values from LanguageManager
textView1.text = question.textKey1?.let { LanguageManager.getText(languageID, it) } ?: ""
textView2.text = question.textKey2?.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 ?: "")
// Set click listener for Next button
layout.findViewById<Button>(R.id.Qnext).setOnClickListener {
onNextClicked()
}
// Set click listener for Previous button
layout.findViewById<Button>(R.id.Qprev).setOnClickListener {
goToPreviousQuestion()
}
}
// Initialize all views once to avoid repeated findViewById calls
private fun initViews() {
textView1 = layout.findViewById(R.id.textView1)
textView2 = layout.findViewById(R.id.textView2)
@ -56,7 +56,6 @@ class HandlerClientNotSigned(
coachCodeField = layout.findViewById(R.id.coach_code)
}
// Handle Next button click
private fun onNextClicked() {
if (validate()) {
saveAnswer()
@ -67,13 +66,11 @@ class HandlerClientNotSigned(
}
}
// Validate that coach code field is not empty
override fun validate(): Boolean {
val coachCode = coachCodeField.text
return coachCode.isNotBlank()
}
// Save entered coach code to answers map
override fun saveAnswer() {
answers[question.id] = coachCodeField.text.toString()
}

View File

@ -11,6 +11,11 @@ import android.util.TypedValue
import androidx.core.widget.TextViewCompat
import android.widget.AbsListView
/*
Zweck:
Rendert eine Datumsfrage mit drei Spinnern (Tag/Monat/Jahr) innerhalb des Fragebogen-Flows.
*/
class HandlerDateSpinner(
private val context: Context,
private val answers: MutableMap<String, Any>,
@ -46,13 +51,13 @@ class HandlerDateSpinner(
questionTextView.text = question.question?.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(questionTextView, 0.03f) // frage
setTextSizePercentOfScreenHeight(labelDay, 0.025f)
setTextSizePercentOfScreenHeight(labelMonth, 0.025f)
setTextSizePercentOfScreenHeight(labelYear, 0.025f)
// ————————————————————————————————
//
// gespeicherte Antwort (YYYY-MM-DD) lesen
val (savedYear, savedMonthIndex, savedDay) = question.question?.let {
@ -202,7 +207,7 @@ class HandlerDateSpinner(
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) {
val dm = (view.context ?: layout.context).resources.displayMetrics
val sp = (dm.heightPixels * percentOfHeight) / dm.scaledDensity
@ -210,7 +215,7 @@ class HandlerDateSpinner(
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?) {
val dm = context.resources.displayMetrics
@ -246,13 +251,13 @@ class HandlerDateSpinner(
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val v = super.getView(position, convertView, parent) as TextView
styleRow(v, forceHeight = false) // ausgewählte Ansicht
styleRow(v, forceHeight = false)
return v
}
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
val v = super.getDropDownView(position, convertView, parent) as TextView
styleRow(v, forceHeight = true) // Dropdown-Zeilen: Höhe erzwingen
styleRow(v, forceHeight = true)
return v
}
}
@ -260,7 +265,6 @@ class HandlerDateSpinner(
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
spinner.adapter = adapter
// Spinner selbst ausreichend hoch
spinner.setPadding(spinner.paddingLeft, vPadPx, spinner.paddingRight, vPadPx)
spinner.minimumHeight = rowHeight
spinner.requestLayout()

View File

@ -8,6 +8,14 @@ import android.widget.*
import androidx.core.widget.TextViewCompat
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(
private val context: Context,
private val answers: MutableMap<String, Any>,
@ -60,7 +68,7 @@ class HandlerGlassScaleQuestion(
setTextSizePercentOfScreenHeight(titleTv, 0.03f)
setTextSizePercentOfScreenHeight(questionTv, 0.03f)
// ----- feste Icon-Leiste -----
// feste Icon-Leiste
val header = layout.findViewById<LinearLayout>(R.id.glass_header)
header.removeAllViews()
header.addView(Space(context).apply {
@ -80,7 +88,7 @@ class HandlerGlassScaleQuestion(
cell.addView(img)
header.addView(cell)
}
// -----------------------------
//
val tableLayout = layout.findViewById<TableLayout>(R.id.glass_table)
tableLayout.removeAllViews()
@ -154,21 +162,26 @@ class HandlerGlassScaleQuestion(
val radioGroup = RadioGroup(context).apply {
orientation = RadioGroup.HORIZONTAL
layoutParams = TableRow.LayoutParams(0, TableRow.LayoutParams.WRAP_CONTENT, 5f)
setPadding(0, 0, 0, 0)
}
// WICHTIG: RadioButtons sind direkte Kinder des RadioGroup!
scaleLabels.forEach { labelKey ->
val cell = FrameLayout(context).apply {
layoutParams = RadioGroup.LayoutParams(0, RadioGroup.LayoutParams.WRAP_CONTENT, 1f)
}
val rb = RadioButton(context).apply {
tag = labelKey
id = View.generateViewId()
isChecked = savedLabel == labelKey
setPadding(0, 0, 0, 0)
}
val lp = RadioGroup.LayoutParams(
0, RadioGroup.LayoutParams.WRAP_CONTENT, 1f
).apply { gravity = Gravity.CENTER }
rb.layoutParams = lp
radioGroup.addView(rb)
rb.layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
Gravity.CENTER
)
cell.addView(rb)
radioGroup.addView(cell)
}
row.addView(radioGroup)
@ -176,6 +189,7 @@ class HandlerGlassScaleQuestion(
}
}
override fun validate(): Boolean {
val table = layout.findViewById<TableLayout>(R.id.glass_table)
for (i in 0 until table.childCount) {
@ -215,7 +229,6 @@ class HandlerGlassScaleQuestion(
}
}
// --- Helpers ---
private fun getRadioFromChild(child: View): RadioButton? =
when (child) {
is RadioButton -> child

View File

@ -9,6 +9,21 @@ import android.widget.TextView
import androidx.core.widget.TextViewCompat
import com.google.android.material.button.MaterialButton
/*
Zweck:
- Steuert die letzte Seite eines Fragebogens.
- Zeigt Abschlusstexte an, speichert alle gesammelten Antworten in die lokale DB und beendet anschließend den Fragebogen und kehrt zur übergeordneten Ansicht zurück.
Beim Klick auf „Speichern“:
- Ladezustand anzeigen (ProgressBar), Buttons deaktivieren.
- Antworten asynchron in Room-DB persistieren (über `saveAnswersToDatabase`).
- Punktsumme ermitteln und in `GlobalValues.INTEGRATION_INDEX` schreiben.
- `client_code` (falls vorhanden) als `GlobalValues.LAST_CLIENT_CODE` merken.
- Mindestens 2 Sekunden „Loading“-Dauer sicherstellen (ruhiges UX).
- Zurück auf den Main-Thread wechseln, UI entsperren und Fragebogen schließen.
*/
class HandlerLastPage(
private val answers: Map<String, Any>,
private val languageID: String,
@ -19,7 +34,7 @@ class HandlerLastPage(
private lateinit var currentQuestion: QuestionItem.LastPage
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) {
this.layout = layout
@ -61,27 +76,31 @@ class HandlerLastPage(
// Punkte summieren
GlobalValues.INTEGRATION_INDEX = sumPoints()
// Client-Code merken
// Client-Code merken (für Auto-Laden im Opening Screen)
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
if (elapsedTime < minLoadingTimeMs) delay(minLoadingTimeMs - elapsedTime)
withContext(Dispatchers.Main) {
showLoading(false)
val activity = layout.context as? MainActivity
// Zurück zum Opening Screen der lädt dann automatisch (siehe Änderung 2)
activity?.finishQuestionnaire() ?: goToNextQuestion()
}
}
}
}
override fun validate(): Boolean = true
override fun saveAnswer() {}
// ---------- Responsive Textgröße für den Finish-Button ----------
private fun applyResponsiveTextSizing(btn: MaterialButton) {
// Max-/Min-Sp anhand der Bildschirmhöhe (in sp) berechnen
val dm = btn.resources.displayMetrics

View File

@ -7,6 +7,11 @@ import kotlinx.coroutines.*
import android.util.TypedValue
import androidx.core.widget.TextViewCompat
/*
Zweck:
- Steuert eine Frage mit mehreren auswählbaren Antwortoptionen (Checkboxen).
*/
class HandlerMultiCheckboxQuestion(
private val context: Context,
private val answers: MutableMap<String, Any>,
@ -15,7 +20,7 @@ class HandlerMultiCheckboxQuestion(
private val goToNextQuestion: () -> Unit,
private val goToPreviousQuestion: () -> 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 {
private lateinit var layout: View
@ -29,14 +34,12 @@ class HandlerMultiCheckboxQuestion(
val questionTitle = layout.findViewById<TextView>(R.id.question)
val questionTextView = layout.findViewById<TextView>(R.id.textView)
// Texte setzen
questionTextView.text = this.question.textKey?.let { LanguageManager.getText(languageID, it) } ?: ""
questionTitle.text = this.question.question?.let { LanguageManager.getText(languageID, it) } ?: ""
// ===== Textgrößen pro Bildschirmhöhe (wie bei deinen anderen Handlern) =====
// Textgrößen pro Bildschirmhöhe (wie bei deinen anderen Handlern)
setTextSizePercentOfScreenHeight(questionTextView, 0.03f) // Überschrift
setTextSizePercentOfScreenHeight(questionTitle, 0.03f) // Frage
// ==========================================================================
container.removeAllViews()
@ -45,13 +48,12 @@ class HandlerMultiCheckboxQuestion(
(answers[it] as? List<*>)?.map { it.toString() }?.toSet()
} ?: emptySet()
// ——— Checkbox-Schrift & Zeilenhöhe dynamisch ableiten (kein Abschneiden) ———
// Checkbox-Schrift & Zeilenhöhe dynamisch ableiten (kein Abschneiden)
val dm = layout.resources.displayMetrics
val cbTextSp = (dm.heightPixels * 0.025f) / dm.scaledDensity // ~2.5% der Bildschirmhöhe
val cbTextPx = cbTextSp * dm.scaledDensity
val cbPadV = (cbTextPx * 0.40f).toInt()
val cbMinH = (cbTextPx * 1.60f + 2 * cbPadV).toInt()
// ----------------------------------------------------------------------------
this.question.options.forEach { option ->
val checkBox = CheckBox(context).apply {
@ -78,7 +80,7 @@ class HandlerMultiCheckboxQuestion(
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 ?: "")
if (answerMapKey.isNotBlank() && !answers.containsKey(answerMapKey)) {
CoroutineScope(Dispatchers.IO).launch {
@ -100,7 +102,7 @@ class HandlerMultiCheckboxQuestion(
cb.isChecked = parsed.contains(cb.tag.toString())
}
// answers-Map aktualisieren (Liste)
// answers-Map aktualisieren
answers[answerMapKey] = parsed.toList()
// 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> {
val trimmed = dbAnswer.trim()
// JSON-Array-like
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
val inner = trimmed.substring(1, trimmed.length - 1)
if (inner.isBlank()) return emptySet()
@ -194,7 +188,6 @@ class HandlerMultiCheckboxQuestion(
.toSet()
}
// If contains comma or semicolon
val separator = when {
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) {
val dm = (view.context ?: layout.context).resources.displayMetrics
val sp = (dm.heightPixels * percentOfHeight) / dm.scaledDensity

View File

@ -13,6 +13,8 @@ import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.util.concurrent.TimeUnit
import com.google.android.material.dialog.MaterialAlertDialogBuilder
var RHS_POINTS: Int? = null
@ -32,6 +34,8 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
private lateinit var databaseButton: Button
private lateinit var statusSession: 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 questionnaireFiles = mutableMapOf<Button, String>()
@ -63,6 +67,11 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
fun init() {
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()
loadQuestionnaireOrder()
createQuestionnaireButtons()
@ -75,12 +84,21 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
setupUploadButton()
setupDownloadButton()
setupDatabaseButtonHandler()
uiHandler.removeCallbacks(statusTicker)
updateStatusStrip()
uiHandler.post(statusTicker)
val pathExists = File("/data/data/com.dano.test1/databases/questionnaire_database").exists()
updateMainButtonsState(pathExists)
updateDownloadButtonState(pathExists)
if (pathExists && !editText.text.isNullOrBlank()) buttonLoad.performClick()
uiHandler.removeCallbacks(statusTicker)
updateStatusStrip()
applySessionAgeHighlight(System.currentTimeMillis() - TokenStore.getLoginTimestamp(activity))
uiHandler.post(statusTicker)
}
private fun bindViews() {
@ -93,7 +111,10 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
saveButton = activity.findViewById(R.id.saveButton)
editButton = activity.findViewById(R.id.editButton)
uploadButton = activity.findViewById(R.id.uploadButton)
downloadButton = activity.findViewById(R.id.downloadButton)
downloadButton.visibility = View.GONE
databaseButton = activity.findViewById(R.id.databaseButton)
statusSession = activity.findViewById(R.id.statusSession)
statusOnline = activity.findViewById(R.id.statusOnline)
@ -240,7 +261,10 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
dynamicButtons.add(btn)
questionnaireFiles[btn] = entry.file
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)
}
applyUpdateButtonTexts(force = false)
@ -248,12 +272,14 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
}
private fun restorePreviousClientCode() {
// Coach-Code (Username) setzen und Feld sperren aber NICHT mehr zurückkehren
val username = TokenStore.getUsername(activity)
if (!username.isNullOrBlank()) {
coachEditText.setText(username)
lockCoachCodeField()
return
}
// Hier den zuletzt verwendeten Client-Code einsetzen
GlobalValues.LAST_CLIENT_CODE?.let {
editText.setText(it)
GlobalValues.LOADED_CLIENT_CODE = it
@ -435,20 +461,61 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
private fun setupUploadButton() {
uploadButton.text = t("upload")
uploadButton.setOnClickListener {
val token = TokenStore.getToken(activity)
if (token.isNullOrBlank()) {
Toast.makeText(activity, t("login_required") ?: "Bitte zuerst einloggen", Toast.LENGTH_LONG).show()
return@setOnClickListener
// ZUERST bestätigen lassen
confirmUpload {
// === dein bestehender Upload-Code unverändert ===
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() {
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 {
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.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()
@ -540,10 +631,19 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
val ageMs = if (ts > 0L) (System.currentTimeMillis() - ts) else 0L
val h = TimeUnit.MILLISECONDS.toHours(ageMs)
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)
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"))
// <<< NEU: hier jeweils prüfen/markieren
applySessionAgeHighlight(ageMs)
}
fun refreshHeaderStatusLive() {
@ -563,6 +663,57 @@ class HandlerOpeningScreen(private val activity: MainActivity) {
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 androidx.core.widget.TextViewCompat // <— hinzugefügt
/*
Zweck:
- Steuert eine Einzelfrage mit genau einer auswählbaren Antwort (RadioButtons).
*/
class HandlerRadioQuestion(
private val context: Context,
private val answers: MutableMap<String, Any>,
@ -36,7 +41,7 @@ class HandlerRadioQuestion(
Html.fromHtml(LanguageManager.getText(languageID, it), Html.FROM_HTML_MODE_LEGACY)
} ?: ""
// === Schriftgrößen wie im HandlerClientCoachCode ===
//
// Titel/Frage: 3% der Bildschirmhöhe
setTextSizePercentOfScreenHeight(questionTextView, 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) {
val dm = (view.context ?: layout.context).resources.displayMetrics
val sp = (dm.heightPixels * percentOfHeight) / dm.scaledDensity

View File

@ -9,6 +9,12 @@ import android.util.TypedValue
import android.widget.TextView
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(
private val context: Context,
private val answers: MutableMap<String, Any>,
@ -16,7 +22,7 @@ class HandlerStringSpinner(
private val goToNextQuestion: () -> Unit,
private val goToPreviousQuestion: () -> Unit,
private val showToast: (String) -> Unit,
private val questionnaireMeta: String // Meta, damit dieselbe DB-ID wie in anderen Handlern gebildet wird
private val questionnaireMeta: String
) : QuestionHandler {
private lateinit var layout: View
@ -36,20 +42,19 @@ class HandlerStringSpinner(
questionTextView.text = question.question?.let { LanguageManager.getText(languageID, it) } ?: ""
textView.text = question.textKey?.let { LanguageManager.getText(languageID, it) } ?: ""
// === Textgrößen prozentual zur Bildschirmhöhe (wie im HandlerRadioQuestion) ===
// Textgrößen prozentual zur Bildschirmhöhe (wie im HandlerRadioQuestion)
setTextSizePercentOfScreenHeight(textView, 0.03f)
setTextSizePercentOfScreenHeight(questionTextView, 0.03f)
// ==============================================================================
val options = buildOptionsList()
// vorhandene Auswahl (falls vorhanden)
val savedSelection = question.question?.let { answers[it] as? String }
// Spinner aufsetzen (Schriftgröße & Zeilenhöhe dynamisch, kein Abschneiden)
// Spinner aufsetzen
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 ?: "")
if (answerMapKey.isNotBlank() && !answers.containsKey(answerMapKey)) {
CoroutineScope(Dispatchers.IO).launch {

View File

@ -8,6 +8,14 @@ import kotlinx.coroutines.*
import android.util.TypedValue
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(
private val context: Context,
private val answers: MutableMap<String, Any>,
@ -16,7 +24,7 @@ class HandlerValueSpinner(
private val goToPreviousQuestion: () -> Unit,
private val goToQuestionById: (String) -> Unit,
private val showToast: (String) -> Unit,
private val questionnaireMeta: String // neu: für die DB-Abfrage
private val questionnaireMeta: String
) : QuestionHandler {
private lateinit var layout: View
@ -35,11 +43,10 @@ class HandlerValueSpinner(
questionTextView.text = question.question?.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
setTextSizePercentOfScreenHeight(textView, 0.03f)
setTextSizePercentOfScreenHeight(questionTextView, 0.03f)
// =================================================
val prompt = LanguageManager.getText(languageID, "choose_answer")
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 }
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 ?: "")
if (answerMapKey.isNotBlank() && !answers.containsKey(answerMapKey)) {
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) {
val dm = (view.context ?: layout.context).resources.displayMetrics
val sp = (dm.heightPixels * percentOfHeight) / dm.scaledDensity
TextViewCompat.setAutoSizeTextTypeWithDefaults(view, TextViewCompat.AUTO_SIZE_TEXT_TYPE_NONE)
view.setTextSize(TypedValue.COMPLEX_UNIT_SP, sp)
}
// ————————————————————————————————————————————————————————————————
private fun <T> setupSpinner(spinner: Spinner, items: List<T>, selectedItem: T?) {
val dm = context.resources.displayMetrics

View File

@ -7,11 +7,23 @@ import org.apache.poi.xssf.usermodel.XSSFWorkbook
import org.json.JSONArray
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 var orderedIdsCache: List<String>? = null
private fun t(key: String): String = LanguageManager.getText(languageIDProvider(), key)
fun loadOrderedIds(): List<String> {
orderedIdsCache?.let { return it }
@ -33,7 +45,7 @@ class HeaderOrderRepository(private val context: Context) {
list
} catch (e: Exception) {
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()
}
}

View File

@ -354,7 +354,58 @@ object LanguageManager {
"error_generic" to "Fehler",
"not_done" to "Nicht Fertog",
"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(
@ -679,7 +730,58 @@ object LanguageManager {
"done" to "Done",
"locked" to "Locked",
"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(
@ -1008,7 +1110,58 @@ object LanguageManager {
"done" to "Terminé",
"locked" to "Verrouillé",
"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(
@ -1333,7 +1486,58 @@ object LanguageManager {
"done" to "Готово",
"locked" 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(
@ -1662,7 +1866,58 @@ object LanguageManager {
"done" to "Завершено",
"locked" 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(
@ -1991,7 +2246,58 @@ object LanguageManager {
"done" to "Tamamlandı",
"locked" to "Kilitli",
"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(
@ -2320,7 +2626,58 @@ object LanguageManager {
"done" to "Gotowe",
"locked" to "Zablokowane",
"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(
@ -2649,7 +3006,58 @@ object LanguageManager {
"done" to "تم",
"locked" 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(
@ -2978,7 +3386,58 @@ object LanguageManager {
"done" to "Finalizat",
"locked" to "Blocat",
"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(
@ -3307,7 +3766,58 @@ object LanguageManager {
"done" to "Completado",
"locked" to "Bloqueado",
"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
import android.app.AlertDialog
import android.content.Context
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import android.view.LayoutInflater
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.Toast
import kotlinx.coroutines.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
@ -13,7 +15,8 @@ import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
object LoginManager {
private const val SERVER_LOGIN_URL = "http://49.13.157.44/login.php"
private const val SERVER_LOGIN_URL = "https://daniel-ocks.de/qdb/login.php"
private const val SERVER_CHANGE_URL = "https://daniel-ocks.de/qdb/change_password.php"
private val client = OkHttpClient()
fun loginUserWithCredentials(
@ -39,23 +42,177 @@ object LoginManager {
val response = client.newCall(request).execute()
val text = response.body?.string()
if (response.isSuccessful && text != null) {
val json = JSONObject(text)
if (json.optBoolean("success")) {
val token = json.getString("token")
// => setzt auch den Login-Timestamp:
TokenStore.save(context, token, username)
withContext(Dispatchers.Main) { onSuccess(token) }
} else {
withContext(Dispatchers.Main) { onError("Login fehlgeschlagen") }
}
} else {
if (!response.isSuccessful || text == null) {
withContext(Dispatchers.Main) { onError("Fehler beim Login (${response.code})") }
return@launch
}
val json = JSONObject(text)
if (!json.optBoolean("success")) {
withContext(Dispatchers.Main) { onError(json.optString("message", "Login fehlgeschlagen")) }
return@launch
}
// Passwortwechsel erforderlich?
if (json.optBoolean("must_change_password", false)) {
withContext(Dispatchers.Main) {
showChangePasswordDialog(
context = context,
username = username,
oldPassword = password,
onChanged = { token ->
// Nach PW-Änderung direkt eingeloggt
TokenStore.save(context, token, username)
onSuccess(token)
},
onError = onError
)
}
return@launch
}
// normaler Login: Token speichern
val token = json.getString("token")
TokenStore.save(context, token, username)
withContext(Dispatchers.Main) { onSuccess(token) }
} catch (e: Exception) {
Log.e("LOGIN", "Exception", e)
withContext(Dispatchers.Main) { onError("Exception: ${e.message}") }
}
}
}
private fun showChangePasswordDialog(
context: Context,
username: String,
oldPassword: String,
onChanged: (String) -> Unit,
onError: (String) -> Unit
) {
val container = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setPadding(48, 24, 48, 0)
}
val etNew = EditText(context).apply {
hint = "Neues Passwort"
inputType = 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)
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?) {
super.onCreate(savedInstanceState)
@ -57,11 +61,11 @@ class MainActivity : AppCompatActivity() {
setPadding(dp(20), dp(8), dp(20), 0)
}
val etUser = EditText(this).apply {
hint = "Username"
hint = t("username_hint")
setSingleLine()
}
val etPass = EditText(this).apply {
hint = "Passwort"
hint = t("password_hint")
setSingleLine()
inputType = android.text.InputType.TYPE_CLASS_TEXT or
android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD
@ -70,14 +74,14 @@ class MainActivity : AppCompatActivity() {
container.addView(etPass)
val dialog = AlertDialog.Builder(this)
.setTitle("Login erforderlich")
.setTitle(t("login_required_title"))
.setView(container)
.setCancelable(false)
.setPositiveButton("Login") { _, _ ->
.setPositiveButton(t("login_btn")) { _, _ ->
val user = etUser.text.toString().trim()
val pass = etPass.text.toString()
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()
return@setPositiveButton
}
@ -97,14 +101,14 @@ class MainActivity : AppCompatActivity() {
// Wenn Download fehlgeschlagen ist, aber evtl. schon eine DB lokal liegt,
// lassen wir den Nutzer trotzdem weiterarbeiten (Offline).
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
showLoginThenDownload()
return@downloadAndReplaceDatabase
}
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
@ -115,12 +119,13 @@ class MainActivity : AppCompatActivity() {
},
onError = { msg ->
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()
}
)
}
.setNegativeButton("Beenden") { _, _ -> finishAffinity() }
.setNegativeButton(t("exit_btn")) { _, _ -> finishAffinity() }
.create()
dialog.show()

View File

@ -6,6 +6,19 @@ import androidx.room.Room
import androidx.room.RoomDatabase
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() {
companion object {
@ -16,7 +29,7 @@ class MyApp : Application() {
override fun 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(
applicationContext,
AppDatabase::class.java,

View File

@ -4,6 +4,20 @@ import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
/*
Zweck:
- Einfache Hilfsklasse, um den aktuellen Online-Status des Geräts zu prüfen.
Funktionsweise:
- `isOnline(context)` nutzt den systemweiten `ConnectivityManager`, fragt die aktive Verbindung (`activeNetwork`) ab und prüft deren `NetworkCapabilities`.
- Es wird nur dann `true` zurückgegeben, wenn:
* eine aktive Verbindung existiert und
* die Verbindung die Fähigkeit „INTERNET“ besitzt und
* die Verbindung als „VALIDATED“ gilt (vom System als funktionsfähig verifiziert).
Verwendung:
- Vo Netzwerkaufrufen (Login, Upload, Download) aufrufen, um „Offline“-Fälle frühzeitig abzufangen und nutzerfreundliche Meldungen zu zeigen.
*/
object NetworkUtils {
fun isOnline(context: Context): Boolean {
return try {

View File

@ -98,7 +98,7 @@ class SaveButtonHandler(
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 csvFileName = "DatabaseOutput_${actualClientCode}.csv"
@ -108,12 +108,17 @@ class SaveButtonHandler(
val projection = arrayOf(android.provider.MediaStore.MediaColumns._ID)
val selection = "${android.provider.MediaStore.MediaColumns.DISPLAY_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 ->
if (cursor.moveToFirst()) {
val idColumn = cursor.getColumnIndexOrThrow(android.provider.MediaStore.MediaColumns._ID)
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)
}
}
@ -153,7 +158,8 @@ class SaveButtonHandler(
}
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 {
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW).apply {
@ -163,14 +169,17 @@ class SaveButtonHandler(
try {
activity.startActivity(intent)
} 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) {
Log.e("SAVE", "Fehler beim Speichern der Dateien", e)
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
/*
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 {
private const val PREF = "qdb_prefs"
private const val KEY_TOKEN = "token"
private const val KEY_USER = "user"
private const val KEY_LOGIN_TS = "login_ts"
/** Speichert Token, Username und Login-Timestamp (jetzt) */
fun save(context: Context, token: String, username: String) {
val now = System.currentTimeMillis()
context.getSharedPreferences(PREF, Context.MODE_PRIVATE)
.edit()
.putString(KEY_TOKEN, token)
.putString(KEY_USER, username)
.putLong(KEY_LOGIN_TS, now)
.putString(KEY_TOKEN, token) // API-/Session-Token
.putString(KEY_USER, username) // angemeldeter Benutzername
.putLong(KEY_LOGIN_TS, now) // Zeitpunkt des Logins
.apply()
}

View File

@ -5,9 +5,14 @@
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity"
tools:layout_editor_absoluteX="11dp"
tools:layout_editor_absoluteY="107dp">
tools:context=".MainActivity">
<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
android:id="@+id/Qprev"
@ -27,7 +32,6 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<!-- Weiter -->
<com.google.android.material.button.MaterialButton
android:id="@+id/Qnext"
android:layout_width="@dimen/nav_btn_size"
@ -46,6 +50,34 @@
app:layout_constraintBottom_toBottomOf="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
android:id="@+id/client_code"
android:layout_width="0dp"
@ -54,7 +86,7 @@
app:layout_constraintHeight_percent="0.08"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/question"
app:layout_constraintTop_toBottomOf="@id/question"
android:layout_marginTop="16dp"
android:background="@android:drawable/edit_text"
android:ems="10"
@ -90,34 +122,4 @@
android:autoSizeMaxTextSize="36sp"
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"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="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
android:id="@+id/textView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_percent="0.15"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:layout_constraintTop_toBottomOf="@id/gTop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.051"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
<!-- Frage -->
<TextView
android:id="@+id/question"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_height="wrap_content"
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"
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_constraintTop_toBottomOf="@+id/textView"
app:layout_constraintVertical_bias="0.0"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
<TextView
@ -129,7 +132,6 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<!-- Weiter -->
<com.google.android.material.button.MaterialButton
android:id="@+id/Qnext"
android:layout_width="@dimen/nav_btn_size"

View File

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

View File

@ -5,9 +5,14 @@
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity"
tools:layout_editor_absoluteX="11dp"
tools:layout_editor_absoluteY="107dp">
tools:context=".MainActivity">
<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
android:id="@+id/Qprev"
@ -27,7 +32,6 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<!-- Weiter -->
<com.google.android.material.button.MaterialButton
android:id="@+id/Qnext"
android:layout_width="@dimen/nav_btn_size"
@ -46,7 +50,34 @@
app:layout_constraintBottom_toBottomOf="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
android:id="@+id/scrollView"
android:layout_width="0dp"
@ -56,8 +87,8 @@
android:layout_marginBottom="8dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/question"
app:layout_constraintBottom_toTopOf="@+id/Qnext"
app:layout_constraintTop_toBottomOf="@id/question"
app:layout_constraintBottom_toTopOf="@id/Qnext"
app:layout_constraintWidth_percent="0.9">
<LinearLayout
@ -68,32 +99,4 @@
android:padding="16dp" />
</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>

View File

@ -5,11 +5,15 @@
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity"
tools:layout_editor_absoluteX="11dp"
tools:layout_editor_absoluteY="107dp">
tools:context=".MainActivity">
<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
android:id="@+id/Qprev"
android:layout_width="@dimen/nav_btn_size"
@ -28,7 +32,6 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<!-- Weiter -->
<com.google.android.material.button.MaterialButton
android:id="@+id/Qnext"
android:layout_width="@dimen/nav_btn_size"
@ -47,10 +50,34 @@
app:layout_constraintBottom_toBottomOf="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
android:id="@+id/radioScroll"
android:layout_width="0dp"
@ -65,7 +92,6 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.80">
<!-- Die RadioGroup bleibt gleich, ist jetzt aber scrollfähig -->
<RadioGroup
android:id="@+id/RadioGroup"
android:layout_width="match_parent"
@ -74,33 +100,4 @@
android:padding="8dp" />
</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>

View File

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

View File

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