Compare commits

...

49 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
dcfa261c1c added offline mode and improved coachcode system 2025-09-29 13:02:00 +02:00
851676f6c3 added fixed coachcode after login. 2025-09-29 11:59:51 +02:00
cfcb689ffc added session time stamp and online/offline state 2025-09-26 12:28:11 +02:00
bf33501b69 implemented donwload upload handling with username 2025-09-23 16:49:59 +02:00
5f5c766133 added username and password for up- and download 2025-09-23 16:10:19 +02:00
91f6f77b73 überarbeiten der datenbank anzeige 2025-09-23 15:17:34 +02:00
8dc9be20a4 new apk 2025-09-21 19:57:26 +02:00
31e2abecf8 finished opening screen 2025-09-21 19:51:23 +02:00
894823f42a finished opening screen 2025-09-21 19:49:49 +02:00
1ffd09049e changed headerCard 2025-09-19 21:18:35 +02:00
0a04568a7c changed qfinish button design 2025-09-19 17:16:53 +02:00
ca8f6ca8e4 added more buttons 2025-09-19 12:22:00 +02:00
e1cf8b4926 changed next and prev button 2025-09-19 12:15:15 +02:00
8ad939db27 added glass graphik 2025-09-19 11:07:31 +02:00
ac1fbb515d languagemanager update 2025-09-18 12:02:47 +02:00
fe2b05c0fd new apk 2025-09-18 09:32:22 +02:00
7014386953 removed error: Toast.makeText(activity, "Fehlende View: $name", Toast.LENGTH_LONG).show() 2025-09-18 08:50:59 +02:00
4cf840b37a added apk 2025-09-17 13:10:31 +02:00
af9c045341 saving header in downloads 2025-09-17 12:46:23 +02:00
77742275e6 finished header and changed colors. 2025-09-16 15:51:11 +02:00
93d2fa4333 full download (excel) 2025-09-12 10:48:55 +02:00
67c11720b9 worked on header, added language manager and output (only english) 2025-09-12 10:29:38 +02:00
304cabc0d7 started adding header 2025-09-10 09:11:09 +02:00
0dfc5df878 added database feature, to check entries 2025-09-08 13:04:37 +02:00
650a3bb050 fixed wrong loading bug, fixed editing bug 2025-09-08 10:27:23 +02:00
45deee664b fixed rotate screen bug 2025-09-06 20:34:01 +02:00
6aebda0009 changed layout for every device 2025-09-06 20:18:26 +02:00
90e662a330 portrait locked 2025-09-05 09:39:09 +02:00
ed4d747798 changed font size 2025-09-05 08:56:56 +02:00
0992304e59 change aes, now more secure, beacuse no hardcode anymore 2025-09-02 13:01:41 +02:00
073f33a9bb final commit on old gitea 2025-08-29 11:51:25 +02:00
5f568f4c0e added edit mode for every important question layout 2025-08-21 11:54:59 +02:00
a803be05d5 added edit mode for string spinner 2025-08-21 11:43:00 +02:00
0a70cb78fd added edit mode for radio questions 2025-08-21 11:08:02 +02:00
ecb1f9b1a2 added consent_not_signed 2025-08-20 12:22:30 +02:00
4e9338631b improved editing offlien and online. 2025-08-20 11:17:43 +02:00
95f290b46a outsourcing functions to new classes 2025-08-19 13:53:16 +02:00
a1736d0241 added passwort for login and download, token-system 2025-08-18 16:27:11 +02:00
77 changed files with 5574 additions and 1518 deletions

View File

@ -4,10 +4,10 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-07-28T06:25:21.461295300Z">
<DropdownSelection timestamp="2025-09-29T10:52:30.282144200Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=HA218GZY" />
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\danie\.android\avd\Medium_Phone.avd" />
</handle>
</Target>
</DropdownSelection>

8
.idea/misc.xml generated
View File

@ -1,3 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
@ -6,4 +7,11 @@
<component name="ProjectType">
<option name="id" value="Android" />
</component>
<component name="VisualizationToolProject">
<option name="state">
<ProjectState>
<option name="scale" value="0.1221923828125" />
</ProjectState>
</option>
</component>
</project>

6
.idea/render.experimental.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RenderSettings">
<option name="showDecorations" value="true" />
</component>
</project>

View File

@ -13,7 +13,7 @@ android {
minSdk = 29
targetSdk = 35
versionCode = 1
versionName = "1.0"
versionName = "1.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@ -27,6 +27,24 @@ android {
)
}
}
// Apache POI bringt viele META-INF-Dateien mit hier aus dem APK ausschließen
packaging {
resources {
excludes += listOf(
"META-INF/DEPENDENCIES",
"META-INF/LICENSE",
"META-INF/LICENSE.txt",
"META-INF/NOTICE",
"META-INF/NOTICE.txt",
"META-INF/NOTICE.md",
"META-INF/LICENSE.md",
"META-INF/*.kotlin_module",
"META-INF/versions/**"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
@ -44,22 +62,31 @@ dependencies {
implementation(libs.material)
implementation(libs.androidx.activity)
implementation(libs.androidx.constraintlayout)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
implementation("com.google.code.gson:gson:2.10.1")
implementation("androidx.room:room-runtime:$room_version")
kapt("androidx.room:room-compiler:$room_version")
implementation("androidx.room:room-ktx:$room_version")
implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4")
// SQLCipher
implementation ("net.zetetic:android-database-sqlcipher:4.5.3@aar")
implementation ("androidx.sqlite:sqlite:2.1.0")
implementation ("androidx.sqlite:sqlite-framework:2.1.0")
implementation("net.zetetic:android-database-sqlcipher:4.5.3@aar")
implementation("androidx.sqlite:sqlite:2.1.0")
implementation("androidx.sqlite:sqlite-framework:2.1.0")
// Server Upload
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// ---- Excel-Export (Apache POI) ----
// Leichtgewichtige OOXML-Implementierung + Kernbibliothek
implementation("org.apache.poi:poi:5.2.5")
implementation("org.apache.poi:poi-ooxml:5.2.5")
implementation("org.apache.poi:poi-ooxml-lite:5.2.5") //für kleinere Schemas
}

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

@ -1,8 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
xmlns:tools="http://schemas.android.com/tools"
package="com.dano.test1">
<!-- Netzwerkberechtigungen -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".MyApp"
@ -15,6 +18,7 @@
android:supportsRtl="true"
android:theme="@style/Theme.Test1"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<provider
@ -29,7 +33,8 @@
<activity
android:name=".MainActivity"
android:exported="true">
android:exported="true"
android:configChanges="orientation|keyboardHidden|screenSize|smallestScreenSize|screenLayout">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />

Binary file not shown.

View File

@ -20,11 +20,16 @@
},
{
"id": "q2",
"layout": "client_not_signed",
"textKey1": "no_consent_entered",
"textKey2": "no_consent_note",
"question": "coach_code_request",
"hint": "coach_code"
"layout": "client_coach_code_question",
"question": "no_consent_entered",
"hint1": "client_code",
"hint2": "coach_code"
},
{
"id": "last_page",
"layout": "last_page",
"textKey": "finish_data_entry",
"question": "data_final_warning"
},
{
"id": "q28",

View File

@ -107,7 +107,7 @@
{
"id": "q11",
"layout": "radio_question",
"question": "times_happend",
"question": "times_happend2",
"options": [
{ "key": "once" },
{ "key": "multiple_times" }
@ -116,7 +116,7 @@
{
"id": "q12",
"layout": "value_spinner",
"question": "age_at_incident",
"question": "age_at_incident2",
"range": {
"min": 1,
"max": 122

View File

@ -28,7 +28,11 @@
"questionnaire_1_demographic_information",
"questionnaire_2_rhs",
"questionnaire_3_integration_index"
]
],
"questionnaire": "questionnaire_1_demographic_information",
"questionId": "consent_instruction",
"operator": "==",
"value": "consent_signed"
}
},
{
@ -44,7 +48,7 @@
},
{
"file": "questionnaire_6_follow_up_survey.json",
"showPoints": false,
"showPoints": true,
"condition": {
"anyOf": [
{

View File

@ -1,56 +1,71 @@
package com.dano.test1
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.CipherInputStream
import javax.crypto.CipherOutputStream
import javax.crypto.Mac
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
import kotlin.random.Random
import kotlin.math.min
object AES256Helper {
private const val TRANSFORMATION = "AES/CBC/PKCS5Padding"
private const val ALGORITHM = "AES"
private const val IV_SIZE = 16
private fun hkdfFromToken(tokenHex: String, info: String = "qdb-aes", len: Int = 32): ByteArray {
val ikm = hexToBytes(tokenHex)
val mac = Mac.getInstance("HmacSHA256")
val zeroSalt = ByteArray(32) { 0 }
mac.init(SecretKeySpec(zeroSalt, "HmacSHA256"))
val prk = mac.doFinal(ikm)
// Beispiel-Key: 32 Bytes = 256 bit. Ersetze das durch deinen eigenen sicheren Schlüssel!
private val keyBytes = "12345678901234567890123456789012".toByteArray(Charsets.UTF_8)
private val secretKey = SecretKeySpec(keyBytes, ALGORITHM)
// Verschlüsseln: InputFile -> OutputFile (mit zufälligem IV vorne in der Datei)
fun encryptFile(inputFile: File, outputFile: File) {
val iv = ByteArray(IV_SIZE)
Random.nextBytes(iv)
val ivSpec = IvParameterSpec(iv)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec)
FileOutputStream(outputFile).use { fileOut ->
// IV vorne reinschreiben
fileOut.write(iv)
CipherOutputStream(fileOut, cipher).use { cipherOut ->
FileInputStream(inputFile).use { fileIn ->
fileIn.copyTo(cipherOut)
}
}
var previous = ByteArray(0)
val okm = ByteArray(len)
var generated = 0
var counter = 1
while (generated < len) {
mac.init(SecretKeySpec(prk, "HmacSHA256"))
mac.update(previous)
mac.update(info.toByteArray(Charsets.UTF_8))
mac.update(counter.toByte())
val t = mac.doFinal()
val toCopy = min(len - generated, t.size)
System.arraycopy(t, 0, okm, generated, toCopy)
previous = t
generated += toCopy
counter++
}
return okm
}
// Entschlüsseln: InputFile (IV+Ciphertext) -> OutputFile (Klartext)
fun decryptFile(inputFile: File, outputFile: File) {
FileInputStream(inputFile).use { fileIn ->
val iv = ByteArray(IV_SIZE)
if (fileIn.read(iv) != IV_SIZE) throw IllegalArgumentException("Ungültige Datei oder IV fehlt")
val ivSpec = IvParameterSpec(iv)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec)
CipherInputStream(fileIn, cipher).use { cipherIn ->
FileOutputStream(outputFile).use { fileOut ->
cipherIn.copyTo(fileOut)
}
}
private fun hexToBytes(hex: String): ByteArray {
val clean = hex.trim()
val len = clean.length
val out = ByteArray(len / 2)
var i = 0
while (i < len) {
out[i / 2] = ((Character.digit(clean[i], 16) shl 4) + Character.digit(clean[i + 1], 16)).toByte()
i += 2
}
return out
}
fun encryptFileWithToken(inFile: File, outFile: File, token: String) {
val key = hkdfFromToken(token)
val iv = ByteArray(16).also { SecureRandom().nextBytes(it) }
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv))
val plain = inFile.readBytes()
val enc = cipher.doFinal(plain)
outFile.writeBytes(iv + enc)
}
fun decryptFileWithToken(inFile: File, token: String): ByteArray {
val key = hkdfFromToken(token)
val data = inFile.readBytes()
require(data.size >= 16) { "cipher too short" }
val iv = data.copyOfRange(0, 16)
val ct = data.copyOfRange(16, data.size)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv))
return cipher.doFinal(ct)
}
}

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,18 +30,22 @@ interface ClientDao {
suspend fun getAllClients(): List<Client>
}
@Dao
interface QuestionnaireDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertQuestionnaire(questionnaire: Questionnaire)
@Query("SELECT * FROM questionnaires WHERE id = :id LIMIT 1")
suspend fun getById(id: String): Questionnaire?
@Query("SELECT * FROM questionnaires")
suspend fun getAll(): List<Questionnaire>
}
@Dao
interface QuestionDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertQuestions(questions: List<Question>)
@ -45,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>)
@ -63,10 +79,22 @@ interface AnswerDao {
@Query("SELECT * FROM answers WHERE clientCode = :clientCode")
suspend fun getAnswersForClient(clientCode: String): List<Answer>
@Query("""
DELETE FROM answers
WHERE clientCode = :clientCode
AND questionId IN (
SELECT questionId FROM questions WHERE questionnaireId = :questionnaireId
)
""")
suspend fun deleteAnswersForClientAndQuestionnaire(
clientCode: String,
questionnaireId: String
)
}
@Dao
interface CompletedQuestionnaireDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(entry: CompletedQuestionnaire)

View File

@ -0,0 +1,465 @@
package com.dano.test1
import android.util.Log
import android.view.View
import android.widget.*
import com.dano.test1.data.Client
import com.dano.test1.data.Question
import com.dano.test1.data.Questionnaire
import kotlinx.coroutines.*
import kotlin.math.roundToInt
import org.json.JSONArray
class DatabaseButtonHandler(
private val activity: MainActivity,
private val databaseButton: Button,
private val onClose: () -> Unit,
private val languageIDProvider: () -> String = { "GERMAN" }
) {
private val uiScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val tag = "DatabaseButtonHandler"
private val headerRepo = HeaderOrderRepository(activity)
private val exporter = ExcelExportService(activity, headerRepo)
fun setup() {
val lang = safeLang()
databaseButton.text = t(lang, "database") ?: "Datenbank"
databaseButton.setOnClickListener { openDatabaseScreen() }
}
// ---------------------------
// SCREEN 1: Client-Liste
// ---------------------------
private fun openDatabaseScreen() {
activity.setContentView(R.layout.database_screen)
val lang = safeLang()
val titleTv: TextView = requireView(R.id.title, "title")
val table: TableLayout = requireView(R.id.tableClients, "tableClients")
val progress: ProgressBar = requireView(R.id.progressBar, "progressBar")
val emptyView: TextView = requireView(R.id.emptyView, "emptyView")
val backButton: Button = requireView(R.id.backButton, "backButton")
val btnDownloadHeader: Button = requireView(R.id.btnDownloadHeader, "btnDownloadHeader")
titleTv.text = t(lang, "database_clients_title") ?: "Datenbank Clients"
emptyView.text = t(lang, "no_clients_available") ?: "Keine Clients vorhanden."
backButton.text = t(lang, "previous") ?: "Zurück"
btnDownloadHeader.text = t(lang, "download_header") ?: "Download Header"
backButton.setOnClickListener { onClose() }
btnDownloadHeader.setOnClickListener { onDownloadHeadersClicked(progress) }
progress.visibility = View.VISIBLE
emptyView.visibility = View.GONE
table.removeAllViews()
addHeaderRow(table, listOf("#", t(lang, "client_code") ?: "Client-Code"))
uiScope.launch {
val clients: List<Client> = withContext(Dispatchers.IO) {
MyApp.database.clientDao().getAllClients()
}
progress.visibility = View.GONE
if (clients.isEmpty()) {
emptyView.visibility = View.VISIBLE
return@launch
}
clients.forEachIndexed { index, client ->
addClickableRow(
table = table,
cells = listOf((index + 1).toString(), client.clientCode),
onClick = { openClientOverviewScreen(client.clientCode) }
)
}
}
}
// ---------------------------
// 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
if (savedUri != null) {
Toast.makeText(
activity,
t(lang, "export_success_downloads") ?: "Export erfolgreich: Downloads/ClientHeaders.xlsx",
Toast.LENGTH_LONG
).show()
} else {
Toast.makeText(activity, t(lang, "export_failed") ?: "Export fehlgeschlagen.", Toast.LENGTH_LONG).show()
}
} catch (e: Exception) {
progress.visibility = View.GONE
Log.e(tag, "Download Header Fehler: ${e.message}", e)
val prefix = t(lang, "error") ?: "Fehler"
Toast.makeText(activity, "$prefix: ${e.message}", Toast.LENGTH_LONG).show()
}
}
}
// ---------------------------
// SCREEN 2: Fragebogen-Übersicht + "header"-Liste
// ---------------------------
private fun openClientOverviewScreen(clientCode: String) {
activity.setContentView(R.layout.client_overview_screen)
val lang = safeLang()
val title: TextView = requireView(R.id.titleClientOverview, "titleClientOverview")
val tableQ: TableLayout = requireView(R.id.tableQuestionnaires, "tableQuestionnaires")
val progress: ProgressBar = requireView(R.id.progressBarClient, "progressBarClient")
val emptyView: TextView = requireView(R.id.emptyViewClient, "emptyViewClient")
val backButton: Button = requireView(R.id.backButtonClient, "backButtonClient")
val headerLabel: TextView = requireView(R.id.headerLabel, "headerLabel")
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"
backButton.text = t(lang, "previous") ?: "Zurück"
backButton.setOnClickListener { openDatabaseScreen() }
progress.visibility = View.VISIBLE
emptyView.visibility = View.GONE
tableQ.removeAllViews()
tableOrdered.removeAllViews()
addHeaderRow(
tableQ,
listOf("#", t(lang, "questionnaire_id") ?: "Fragebogen-ID", t(lang, "status") ?: "Status")
)
addHeaderRow(
tableOrdered,
listOf("#", t(lang, "id") ?: "ID", t(lang, "value") ?: "Wert")
)
uiScope.launch {
val result = withContext(Dispatchers.IO) {
val allQuestionnairesDb = MyApp.database.questionnaireDao().getAll()
val completedForClient = MyApp.database.completedQuestionnaireDao().getAllForClient(clientCode)
val allAnswersForClient = MyApp.database.answerDao().getAnswersForClient(clientCode)
Triple(allQuestionnairesDb, completedForClient, allAnswersForClient)
}
// IDs aus der JSON-Reihenfolge lesen (alle, die es geben soll)
val idsFromAssets: List<String> = loadQuestionnaireIdsFromAssets()
progress.visibility = View.GONE
val dbQuestionnaires: List<Questionnaire> = result.first
val completedForClient = result.second
val allAnswersForClient = result.third
// Vereinigung: alles was in JSON steht + alles was in der DB existiert
val allIds: List<String> =
(idsFromAssets + dbQuestionnaires.map { it.id }).distinct()
if (allIds.isEmpty()) {
emptyView.text = t(lang, "no_questionnaires") ?: "Keine Fragebögen vorhanden."
emptyView.visibility = View.VISIBLE
}
val statusMap = completedForClient.associate { it.questionnaireId to it.isDone }
val questionnaireIdSet = allIds.toSet() // für die zweite Tabelle
val answerMap = allAnswersForClient.associate { it.questionId to it.answerValue }
// Tabelle 1 (Status) JETZT mit allen IDs
allIds
.sortedWith(compareBy({ extractQuestionnaireNumber(it) ?: Int.MAX_VALUE }, { it }))
.forEachIndexed { idx, qid ->
val isDone = statusMap[qid] ?: false
val statusText = if (isDone) "" else ""
val statusTextColor = if (isDone) 0xFF4CAF50.toInt() else 0xFFF44336.toInt()
if (isDone) {
addClickableRow(
table = tableQ,
cells = listOf((idx + 1).toString(), qid, statusText),
onClick = { openQuestionnaireDetailScreen(clientCode, qid) },
colorOverrides = mapOf(2 to statusTextColor)
)
} else {
addDisabledRow(
table = tableQ,
cells = listOf((idx + 1).toString(), qid, statusText),
colorOverrides = mapOf(2 to statusTextColor)
)
}
}
// Farben
val lightGreen = 0xFFC8E6C9.toInt()
val lightRed = 0xFFFFCDD2.toInt()
val doneGreen = 0xFF4CAF50.toInt()
val notRed = 0xFFF44336.toInt()
// Tabelle 2 (Header-Liste)
val orderedIds = headerRepo.loadOrderedIds()
orderedIds.forEachIndexed { idx, id ->
var rowBgColor: Int? = null
val raw: String
val cellBgForQuestionnaire: Int?
if (id == "client_code") {
raw = clientCode
cellBgForQuestionnaire = null
} else if (id in questionnaireIdSet) {
raw = if (statusMap[id] == true) "Done" else "Not Done"
cellBgForQuestionnaire = if (raw == "Done") doneGreen else notRed
} else {
raw = answerMap[id]?.takeIf { it.isNotBlank() } ?: "None"
cellBgForQuestionnaire = null
rowBgColor = if (raw == "None") lightRed else lightGreen
}
val display = localizeHeaderValue(id, raw, lang)
val cellBgOverrides =
if (cellBgForQuestionnaire != null)
mapOf(0 to cellBgForQuestionnaire, 1 to cellBgForQuestionnaire, 2 to cellBgForQuestionnaire)
else emptyMap()
addRow(
table = tableOrdered,
cells = listOf((idx + 1).toString(), id, display),
rowBgColor = rowBgColor,
cellBgOverrides = cellBgOverrides
)
}
}
}
// ---------------------------
// SCREEN 3: Fragen & Antworten eines Fragebogens
// ---------------------------
private fun openQuestionnaireDetailScreen(clientCode: String, questionnaireId: String) {
activity.setContentView(R.layout.questionnaire_detail_screen)
val lang = safeLang()
val title: TextView = requireView(R.id.titleQuestionnaireDetail, "titleQuestionnaireDetail")
val table: TableLayout = requireView(R.id.tableQA, "tableQA")
val progress: ProgressBar = requireView(R.id.progressBarQA, "progressBarQA")
val emptyView: TextView = requireView(R.id.emptyViewQA, "emptyViewQA")
val backButton: Button = requireView(R.id.backButtonQA, "backButtonQA")
title.text = "${t(lang, "client") ?: "Client"}: $clientCode ${t(lang, "questionnaire") ?: "Fragebogen"}: $questionnaireId"
backButton.text = t(lang, "previous") ?: "Zurück"
emptyView.text = t(lang, "no_questions_available") ?: "Keine Fragen vorhanden."
backButton.setOnClickListener { openClientOverviewScreen(clientCode) }
progress.visibility = View.VISIBLE
emptyView.visibility = View.GONE
table.removeAllViews()
addHeaderRow(table, listOf("#", t(lang, "question") ?: "Frage", t(lang, "answer") ?: "Antwort"))
uiScope.launch {
val (questions, answersForClient) = withContext(Dispatchers.IO) {
val qs = MyApp.database.questionDao().getQuestionsForQuestionnaire(questionnaireId)
val ans = MyApp.database.answerDao()
.getAnswersForClientAndQuestionnaire(clientCode, questionnaireId)
qs to ans
}
progress.visibility = View.GONE
if (questions.isEmpty()) {
emptyView.visibility = View.VISIBLE
return@launch
}
val answerMap = answersForClient.associate { it.questionId to it.answerValue }
questions.forEachIndexed { idx, q: Question ->
val baseId = q.questionId.substringAfterLast('-', q.questionId)
val qText = localizeQuestionLabel(q.questionId, q.question, lang)
val raw = answerMap[q.questionId]?.takeIf { it.isNotBlank() } ?: ""
val aText = localizeAnswerValue(baseId, raw, lang)
addRow(table, listOf((idx + 1).toString(), qText, aText))
}
}
}
// ---------------------------
// Hilfen
// ---------------------------
private fun safeLang(): String = try { languageIDProvider() } catch (_: Exception) { "GERMAN" }
private fun stripBrackets(s: String): String {
val m = Regex("^\\[(.*)]$").matchEntire(s.trim())
return m?.groupValues?.get(1) ?: s
}
private fun t(lang: String, key: String): String? {
val txt = try { LanguageManager.getText(lang, key) } catch (_: Exception) { null } ?: return null
val out = stripBrackets(txt).trim()
return if (out.equals(key, true) || out.isBlank()) null else out
}
private fun localizeQuestionLabel(questionId: String, fallbackQuestionText: String?, lang: String): String {
val field = questionId.substringAfterLast('-', questionId)
t(lang, field)?.let { return it }
t(lang, questionId)?.let { return it }
fallbackQuestionText?.takeIf { it.isNotBlank() }?.let { return it }
return field.replace('_', ' ').replaceFirstChar { it.titlecase() }
}
private fun localizeAnswerValue(fieldId: String, raw: String, lang: String): String {
if (raw == "") return raw
if (raw.matches(Regex("^\\d{1,4}([./-]\\d{1,2}([./-]\\d{1,4})?)?\$"))) return raw
t(lang, raw)?.let { return it }
val norm = raw.lowercase().replace(Regex("[^a-z0-9]+"), "_").trim('_')
if (norm.isNotBlank()) t(lang, norm)?.let { return it }
if (norm.isNotBlank()) {
t(lang, "${fieldId}_$norm")?.let { return it }
t(lang, "${fieldId}-${norm}")?.let { return it }
}
return stripBrackets(raw)
}
private fun addHeaderRow(table: TableLayout, labels: List<String>) {
val row = TableRow(activity)
labels.forEach { label -> row.addView(makeHeaderCell(label)) }
table.addView(row); addDivider(table)
}
private fun addRow(
table: TableLayout,
cells: List<String>,
colorOverrides: Map<Int, Int> = emptyMap(),
rowBgColor: Int? = null,
cellBgOverrides: Map<Int, Int> = emptyMap()
) {
val row = TableRow(activity)
rowBgColor?.let { row.setBackgroundColor(it) }
cells.forEachIndexed { index, text ->
val tv = makeBodyCell(text, colorOverrides[index], cellBgOverrides[index]); row.addView(tv)
}
table.addView(row); addDivider(table)
}
private fun addClickableRow(
table: TableLayout,
cells: List<String>,
onClick: () -> Unit,
colorOverrides: Map<Int, Int> = emptyMap(),
cellBgOverrides: Map<Int, Int> = emptyMap()
) {
val row = TableRow(activity).apply {
isClickable = true
isFocusable = true
setBackgroundColor(android.graphics.Color.TRANSPARENT)
setOnClickListener { onClick() }
}
cells.forEachIndexed { index, text ->
val tv = makeBodyCell(text, colorOverrides[index], cellBgOverrides[index]); row.addView(tv)
}
table.addView(row); addDivider(table)
}
private fun addDisabledRow(
table: TableLayout,
cells: List<String>,
colorOverrides: Map<Int, Int> = emptyMap(),
cellBgOverrides: Map<Int, Int> = emptyMap()
) {
val row = TableRow(activity).apply { isClickable = false; isEnabled = false; alpha = 0.6f }
cells.forEachIndexed { index, text ->
val tv = makeBodyCell(text, colorOverrides[index], cellBgOverrides[index]); row.addView(tv)
}
table.addView(row); addDivider(table)
}
private fun addDivider(table: TableLayout) {
val divider = View(activity)
val params = TableLayout.LayoutParams(TableLayout.LayoutParams.MATCH_PARENT, dp(1))
divider.layoutParams = params
divider.setBackgroundColor(0xFFDDDDDD.toInt())
table.addView(divider)
}
private fun makeHeaderCell(text: String): TextView =
TextView(activity).apply {
this.text = text
setPadding(dp(12), dp(10), dp(12), dp(10))
textSize = 16f
setTypeface(typeface, android.graphics.Typeface.BOLD)
}
private fun makeBodyCell(
text: String,
textColor: Int? = null,
bgColor: Int? = null
): TextView =
TextView(activity).apply {
this.text = text
setPadding(dp(12), dp(10), dp(12), dp(10))
textSize = 15f
textColor?.let { setTextColor(it) }
bgColor?.let { setBackgroundColor(it) }
}
private fun dp(value: Int): Int {
val density = activity.resources.displayMetrics.density
return (value * density).roundToInt()
}
private fun <T : View> requireView(id: Int, name: String): T {
val v = activity.findViewById<T>(id)
if (v == null) {
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, msg, Toast.LENGTH_LONG).show()
throw IllegalStateException(msg)
}
return v
}
private fun extractQuestionnaireNumber(id: String): Int? {
val m = Regex("^questionnaire_(\\d+)").find(id.lowercase())
return m?.groupValues?.get(1)?.toIntOrNull()
}
private fun localizeHeaderValue(id: String, raw: String, lang: String): String {
if (id == "client_code") return raw
fun norm(s: String) = s.lowercase().replace(Regex("[^a-z0-9]+"), "_").trim('_')
val candidates = buildList {
when (raw) { "Done" -> add("done"); "Not Done" -> add("not_done"); "None" -> add("none") }
add(raw)
val n = norm(raw)
if (n.isNotBlank() && n != raw) add(n)
if (n.isNotBlank()) { add("${id}_$n"); add("${id}-$n") }
}
for (k in candidates) t(lang, k)?.let { return it }
return stripBrackets(raw)
}
/** Lädt alle Fragebogen-IDs aus questionnaire_order.json (file ohne .json). */
private fun loadQuestionnaireIdsFromAssets(): List<String> = try {
val input = activity.assets.open("questionnaire_order.json")
val json = input.bufferedReader().use { it.readText() }
val arr = JSONArray(json)
(0 until arr.length()).mapNotNull { i ->
val obj = arr.optJSONObject(i)
obj?.optString("file")?.removeSuffix(".json")
}
} catch (_: Exception) {
emptyList()
}
}

View File

@ -5,77 +5,58 @@ import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.FileOutputStream
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
object DatabaseDownloader {
private const val DB_NAME = "questionnaire_database"
private const val API_TOKEN = "MEIN_SUPER_GEHEIMES_TOKEN_12345"
private const val SERVER_DOWNLOAD_URL = "http://49.13.157.44/downloadFull.php?token=$API_TOKEN"
// AES-256 Key (muss exakt 32 Bytes lang sein)
private const val AES_KEY = "12345678901234567890123456789012"
private const val SERVER_DOWNLOAD_URL = "https://daniel-ocks.de/qdb/downloadFull.php"
private val client = OkHttpClient()
fun downloadAndReplaceDatabase(context: Context) {
// Neue Variante mit Callback
fun downloadAndReplaceDatabase(context: Context, token: String, onDone: ((Boolean) -> Unit)? = null) {
CoroutineScope(Dispatchers.IO).launch {
var ok = false
try {
Log.d("DOWNLOAD", "Download gestartet: $SERVER_DOWNLOAD_URL")
val request = Request.Builder()
.url(SERVER_DOWNLOAD_URL)
.header("Authorization", "Bearer $token")
.build()
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
Log.e("DOWNLOAD", "Fehler beim Download: ${response.code}")
Log.e("DOWNLOAD", "HTTP ${response.code}")
withContext(Dispatchers.Main) { onDone?.invoke(false) }
return@launch
}
// Zwischenspeichern der verschlüsselten Datei
val downloadedFile = File(context.cacheDir, "downloaded_database.enc")
val encFile = File(context.cacheDir, "downloaded_database.enc")
response.body?.byteStream()?.use { input ->
FileOutputStream(downloadedFile).use { output ->
input.copyTo(output)
}
FileOutputStream(encFile).use { output -> input.copyTo(output) }
}
Log.d("DOWNLOAD", "Datei gespeichert: ${downloadedFile.absolutePath}")
// Entschlüsselung
val decryptedBytes = decryptFile(downloadedFile)
val decryptedBytes = AES256Helper.decryptFileWithToken(encFile, token)
val dbFile = context.getDatabasePath(DB_NAME)
if (dbFile.exists()) dbFile.delete()
FileOutputStream(dbFile).use { fos ->
fos.write(decryptedBytes)
}
Log.d("DOWNLOAD", "Neue DB erfolgreich entschlüsselt und eingesetzt")
FileOutputStream(dbFile).use { it.write(decryptedBytes) }
Log.d("DOWNLOAD", "DB erfolgreich ersetzt")
ok = true
} catch (e: Exception) {
Log.e("DOWNLOAD", "Fehler beim Download oder Ersetzen der DB", e)
Log.e("DOWNLOAD", "Fehler", e)
} finally {
withContext(Dispatchers.Main) { onDone?.invoke(ok) }
}
}
}
private fun decryptFile(file: File): ByteArray {
val fileBytes = file.readBytes()
if (fileBytes.size < 16) throw IllegalArgumentException("Datei zu kurz, kein IV vorhanden")
val iv = fileBytes.copyOfRange(0, 16)
val cipherBytes = fileBytes.copyOfRange(16, fileBytes.size)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
val keySpec = SecretKeySpec(AES_KEY.toByteArray(Charsets.UTF_8), "AES")
val ivSpec = IvParameterSpec(iv)
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec)
return cipher.doFinal(cipherBytes)
// Abwärtskompatible alte Signatur
fun downloadAndReplaceDatabase(context: Context, token: String) {
downloadAndReplaceDatabase(context, token, null)
}
}

View File

@ -20,95 +20,49 @@ import kotlin.system.exitProcess
object DatabaseUploader {
private const val DB_NAME = "questionnaire_database"
// TODO entferne uploadDeltaTest2.php
private const val SERVER_DELTA_URL = "http://49.13.157.44/uploadDeltaTest3.php"
private const val SERVER_CHECK_URL = "http://49.13.157.44/checkDatabaseExists.php"
private const val API_TOKEN = "MEIN_SUPER_GEHEIMES_TOKEN_12345"
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()
fun uploadDatabase(context: Context) {
private fun uploadDatabase(context: Context, token: String) {
CoroutineScope(Dispatchers.IO).launch {
try {
val dbFile = context.getDatabasePath(DB_NAME)
if (!dbFile.exists()) {
Log.e("UPLOAD", "Datenbankdatei existiert nicht: ${dbFile.absolutePath}")
Log.e("UPLOAD", "DB fehlt: ${dbFile.absolutePath}")
return@launch
}
// WAL-Checkpoint
// 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 { cursor ->
if (cursor.moveToFirst()) {
try {
Log.d("UPLOAD", "WAL-Checkpoint result: ${cursor.getInt(0)}")
} catch (_: Exception) {}
}
}
val db = SQLiteDatabase.openDatabase(dbFile.absolutePath, null, SQLiteDatabase.OPEN_READWRITE)
db.rawQuery("PRAGMA wal_checkpoint(FULL);", null).use { /* noop */ }
db.close()
Log.d("UPLOAD", "WAL-Checkpoint erfolgreich.")
} catch (e: Exception) {
Log.e("UPLOAD", "Fehler beim WAL-Checkpoint", e)
}
val exists = checkDatabaseExists()
if (exists) {
Log.d("UPLOAD", "Server-Datenbank vorhanden → Delta-Upload")
uploadPseudoDelta(context, dbFile)
} else {
Log.d("UPLOAD", "Keine Server-Datenbank → Delta-Upload")
uploadPseudoDelta(context, dbFile)
}
} catch (_: Exception) { }
checkDatabaseExists()
uploadPseudoDelta(context, dbFile, token)
} catch (e: Exception) {
Log.e("UPLOAD", "Fehler beim Hochladen der DB", e)
Log.e("UPLOAD", "Fehler", e)
}
}
}
private fun checkDatabaseExists(): Boolean {
return try {
val request = Request.Builder()
.url(SERVER_CHECK_URL)
.get()
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
Log.e("UPLOAD", "checkDatabaseExists HTTP error: ${response.code}")
return false
}
val body = response.body?.string() ?: return false
try {
val j = JSONObject(body)
j.optBoolean("exists", false)
} catch (e: Exception) {
body.contains("exists", ignoreCase = true)
}
val req = Request.Builder().url(SERVER_CHECK_URL).get().build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) return false
val body = resp.body?.string() ?: return false
try { JSONObject(body).optBoolean("exists", false) } catch (_: Exception) { false }
}
} catch (e: Exception) {
Log.e("UPLOAD", "Fehler bei Server-Prüfung", e)
false
}
} catch (e: Exception) { false }
}
/**
* Wichtig: Diese Funktion wurde erweitert, sodass:
* - die DB als JSON in eine temporäre Datei geschrieben wird,
* - diese JSON-Datei AES-verschlüsselt wird (mit AES256Helper.encryptFile),
* - die verschlüsselte Datei als Multipart 'file' an den Server gesendet wird.
*
* (Funktionalität: gleiche Signatur wie vorher behalten)
*/
private fun uploadPseudoDelta(context: Context, file: File) {
private fun uploadPseudoDelta(context: Context, file: File, token: String) {
try {
val db = SQLiteDatabase.openDatabase(file.absolutePath, null, SQLiteDatabase.OPEN_READONLY)
val data = JSONObject().apply {
put("clients", queryToJsonArray(db, "SELECT clientCode FROM clients"))
put("questionnaires", queryToJsonArray(db, "SELECT id FROM questionnaires"))
@ -116,98 +70,75 @@ object DatabaseUploader {
put("answers", queryToJsonArray(db, "SELECT clientCode, questionId, answerValue FROM answers"))
put(
"completed_questionnaires",
queryToJsonArray(
db,
"SELECT clientCode, questionnaireId, timestamp, isDone, sumPoints FROM completed_questionnaires"
)
queryToJsonArray(db, "SELECT clientCode, questionnaireId, timestamp, isDone, sumPoints FROM completed_questionnaires")
)
}
db.close()
// Schreibe JSON in temporäre Datei
val tmpJson = File(context.cacheDir, "payload.json")
tmpJson.writeText(data.toString())
// Verschlüssele JSON -> tmpEnc
// JSON -> verschlüsselte Payload
val tmpJson = File(context.cacheDir, "payload.json").apply { writeText(data.toString()) }
val tmpEnc = File(context.cacheDir, "payload.enc")
try {
AES256Helper.encryptFile(tmpJson, tmpEnc)
AES256Helper.encryptFileWithToken(tmpJson, tmpEnc, token)
} catch (e: Exception) {
Log.e("UPLOAD", "Fehler bei der Verschlüsselung der JSON-Datei", e)
// cleanup
tmpJson.delete()
return
Log.e("UPLOAD", "Verschlüsselung fehlgeschlagen", e)
tmpJson.delete(); return
}
val requestBody = MultipartBody.Builder()
val body = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("token", API_TOKEN)
// Datei-Feld "file" mit verschlüsselter Payload
.addFormDataPart(
"file",
"payload.enc",
tmpEnc.asRequestBody("application/octet-stream".toMediaType())
)
.addFormDataPart("token", token) // bleibt für Kompatibilität enthalten
.addFormDataPart("file", "payload.enc", tmpEnc.asRequestBody("application/octet-stream".toMediaType()))
.build()
// WICHTIG: Jetzt HTTPS + Konstanten-URL verwenden, plus Bearer-Header
val request = Request.Builder()
.url(SERVER_DELTA_URL)
.post(requestBody)
.post(body)
.header("Authorization", "Bearer $token")
.build()
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
Log.e("UPLOAD", "Delta-Upload fehlgeschlagen: ${e.message}")
// cleanup
tmpJson.delete()
tmpEnc.delete()
Log.e("UPLOAD", "Fehlgeschlagen: ${e.message}")
tmpJson.delete(); tmpEnc.delete()
}
override fun onResponse(call: Call, response: Response) {
val body = try {
response.body?.string() ?: "Keine Response"
} catch (e: Exception) {
"Fehler beim Lesen der Response: ${e.message}"
}
val respBody = try { response.body?.string() ?: "" } catch (_: Exception) { "" }
if (response.isSuccessful) {
Log.d("UPLOAD", "Delta-Upload erfolgreich: $body")
// Lösche Hauptdatenbank
if (file.delete()) {
Log.d("UPLOAD", "Lokale DB gelöscht.")
} else {
Log.e("UPLOAD", "Löschen der lokalen DB fehlgeschlagen.")
Log.d("UPLOAD", "OK: $respBody")
// 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()
File(file.parent, "${file.name}-wal").delete()
File(file.parent, "${file.name}-shm").delete()
} catch (e: Exception) {
Log.w("UPLOAD", "Fehler beim Löschen lokaler DB-Dateien", e)
}
// Lösche Journal-Datei
val journalFile = File(file.parent, file.name + "-journal")
if (journalFile.exists() && journalFile.delete()) {
Log.d("UPLOAD", "Journal-Datei gelöscht.")
}
// cleanup temp files
tmpJson.delete()
tmpEnc.delete()
exitProcess(0)
} else {
Log.e("UPLOAD", "Delta-Upload fehlgeschlagen: ${response.code} $body")
tmpJson.delete()
tmpEnc.delete()
Log.e("UPLOAD", "HTTP ${response.code}: $respBody")
}
tmpJson.delete(); tmpEnc.delete()
// unverändert beibehalten
try { exitProcess(0) } catch (_: Exception) {}
}
})
} catch (e: Exception) {
Log.e("UPLOAD", "Fehler beim Delta-Upload", e)
Log.e("UPLOAD", "Exception", e)
}
}
private fun queryToJsonArray(db: SQLiteDatabase, query: String): JSONArray {
val cursor = db.rawQuery(query, null)
val jsonArray = JSONArray()
cursor.use {
val columnNames = it.columnNames
val c = db.rawQuery(query, null)
val arr = JSONArray()
c.use {
val cols = it.columnNames
while (it.moveToNext()) {
val obj = JSONObject()
for (col in columnNames) {
for (col in cols) {
val idx = it.getColumnIndex(col)
if (idx >= 0) {
when (it.getType(idx)) {
@ -215,17 +146,20 @@ object DatabaseUploader {
Cursor.FIELD_TYPE_FLOAT -> obj.put(col, it.getDouble(idx))
Cursor.FIELD_TYPE_STRING -> obj.put(col, it.getString(idx))
Cursor.FIELD_TYPE_NULL -> obj.put(col, JSONObject.NULL)
Cursor.FIELD_TYPE_BLOB -> {
val blob = it.getBlob(idx)
obj.put(col, Base64.encodeToString(blob, Base64.NO_WRAP))
}
Cursor.FIELD_TYPE_BLOB -> obj.put(col, Base64.encodeToString(it.getBlob(idx), Base64.NO_WRAP))
else -> obj.put(col, it.getString(idx))
}
}
}
jsonArray.put(obj)
arr.put(obj)
}
}
return jsonArray
return arr
}
fun uploadDatabaseWithToken(context: Context, token: String) {
uploadDatabase(context, token)
}
}

View File

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

View File

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

@ -0,0 +1,178 @@
package com.dano.test1
import android.content.ContentValues
import android.content.Context
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Build
import android.os.Environment
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". */
suspend fun exportHeadersForAllClients(): Uri? {
val orderedIds = headerRepo.loadOrderedIds()
if (orderedIds.isEmpty()) return null
val clients = MyApp.database.clientDao().getAllClients()
val questionnaires = MyApp.database.questionnaireDao().getAll()
val questionnaireIdSet = questionnaires.map { it.id }.toSet()
val wb = XSSFWorkbook()
val sheet = wb.createSheet("Headers")
sheet.setColumnWidth(0, 8 * 256)
for (i in 1..orderedIds.size) sheet.setColumnWidth(i, 36 * 256)
// Row 1: IDs
var col = 0
val headerRow: Row = sheet.createRow(0)
headerRow.createCell(col++).setCellValue("#")
orderedIds.forEach { id -> headerRow.createCell(col++).setCellValue(id) }
// Row 2: Questions (EN)
val questionRow: Row = sheet.createRow(1)
var qc = 0
questionRow.createCell(qc++).setCellValue("Question (EN)")
for (id in orderedIds) {
val englishQuestion = englishQuestionForId(id, questionnaireIdSet)
questionRow.createCell(qc++).setCellValue(englishQuestion)
}
// Rows 3+: Values per client
clients.forEachIndexed { rowIdx, client ->
val row: Row = sheet.createRow(rowIdx + 2)
var c = 0
row.createCell(c++).setCellValue((rowIdx + 1).toDouble())
val completedForClient = MyApp.database.completedQuestionnaireDao().getAllForClient(client.clientCode)
val statusMap = completedForClient.associate { it.questionnaireId to it.isDone }
val answers = MyApp.database.answerDao().getAnswersForClient(client.clientCode)
val answerMap = answers.associate { it.questionId to it.answerValue }
orderedIds.forEach { id ->
val raw = when {
id == "client_code" -> client.clientCode
id in questionnaireIdSet -> if (statusMap[id] == true) "Done" else "Not Done"
else -> answerMap[id]?.takeIf { it.isNotBlank() } ?: "None"
}
val out = localizeForExportEn(id, raw)
row.createCell(c++).setCellValue(out)
}
}
val bytes = java.io.ByteArrayOutputStream().use { bos ->
wb.write(bos); bos.toByteArray()
}
wb.close()
return saveToDownloads(
filename = "ClientHeaders.xlsx",
mimeType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
bytes = bytes
)
}
private fun saveToDownloads(filename: String, mimeType: String, bytes: ByteArray): Uri? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val resolver = context.contentResolver
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, filename)
put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val uri = resolver.insert(collection, values)
if (uri != null) {
resolver.openOutputStream(uri)?.use { it.write(bytes) } ?: return null
uri
} else null
} else {
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
if (!downloadsDir.exists()) downloadsDir.mkdirs()
val outFile = java.io.File(downloadsDir, filename)
outFile.writeBytes(bytes)
MediaScannerConnection.scanFile(
context,
arrayOf(outFile.absolutePath),
arrayOf(mimeType),
null
)
Uri.fromFile(outFile)
}
}
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"
localizeEnglishNoBrackets(id)?.let { lm ->
if (!looksLikeId(lm, id)) return lm
}
val fieldPart = id.substringAfterLast('-', id)
localizeEnglishNoBrackets(fieldPart)?.let { lm ->
if (!looksLikeId(lm, fieldPart)) return lm
}
val pretty = humanizeIdToEnglish(fieldPart)
if (pretty.isNotBlank()) return pretty
return "Question"
}
private fun looksLikeId(text: String, originalId: String): Boolean {
val normText = text.lowercase().replace(Regex("[^a-z0-9]+"), "_").trim('_')
val normId = originalId.lowercase().replace(Regex("[^a-z0-9]+"), "_").trim('_')
return normText == normId
}
private fun humanizeIdToEnglish(source: String): String {
val s = source.replace(Regex("^questionnaire_\\d+_"), "").replace('_', ' ').trim()
if (s.isBlank()) return s
return s.split(Regex("\\s+")).joinToString(" ") { it.lowercase().replaceFirstChar { c -> c.titlecase() } }
}
private fun localizeEnglishNoBrackets(key: String): String? {
val t = try { LanguageManager.getText("ENGLISH", key) } catch (_: Exception) { null }
val m = Regex("^\\[(.*)]$").matchEntire(t?.trim() ?: "")
val stripped = m?.groupValues?.get(1) ?: t
if (stripped == null || stripped.isBlank() || stripped.equals(key, ignoreCase = true)) return null
return stripped
}
/* 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
val norm = raw.lowercase().replace(Regex("[^a-z0-9]+"), "_").trim('_')
val candidates = buildList {
add(raw)
if (norm.isNotBlank() && norm != raw) add(norm)
if (norm.isNotBlank()) { add("${id}_$norm"); add("${id}-$norm") }
}
for (key in candidates) localizeEnglishNoBrackets(key)?.let { return it }
return raw
}
}

View File

@ -2,11 +2,18 @@ package com.dano.test1
import android.view.View
import android.widget.*
import android.util.TypedValue
import androidx.core.widget.TextViewCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/*
Zweck :
- Steuert die Eingabeseite für „Client Code“ und „Coach Code“ innerhalb des Fragebogen-Flows.
*/
class HandlerClientCoachCode(
private val answers: MutableMap<String, Any>,
private val languageID: String,
@ -24,42 +31,57 @@ class HandlerClientCoachCode(
this.layout = layout
this.question = question
// Bind UI components
val clientCodeField = layout.findViewById<EditText>(R.id.client_code)
val coachCodeField = layout.findViewById<EditText>(R.id.coach_code)
val coachCodeField = layout.findViewById<EditText>(R.id.coach_code)
val questionTextView = layout.findViewById<TextView>(R.id.question)
val titleTextView = layout.findViewById<TextView>(R.id.textView)
// Fill question text using language manager
questionTextView.text = question.question?.let {
LanguageManager.getText(languageID, it)
} ?: ""
questionTextView.text = question.question?.let { LanguageManager.getText(languageID, it) } ?: ""
// Load last used client code if available
val lastClientCode = GlobalValues.LAST_CLIENT_CODE
if (!lastClientCode.isNullOrBlank()) {
clientCodeField.setText(lastClientCode)
setTextSizePercentOfScreenHeight(titleTextView, 0.03f)
setTextSizePercentOfScreenHeight(questionTextView, 0.03f)
setTextSizePercentOfScreenHeight(clientCodeField, 0.025f)
setTextSizePercentOfScreenHeight(coachCodeField, 0.025f)
// Client-Code: nur verwenden, wenn bereits geladen
val loadedClientCode = GlobalValues.LOADED_CLIENT_CODE
if (!loadedClientCode.isNullOrBlank()) {
clientCodeField.setText(loadedClientCode)
clientCodeField.isEnabled = false
} else {
clientCodeField.setText(answers["client_code"] as? String ?: "")
clientCodeField.setText("")
clientCodeField.isEnabled = true
}
// Load saved coach code
coachCodeField.setText(answers["coach_code"] as? String ?: "")
// Coach-Code immer aus dem Login (TokenStore) setzen und sperren
val coachFromLogin = TokenStore.getUsername(layout.context)
if (!coachFromLogin.isNullOrBlank()) {
coachCodeField.setText(coachFromLogin)
lockCoachField(coachCodeField) // optisch & technisch gesperrt
} else {
// Falls (theoretisch) kein Login-Username vorhanden ist, verhalten wie bisher
coachCodeField.setText(answers["coach_code"] as? String ?: "")
coachCodeField.isEnabled = true
}
// Set click listener for Next button
layout.findViewById<Button>(R.id.Qnext).setOnClickListener {
onNextClicked(clientCodeField, coachCodeField)
}
// Set click listener for Previous button
layout.findViewById<Button>(R.id.Qprev).setOnClickListener {
onPreviousClicked(clientCodeField, coachCodeField)
}
}
// Handle Next button click
private fun setTextSizePercentOfScreenHeight(view: TextView, percentOfHeight: Float) {
val dm = layout.resources.displayMetrics
val sp = (dm.heightPixels * percentOfHeight) / dm.scaledDensity
TextViewCompat.setAutoSizeTextTypeWithDefaults(view, TextViewCompat.AUTO_SIZE_TEXT_TYPE_NONE)
view.setTextSize(TypedValue.COMPLEX_UNIT_SP, sp)
}
private fun onNextClicked(clientCodeField: EditText, coachCodeField: EditText) {
val loadedClientCode = GlobalValues.LOADED_CLIENT_CODE
if (!validate()) {
val message = LanguageManager.getText(languageID, "fill_both_fields")
showToast(message)
@ -67,62 +89,73 @@ class HandlerClientCoachCode(
}
val clientCode = clientCodeField.text.toString()
val coachCode = coachCodeField.text.toString()
// Erzwinge Coach-Code aus Login (falls vorhanden)
val coachCode = TokenStore.getUsername(layout.context) ?: coachCodeField.text.toString()
// Prüfen, ob die Datenbank-Dateien vor dem Klick existieren
val dbFile = layout.context.getDatabasePath("questionnaire_database")
val dbJournalFile = layout.context.getDatabasePath("questionnaire_database-journal")
val dbExisted = dbFile.exists() || dbJournalFile.exists()
// Prüfen, ob die DB-Datei vor dem Zugriff existiert
val dbPath = layout.context.getDatabasePath("questionnaire_database")
val dbExistedBefore = dbPath.exists()
// Check if client code already exists asynchronously
CoroutineScope(Dispatchers.IO).launch {
val existingClient = MyApp.database.clientDao().getClientByCode(clientCode)
withContext(Dispatchers.Main) {
if (existingClient != null && clientCodeField.isEnabled) {
// Client code already exists and field was editable
val message = LanguageManager.getText(languageID, "client_code_exists")
showToast(message)
} else {
// Either no existing client or re-using previous code
saveAnswers(clientCode, coachCode)
// Datenbank-Dateien löschen, wenn sie vorher NICHT existierten
if (!dbExisted) {
dbFile.delete()
dbJournalFile.delete()
}
goToNextQuestion()
if (!dbExistedBefore) {
MyApp.database.close()
dbPath.delete()
val journalFile = layout.context.getDatabasePath("questionnaire_database-journal")
journalFile.delete()
}
}
}
}
}
// Handle Previous button click
private fun onPreviousClicked(clientCodeField: EditText, coachCodeField: EditText) {
val clientCode = clientCodeField.text.toString()
val coachCode = coachCodeField.text.toString()
val coachCode = TokenStore.getUsername(layout.context) ?: coachCodeField.text.toString()
saveAnswers(clientCode, coachCode)
goToPreviousQuestion()
}
// Validate that both fields are filled
override fun validate(): Boolean {
val clientCode = layout.findViewById<EditText>(R.id.client_code).text
val coachCode = layout.findViewById<EditText>(R.id.coach_code).text
return clientCode.isNotBlank() && coachCode.isNotBlank()
val coachText = layout.findViewById<EditText>(R.id.coach_code).text
return clientCode.isNotBlank() && coachText.isNotBlank()
}
// Save answers to shared state and global value
private fun saveAnswers(clientCode: String, coachCode: String) {
GlobalValues.LAST_CLIENT_CODE = clientCode
answers["client_code"] = clientCode
answers["coach_code"] = coachCode
// Speichere garantierten Coach-Code aus Login bevorzugt
val loginCoach = TokenStore.getUsername(layout.context)
answers["coach_code"] = loginCoach ?: coachCode
}
// Required override but not used here
override fun saveAnswer() {
// Not used
}
private fun lockCoachField(field: EditText) {
field.isFocusable = false
field.isFocusableInTouchMode = false
field.isCursorVisible = false
field.keyListener = null
field.isLongClickable = false
field.isClickable = false
field.setBackgroundResource(R.drawable.bg_field_locked)
field.setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.ic_lock_24, 0)
field.compoundDrawablePadding = dp(8)
field.alpha = 0.95f
}
private fun dp(v: Int): Int =
(v * layout.resources.displayMetrics.density).toInt()
}

View File

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

@ -2,9 +2,19 @@ package com.dano.test1
import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.widget.*
import kotlinx.coroutines.*
import java.text.SimpleDateFormat
import java.util.*
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,
@ -12,7 +22,8 @@ class HandlerDateSpinner(
private val languageID: String,
private val goToNextQuestion: () -> Unit,
private val goToPreviousQuestion: () -> Unit,
private val showToast: (String) -> Unit
private val showToast: (String) -> Unit,
private val questionnaireMeta: String // neu für DB-Abfrage
) : QuestionHandler {
private lateinit var question: QuestionItem.DateSpinnerQuestion
@ -33,9 +44,22 @@ class HandlerDateSpinner(
val questionTextView = layout.findViewById<TextView>(R.id.question)
val textView = layout.findViewById<TextView>(R.id.textView)
val labelDay = layout.findViewById<TextView>(R.id.date_spinner_day)
val labelMonth = layout.findViewById<TextView>(R.id.date_spinner_month)
val labelYear = layout.findViewById<TextView>(R.id.date_spinner_year)
questionTextView.text = question.question?.let { LanguageManager.getText(languageID, it) } ?: ""
textView.text = question.textKey?.let { LanguageManager.getText(languageID, it) } ?: ""
// 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 {
parseSavedDate(answers[it] as? String)
} ?: Triple(null, null, null)
@ -50,10 +74,54 @@ class HandlerDateSpinner(
?: months[today.get(Calendar.MONTH)]
val defaultYear = savedYear ?: today.get(Calendar.YEAR)
// Spinner responsiv aufsetzen (Schrift + Zeilenhöhe ohne Abschneiden)
setupSpinner(spinnerDay, days, defaultDay)
setupSpinner(spinnerMonth, months, defaultMonth)
setupSpinner(spinnerYear, years, defaultYear)
// DB-Abfrage, falls noch nicht im answers-Map
val answerMapKey = question.question ?: (question.id ?: "")
if (answerMapKey.isNotBlank() && !answers.containsKey(answerMapKey)) {
CoroutineScope(Dispatchers.IO).launch {
try {
val clientCode = GlobalValues.LAST_CLIENT_CODE
if (clientCode.isNullOrBlank()) return@launch
val allAnswersForClient = MyApp.database.answerDao().getAnswersForClient(clientCode)
val myQuestionId = questionnaireMeta + "-" + question.question
val dbAnswer = allAnswersForClient.find { it.questionId == myQuestionId }?.answerValue
if (!dbAnswer.isNullOrBlank()) {
withContext(Dispatchers.Main) {
answers[answerMapKey] = dbAnswer
val (dbYear, dbMonthIndex, dbDay) = parseSavedDate(dbAnswer)
dbYear?.let { year ->
val index = years.indexOf(year)
if (index >= 0) spinnerYear.setSelection(index)
}
dbMonthIndex?.let { monthIndex ->
if (monthIndex in months.indices) {
val monthObj = months[monthIndex]
val idx = months.indexOf(monthObj)
if (idx >= 0) spinnerMonth.setSelection(idx)
}
}
dbDay?.let { day ->
val idx = days.indexOf(day)
if (idx >= 0) spinnerDay.setSelection(idx)
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
layout.findViewById<Button>(R.id.Qnext).setOnClickListener {
if (validate()) {
saveAnswer()
@ -139,16 +207,71 @@ class HandlerDateSpinner(
return sdf.parse(dateString)
}
// Textgröße prozentual zur Bildschirmhöhe (in sp)
private fun setTextSizePercentOfScreenHeight(view: TextView, percentOfHeight: Float) {
val dm = (view.context ?: layout.context).resources.displayMetrics
val sp = (dm.heightPixels * percentOfHeight) / dm.scaledDensity
TextViewCompat.setAutoSizeTextTypeWithDefaults(view, TextViewCompat.AUTO_SIZE_TEXT_TYPE_NONE)
view.setTextSize(TypedValue.COMPLEX_UNIT_SP, sp)
}
// Spinner-Adapter: Schrift & Zeilenhöhe dynamisch, kein Abschneiden
private fun <T> setupSpinner(spinner: Spinner, items: List<T>, defaultSelection: T?) {
val adapter = ArrayAdapter(context, android.R.layout.simple_spinner_item, items)
val dm = context.resources.displayMetrics
fun spFromScreenHeight(percent: Float): Float =
(dm.heightPixels * percent) / dm.scaledDensity
fun pxFromSp(sp: Float): Int = (sp * dm.scaledDensity).toInt()
val textSp = spFromScreenHeight(0.0275f) // ~2.75% der Bildschirmhöhe
val textPx = pxFromSp(textSp)
val vPadPx = (textPx * 0.50f).toInt() // vertikales Padding
val rowHeight = (textPx * 2.20f + 2 * vPadPx).toInt() // feste Zeilenhöhe
val adapter = object : ArrayAdapter<T>(context, android.R.layout.simple_spinner_item, items) {
private fun styleRow(tv: TextView, forceHeight: Boolean) {
tv.setTextSize(TypedValue.COMPLEX_UNIT_SP, textSp)
tv.includeFontPadding = true
tv.setLineSpacing(0f, 1.2f)
tv.gravity = (tv.gravity and android.view.Gravity.HORIZONTAL_GRAVITY_MASK) or android.view.Gravity.CENTER_VERTICAL
tv.setPadding(tv.paddingLeft, vPadPx, tv.paddingRight, vPadPx)
tv.minHeight = rowHeight
tv.isSingleLine = true
if (forceHeight) {
val lp = tv.layoutParams
if (lp == null || lp.height <= 0) {
tv.layoutParams = AbsListView.LayoutParams(
AbsListView.LayoutParams.MATCH_PARENT, rowHeight
)
} else {
lp.height = rowHeight
}
}
}
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val v = super.getView(position, convertView, parent) as TextView
styleRow(v, forceHeight = false)
return v
}
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
val v = super.getDropDownView(position, convertView, parent) as TextView
styleRow(v, forceHeight = true)
return v
}
}
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
spinner.adapter = adapter
spinner.setPadding(spinner.paddingLeft, vPadPx, spinner.paddingRight, vPadPx)
spinner.minimumHeight = rowHeight
spinner.requestLayout()
defaultSelection?.let {
val index = items.indexOf(it)
if (index >= 0) {
spinner.setSelection(index)
}
if (index >= 0) spinner.setSelection(index)
}
}
}

View File

@ -1,9 +1,20 @@
package com.dano.test1
import android.content.Context
import android.util.TypedValue
import android.view.Gravity
import android.view.View
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,
@ -12,14 +23,19 @@ class HandlerGlassScaleQuestion(
private val languageID: String,
private val goToNextQuestion: () -> Unit,
private val goToPreviousQuestion: () -> Unit,
private val showToast: (String) -> Unit
private val showToast: (String) -> Unit,
private val questionnaireMeta: String
) : QuestionHandler {
private lateinit var layout: View
private lateinit var question: QuestionItem.GlassScaleQuestion
private val scaleLabels = listOf(
"never_glass", "little_glass", "moderate_glass", "much_glass", "extreme_glass"
"never_glass",
"little_glass",
"moderate_glass",
"much_glass",
"extreme_glass"
)
private val pointsMap = mapOf(
@ -30,60 +46,98 @@ class HandlerGlassScaleQuestion(
"extreme_glass" to 4
)
private val glassIconForLabel = mapOf(
"never_glass" to R.drawable.ic_glass_0,
"little_glass" to R.drawable.ic_glass_1,
"moderate_glass" to R.drawable.ic_glass_2,
"much_glass" to R.drawable.ic_glass_3,
"extreme_glass" to R.drawable.ic_glass_4
)
override fun bind(layout: View, question: QuestionItem) {
if (question !is QuestionItem.GlassScaleQuestion) return
this.layout = layout
this.question = question
layout.findViewById<TextView>(R.id.textView).text =
question.textKey?.let { LanguageManager.getText(languageID, it) } ?: ""
val titleTv = layout.findViewById<TextView>(R.id.textView)
val questionTv = layout.findViewById<TextView>(R.id.question)
layout.findViewById<TextView>(R.id.question).text =
question.question?.let { LanguageManager.getText(languageID, it) } ?: ""
titleTv.text = question.textKey?.let { LanguageManager.getText(languageID, it) } ?: ""
questionTv.text = question.question?.let { LanguageManager.getText(languageID, it) } ?: ""
setTextSizePercentOfScreenHeight(titleTv, 0.03f)
setTextSizePercentOfScreenHeight(questionTv, 0.03f)
// feste Icon-Leiste
val header = layout.findViewById<LinearLayout>(R.id.glass_header)
header.removeAllViews()
header.addView(Space(context).apply {
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 4f)
})
val iconSizePx = (context.resources.displayMetrics.density * 36).toInt()
scaleLabels.forEach { labelKey ->
val cell = FrameLayout(context).apply {
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
val img = ImageView(context).apply {
setImageResource(glassIconForLabel[labelKey]!!)
layoutParams = FrameLayout.LayoutParams(iconSizePx, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.CENTER)
adjustViewBounds = true
scaleType = ImageView.ScaleType.FIT_CENTER
}
cell.addView(img)
header.addView(cell)
}
//
val tableLayout = layout.findViewById<TableLayout>(R.id.glass_table)
tableLayout.removeAllViews()
val headerRow = TableRow(context).apply {
layoutParams = TableLayout.LayoutParams(
TableLayout.LayoutParams.MATCH_PARENT,
TableLayout.LayoutParams.WRAP_CONTENT
)
gravity = Gravity.CENTER
}
val emptyCell = TextView(context).apply {
layoutParams = TableRow.LayoutParams(0, TableRow.LayoutParams.WRAP_CONTENT, 4f)
}
headerRow.addView(emptyCell)
scaleLabels.forEach { labelKey ->
val labelText = LanguageManager.getText(languageID, labelKey)
val labelView = TextView(context).apply {
text = labelText
gravity = Gravity.START
layoutParams = TableRow.LayoutParams(0, TableRow.LayoutParams.WRAP_CONTENT, 1f)
}
headerRow.addView(labelView)
}
tableLayout.addView(headerRow)
addSymptomRows(tableLayout)
// ggf. Antworten aus DB wiederherstellen
val anySymptomNeedsRestore = question.symptoms.any { !answers.containsKey(it) }
if (anySymptomNeedsRestore) {
CoroutineScope(Dispatchers.IO).launch {
try {
val clientCode = GlobalValues.LAST_CLIENT_CODE ?: return@launch
val allAnswersForClient = MyApp.database.answerDao().getAnswersForClient(clientCode)
val answerMap = allAnswersForClient.associateBy({ it.questionId }, { it.answerValue })
withContext(Dispatchers.Main) {
val table = tableLayout
for ((index, symptomKey) in question.symptoms.withIndex()) {
val answerMapKey = "$questionnaireMeta-$symptomKey"
val dbAnswer = answerMap[answerMapKey]?.takeIf { it.isNotBlank() }?.trim()
if (!answers.containsKey(symptomKey) && !dbAnswer.isNullOrBlank()) {
if (index < table.childCount) {
val row = table.getChildAt(index) as? TableRow ?: continue
val radioGroup = row.getChildAt(1) as? RadioGroup ?: continue
for (i in 0 until radioGroup.childCount) {
val rb = getRadioFromChild(radioGroup.getChildAt(i)) ?: continue
if ((rb.tag as? String)?.trim() == dbAnswer) {
rb.isChecked = true
break
}
}
answers[symptomKey] = dbAnswer
points.add(pointsMap[dbAnswer] ?: 0)
}
}
}
}
} catch (_: Exception) { /* ignore */ }
}
}
layout.findViewById<Button>(R.id.Qnext).setOnClickListener {
if (validate()) {
saveAnswer()
goToNextQuestion()
} else {
val message = LanguageManager.getText(languageID, "select_one_answer_per_row")
showToast(message)
showToast(LanguageManager.getText(languageID, "select_one_answer_per_row"))
}
}
layout.findViewById<Button>(R.id.Qprev).setOnClickListener {
goToPreviousQuestion()
}
layout.findViewById<Button>(R.id.Qprev).setOnClickListener { goToPreviousQuestion() }
}
private fun addSymptomRows(table: TableLayout) {
@ -101,67 +155,91 @@ class HandlerGlassScaleQuestion(
text = LanguageManager.getText(languageID, symptomKey)
layoutParams = TableRow.LayoutParams(0, TableRow.LayoutParams.WRAP_CONTENT, 4f)
setPadding(4, 16, 4, 16)
setTextSizePercentOfScreenHeight(this, 0.022f)
}
row.addView(symptomText)
val radioGroup = RadioGroup(context).apply {
orientation = RadioGroup.HORIZONTAL
layoutParams = TableRow.LayoutParams(0, TableRow.LayoutParams.WRAP_CONTENT, 5f)
setPadding(0, 0, 0, 0)
}
scaleLabels.forEach { labelKey ->
val radioButton = RadioButton(context).apply {
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
layoutParams =
RadioGroup.LayoutParams(0, RadioGroup.LayoutParams.WRAP_CONTENT, 1f)
gravity = Gravity.CENTER
setPadding(0, 0, 0, 0)
}
radioGroup.addView(radioButton)
rb.layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
Gravity.CENTER
)
cell.addView(rb)
radioGroup.addView(cell)
}
row.addView(radioGroup)
table.addView(row)
}
}
override fun validate(): Boolean {
val table = layout.findViewById<TableLayout>(R.id.glass_table)
for (i in 1 until table.childCount) {
for (i in 0 until table.childCount) {
val row = table.getChildAt(i) as TableRow
val radioGroup = row.getChildAt(1) as RadioGroup
if (radioGroup.checkedRadioButtonId == -1) {
return false
var anyChecked = false
for (j in 0 until radioGroup.childCount) {
val rb = getRadioFromChild(radioGroup.getChildAt(j)) ?: continue
if (rb.isChecked) { anyChecked = true; break }
}
if (!anyChecked) return false
}
return true
}
override fun saveAnswer() {
// Vorherige Punkte dieser Frage entfernen
question.symptoms.forEach {
val previousLabel = answers[it] as? String
val previousPoint = pointsMap[previousLabel]
if (previousPoint != null) {
points.remove(previousPoint)
}
// alte Punkte entfernen
question.symptoms.forEach { key ->
val prev = answers[key] as? String
prev?.let { pointsMap[it] }?.let { points.remove(it) }
}
val table = layout.findViewById<TableLayout>(R.id.glass_table)
for (i in 1 until table.childCount) {
for (i in 0 until table.childCount) {
val row = table.getChildAt(i) as TableRow
val symptomKey = question.symptoms[i - 1]
val symptomKey = question.symptoms[i]
val radioGroup = row.getChildAt(1) as RadioGroup
val checkedId = radioGroup.checkedRadioButtonId
if (checkedId != -1) {
val radioButton = radioGroup.findViewById<RadioButton>(checkedId)
val selectedLabel = radioButton.tag as String
answers[symptomKey] = selectedLabel
val point = pointsMap[selectedLabel] ?: 0
points.add(point)
for (j in 0 until radioGroup.childCount) {
val rb = getRadioFromChild(radioGroup.getChildAt(j)) ?: continue
if (rb.isChecked) {
val selected = rb.tag as String
answers[symptomKey] = selected
points.add(pointsMap[selected] ?: 0)
break
}
}
}
}
private fun getRadioFromChild(child: View): RadioButton? =
when (child) {
is RadioButton -> child
is FrameLayout -> child.getChildAt(0) as? RadioButton
else -> null
}
private fun setTextSizePercentOfScreenHeight(view: TextView, percentOfHeight: Float) {
val dm = (view.context ?: layout.context).resources.displayMetrics
val sp = (dm.heightPixels * percentOfHeight) / dm.scaledDensity
TextViewCompat.setAutoSizeTextTypeWithDefaults(view, TextViewCompat.AUTO_SIZE_TEXT_TYPE_NONE)
view.setTextSize(TypedValue.COMPLEX_UNIT_SP, sp)
}
}

View File

@ -4,6 +4,25 @@ import android.view.View
import android.widget.*
import android.text.Html
import kotlinx.coroutines.*
import android.util.TypedValue
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>,
@ -15,72 +34,113 @@ 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
currentQuestion = question as QuestionItem.LastPage
// Set localized text for the last page
layout.findViewById<TextView>(R.id.textView).text =
LanguageManager.getText(languageID, currentQuestion.textKey)
val titleTv = layout.findViewById<TextView>(R.id.textView)
val questionTv = layout.findViewById<TextView>(R.id.question)
val prevBtn = layout.findViewById<MaterialButton>(R.id.Qprev)
val finishBtn = layout.findViewById<MaterialButton>(R.id.Qfinish)
// Set question text with HTML formatting
layout.findViewById<TextView>(R.id.question).text =
Html.fromHtml(
LanguageManager.getText(languageID, currentQuestion.question),
Html.FROM_HTML_MODE_LEGACY
)
// Texte setzen
titleTv.text = LanguageManager.getText(languageID, currentQuestion.textKey)
questionTv.text = Html.fromHtml(
LanguageManager.getText(languageID, currentQuestion.question),
Html.FROM_HTML_MODE_LEGACY
)
// Setup previous button
layout.findViewById<Button>(R.id.Qprev).setOnClickListener {
goToPreviousQuestion()
}
// Finish-Button: Text + responsive Schrift
finishBtn.text = LanguageManager.getText(languageID, "save")
finishBtn.isAllCaps = false
applyResponsiveTextSizing(finishBtn)
// Setup finish button
layout.findViewById<Button>(R.id.Qfinish).setOnClickListener {
showLoading(true) // Show loading indicator
// Überschriften responsiv skalieren (wie zuvor)
setTextSizePercentOfScreenHeight(titleTv, 0.03f)
setTextSizePercentOfScreenHeight(questionTv, 0.03f)
// Buttons
prevBtn.setOnClickListener { goToPreviousQuestion() }
finishBtn.setOnClickListener {
showLoading(true)
// Save answers on a background thread
CoroutineScope(Dispatchers.IO).launch {
val startTime = System.currentTimeMillis()
// Save answers to database (suspend function)
// Antworten speichern
saveAnswersToDatabase(answers)
// Calculate total points and update global value
// Punkte summieren
GlobalValues.INTEGRATION_INDEX = sumPoints()
// Save last client code globally if available
// 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
// Ensure loading animation runs at least 2 seconds
val elapsedTime = System.currentTimeMillis() - startTime
if (elapsedTime < minLoadingTimeMs) {
delay(minLoadingTimeMs - elapsedTime)
if (clientCode != null) {
GlobalValues.LAST_CLIENT_CODE = clientCode
GlobalValues.LOADED_CLIENT_CODE = clientCode // <— zusätzlich setzen
}
// Switch back to main thread to update UI
// min. Ladezeit einhalten (ruhiges UX)
val elapsedTime = System.currentTimeMillis() - startTime
if (elapsedTime < minLoadingTimeMs) delay(minLoadingTimeMs - elapsedTime)
withContext(Dispatchers.Main) {
showLoading(false) // Hide loading indicator
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 // No validation needed on last page
override fun saveAnswer() {} // No answers to save here
override fun validate(): Boolean = true
override fun saveAnswer() {}
private fun applyResponsiveTextSizing(btn: MaterialButton) {
// Max-/Min-Sp anhand der Bildschirmhöhe (in sp) berechnen
val dm = btn.resources.displayMetrics
val maxSp = (dm.heightPixels * 0.028f) / dm.scaledDensity // ~2.8% der Höhe
val minSp = (dm.heightPixels * 0.018f) / dm.scaledDensity // ~1.8% der Höhe
// AutoSize aktivieren (schrumpft/expandiert den Text innerhalb des Buttons)
TextViewCompat.setAutoSizeTextTypeUniformWithConfiguration(
btn,
minSp.toInt(),
maxSp.toInt(),
1,
TypedValue.COMPLEX_UNIT_SP
)
btn.setSingleLine(true)
btn.maxLines = 1
btn.isAllCaps = false
// Padding nach Layout proportional zur Button-Höhe setzen (wirkt auf Lesbarkeit)
btn.post {
val padH = (btn.height * 0.18f).toInt()
val padV = (btn.height * 0.12f).toInt()
btn.setPadding(padH, padV, padH, padV)
}
}
// ----------------------------------------------------------------
// Helper: Textgröße prozentual zur Bildschirmhöhe setzen (in sp)
private fun setTextSizePercentOfScreenHeight(view: TextView, percentOfHeight: Float) {
val dm = (view.context ?: layout.context).resources.displayMetrics
val sp = (dm.heightPixels * percentOfHeight) / dm.scaledDensity
TextViewCompat.setAutoSizeTextTypeWithDefaults(view, TextViewCompat.AUTO_SIZE_TEXT_TYPE_NONE)
view.setTextSize(TypedValue.COMPLEX_UNIT_SP, sp)
}
// Calculate the sum of all keys ending with "_points"
private fun sumPoints(): Int =
answers.filterKeys { it.endsWith("_points") }
.values.mapNotNull { it as? Int }
.sum()
// Show or hide a ProgressBar (loading spinner)
private fun showLoading(show: Boolean) {
val progressBar = layout.findViewById<ProgressBar>(R.id.progressBar)
val finishButton = layout.findViewById<Button>(R.id.Qfinish)

View File

@ -3,6 +3,14 @@ package com.dano.test1
import android.content.Context
import android.view.View
import android.widget.*
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,
@ -11,7 +19,8 @@ class HandlerMultiCheckboxQuestion(
private val languageID: String,
private val goToNextQuestion: () -> Unit,
private val goToPreviousQuestion: () -> Unit,
private val showToast: (String) -> Unit
private val showToast: (String) -> Unit,
private val questionnaireMeta: String //
) : QuestionHandler {
private lateinit var layout: View
@ -25,21 +34,40 @@ class HandlerMultiCheckboxQuestion(
val questionTitle = layout.findViewById<TextView>(R.id.question)
val questionTextView = layout.findViewById<TextView>(R.id.textView)
// Hier jetzt identisch zur RadioQuestion:
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)
setTextSizePercentOfScreenHeight(questionTextView, 0.03f) // Überschrift
setTextSizePercentOfScreenHeight(questionTitle, 0.03f) // Frage
container.removeAllViews()
// bestehende Auswahl aus answers (falls vorhanden) als Set
val selectedKeys = this.question.question?.let {
(answers[it] as? List<*>)?.map { it.toString() }?.toSet()
} ?: emptySet()
// 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 {
text = LanguageManager.getText(languageID, option.key)
tag = option.key
isChecked = selectedKeys.contains(option.key)
// Textgröße prozentual & Zeilenhöhe/Padding für Lesbarkeit
TextViewCompat.setAutoSizeTextTypeWithDefaults(this, TextViewCompat.AUTO_SIZE_TEXT_TYPE_NONE)
setTextSize(TypedValue.COMPLEX_UNIT_SP, cbTextSp)
includeFontPadding = true
setPadding(paddingLeft, cbPadV, paddingRight, cbPadV)
minHeight = cbMinH
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
@ -52,6 +80,44 @@ class HandlerMultiCheckboxQuestion(
container.addView(checkBox)
}
//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 {
try {
val clientCode = GlobalValues.LAST_CLIENT_CODE
if (clientCode.isNullOrBlank()) return@launch
val allAnswersForClient = MyApp.database.answerDao().getAnswersForClient(clientCode)
val myQuestionId = questionnaireMeta + "-" + question.question
val dbAnswer = allAnswersForClient.find { it.questionId == myQuestionId }?.answerValue
if (!dbAnswer.isNullOrBlank()) {
val parsed = parseMultiAnswer(dbAnswer)
withContext(Dispatchers.Main) {
// UI: Checkboxen setzen
for (i in 0 until container.childCount) {
val cb = container.getChildAt(i) as? CheckBox ?: continue
cb.isChecked = parsed.contains(cb.tag.toString())
}
// answers-Map aktualisieren
answers[answerMapKey] = parsed.toList()
// Punkte berechnen und hinzufügen
val totalPoints = parsed.sumOf { key ->
question.pointsMap?.get(key) ?: 0
}
points.add(totalPoints)
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
layout.findViewById<Button>(R.id.Qnext).setOnClickListener {
if (validate()) {
saveAnswer()
@ -97,8 +163,51 @@ class HandlerMultiCheckboxQuestion(
}
question.question?.let { questionKey ->
// ggf. alte Punkte entfernen
val oldList = answers[questionKey] as? List<*>
if (oldList != null) {
val oldTotal = oldList.mapNotNull { it?.toString() }.sumOf { oldKey ->
question.pointsMap?.get(oldKey) ?: 0
}
points.remove(oldTotal)
}
answers[questionKey] = selectedKeys
points.add(totalPoints)
}
}
private fun parseMultiAnswer(dbAnswer: String): Set<String> {
val trimmed = dbAnswer.trim()
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
val inner = trimmed.substring(1, trimmed.length - 1)
if (inner.isBlank()) return emptySet()
return inner.split(",")
.map { it.trim().trim('"', '\'') }
.filter { it.isNotEmpty() }
.toSet()
}
val separator = when {
trimmed.contains(",") -> ","
trimmed.contains(";") -> ";"
else -> null
}
return if (separator != null) {
trimmed.split(separator)
.map { it.trim().trim('"', '\'') }
.filter { it.isNotEmpty() }
.toSet()
} else {
setOf(trimmed.trim().trim('"', '\''))
}
}
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)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,14 @@ import android.content.Context
import android.view.View
import android.text.Html
import android.widget.*
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,
@ -13,7 +21,8 @@ class HandlerRadioQuestion(
private val goToNextQuestion: () -> Unit,
private val goToPreviousQuestion: () -> Unit,
private val goToQuestionById: (String) -> Unit,
private val showToast: (String) -> Unit
private val showToast: (String) -> Unit,
private val questionnaireMeta: String
) : QuestionHandler {
private lateinit var layout: View
@ -32,6 +41,11 @@ class HandlerRadioQuestion(
Html.fromHtml(LanguageManager.getText(languageID, it), Html.FROM_HTML_MODE_LEGACY)
} ?: ""
//
// Titel/Frage: 3% der Bildschirmhöhe
setTextSizePercentOfScreenHeight(questionTextView, 0.03f)
setTextSizePercentOfScreenHeight(questionTitle, 0.03f)
// ===================================================
radioGroup.removeAllViews()
@ -39,6 +53,10 @@ class HandlerRadioQuestion(
val radioButton = RadioButton(context).apply {
text = LanguageManager.getText(languageID, option.key)
tag = option.key
// RadioButton-Text analog zu EditTexts: 2.5% der Bildschirmhöhe
setTextSizePercentOfScreenHeight(this, 0.025f)
layoutParams = RadioGroup.LayoutParams(
RadioGroup.LayoutParams.MATCH_PARENT,
RadioGroup.LayoutParams.WRAP_CONTENT
@ -47,12 +65,53 @@ class HandlerRadioQuestion(
val margin = (16 * scale + 0.5f).toInt()
setMargins(0, 0, 0, margin)
}
val padding = (12 * resources.displayMetrics.density).toInt()
setPadding(padding, padding, padding, padding)
}
radioGroup.addView(radioButton)
}
restorePreviousAnswer(radioGroup)
val answerMapKey = question.question ?: (question.id ?: "")
if (answerMapKey.isNotBlank() && !answers.containsKey(answerMapKey)) {
CoroutineScope(Dispatchers.IO).launch {
try {
val clientCode = GlobalValues.LAST_CLIENT_CODE
if (clientCode.isNullOrBlank()) return@launch
val allAnswersForClient = MyApp.database.answerDao().getAnswersForClient(clientCode)
val myQuestionId = questionnaireMeta + "-" + question.question
val dbAnswer = allAnswersForClient.find { it.questionId == myQuestionId }?.answerValue
if (!dbAnswer.isNullOrBlank()) {
withContext(Dispatchers.Main) {
val oldAnswerKey = answers[answerMapKey] as? String
val oldPoint = oldAnswerKey?.let { question.pointsMap?.get(it) } ?: 0
if (oldAnswerKey != null) {
points.remove(oldPoint)
}
for (i in 0 until radioGroup.childCount) {
val radioButton = radioGroup.getChildAt(i) as RadioButton
if (radioButton.tag == dbAnswer) {
radioButton.isChecked = true
break
}
}
answers[answerMapKey] = dbAnswer
val newPoint = question.pointsMap?.get(dbAnswer) ?: 0
points.add(newPoint)
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
layout.findViewById<Button>(R.id.Qnext).setOnClickListener {
if (validate()) {
saveAnswer()
@ -78,6 +137,15 @@ class HandlerRadioQuestion(
}
}
// setzt Textgröße prozentual zur Bildschirmhöhe (in sp)
private fun setTextSizePercentOfScreenHeight(view: TextView, percentOfHeight: Float) {
val dm = (view.context ?: layout.context).resources.displayMetrics
val sp = (dm.heightPixels * percentOfHeight) / dm.scaledDensity
TextViewCompat.setAutoSizeTextTypeWithDefaults(view, TextViewCompat.AUTO_SIZE_TEXT_TYPE_NONE)
view.setTextSize(TypedValue.COMPLEX_UNIT_SP, sp)
}
// ————————————————————————————————————————————————————————————————
private fun restorePreviousAnswer(radioGroup: RadioGroup) {
question.question?.let { questionKey ->
val savedAnswer = answers[questionKey] as? String
@ -104,10 +172,8 @@ class HandlerRadioQuestion(
val answerKey = selectedRadioButton.tag.toString()
question.question?.let { questionKey ->
val oldAnswerKey = answers[questionKey] as? String
val oldPoint = oldAnswerKey?.let { question.pointsMap?.get(it) } ?: 0
points.remove(oldPoint)
answers[questionKey] = answerKey
@ -116,6 +182,4 @@ class HandlerRadioQuestion(
points.add(newPoint)
}
}
}

View File

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

View File

@ -2,7 +2,19 @@ package com.dano.test1
import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.widget.*
import kotlinx.coroutines.*
import android.util.TypedValue
import 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,
@ -11,7 +23,8 @@ class HandlerValueSpinner(
private val goToNextQuestion: () -> Unit,
private val goToPreviousQuestion: () -> Unit,
private val goToQuestionById: (String) -> Unit,
private val showToast: (String) -> Unit
private val showToast: (String) -> Unit,
private val questionnaireMeta: String
) : QuestionHandler {
private lateinit var layout: View
@ -30,6 +43,11 @@ 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
// 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) {
(question.range.min..question.range.max).map { it.toString() }
@ -40,6 +58,31 @@ class HandlerValueSpinner(
val savedValue = question.question?.let { answers[it] as? String }
setupSpinner(spinner, spinnerItems, savedValue)
//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 {
try {
val clientCode = GlobalValues.LAST_CLIENT_CODE
if (clientCode.isNullOrBlank()) return@launch
val allAnswersForClient = MyApp.database.answerDao().getAnswersForClient(clientCode)
val myQuestionId = questionnaireMeta + "-" + question.question
val dbAnswer = allAnswersForClient.find { it.questionId == myQuestionId }?.answerValue
if (!dbAnswer.isNullOrBlank()) {
withContext(Dispatchers.Main) {
answers[answerMapKey] = dbAnswer
val index = spinnerItems.indexOf(dbAnswer)
if (index >= 0) spinner.setSelection(index)
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
layout.findViewById<Button>(R.id.Qnext).setOnClickListener {
if (validate()) {
saveAnswer()
@ -84,14 +127,72 @@ class HandlerValueSpinner(
}
}
// 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 adapter = ArrayAdapter(context, android.R.layout.simple_spinner_item, items)
val dm = context.resources.displayMetrics
fun spFromScreenHeight(percent: Float): Float =
(dm.heightPixels * percent) / dm.scaledDensity
fun pxFromSp(sp: Float): Int = (sp * dm.scaledDensity).toInt()
// Schrift & abgeleitete Höhen
val textSp = spFromScreenHeight(0.0275f) // ~2.75% der Bildschirmhöhe
val textPx = pxFromSp(textSp)
val vPadPx = (textPx * 0.50f).toInt() // vertikales Padding
val rowHeight = (textPx * 2.20f + 2 * vPadPx).toInt() // feste Zeilenhöhe
val adapter = object : ArrayAdapter<T>(context, android.R.layout.simple_spinner_item, items) {
private fun styleRow(tv: TextView, forceHeight: Boolean) {
tv.setTextSize(TypedValue.COMPLEX_UNIT_SP, textSp)
tv.includeFontPadding = true
tv.setLineSpacing(0f, 1.2f)
tv.gravity = (tv.gravity and android.view.Gravity.HORIZONTAL_GRAVITY_MASK) or android.view.Gravity.CENTER_VERTICAL
tv.setPadding(tv.paddingLeft, vPadPx, tv.paddingRight, vPadPx)
tv.minHeight = rowHeight
tv.isSingleLine = true
if (forceHeight) {
val lp = tv.layoutParams
if (lp == null || lp.height <= 0) {
tv.layoutParams = AbsListView.LayoutParams(
AbsListView.LayoutParams.MATCH_PARENT, rowHeight
)
} else {
lp.height = rowHeight
}
}
}
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val v = super.getView(position, convertView, parent) as TextView
styleRow(v, forceHeight = false) // ausgewählte Ansicht
return v
}
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
val v = super.getDropDownView(position, convertView, parent) as TextView
styleRow(v, forceHeight = true) // Dropdown-Zeilen: Höhe erzwingen
return v
}
}
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
spinner.adapter = adapter
// Spinner selbst ausreichend hoch machen
spinner.setPadding(spinner.paddingLeft, vPadPx, spinner.paddingRight, vPadPx)
spinner.minimumHeight = rowHeight
spinner.requestLayout()
selectedItem?.let {
val index = items.indexOf(it)
if (index >= 0) spinner.setSelection(index)
}
}
}

View File

@ -0,0 +1,87 @@
package com.dano.test1
import android.content.Context
import android.util.Log
import android.widget.Toast
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import org.json.JSONArray
import java.nio.charset.Charset
/*
Zweck:
- Liefert die Reihenfolge/IDs der zu exportierenden Header (Spalten) für den Excel-Export.
- Bevorzugte Quelle ist eine Excel-Datei aus den App-Assets („header_order.xlsx“), als Fallback wird eine JSON-Datei („header_order.json“) genutzt.
*/
class HeaderOrderRepository(
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 }
val fromXlsx = runCatching { loadOrderedIdsFromExcel("header_order.xlsx") }
.onFailure { e -> Log.w(tag, "header_order.xlsx konnte nicht gelesen werden: ${e.message}") }
.getOrElse { emptyList() }
if (fromXlsx.isNotEmpty()) {
orderedIdsCache = fromXlsx
return fromXlsx
}
return try {
val stream = context.assets.open("header_order.json")
val json = stream.readBytes().toString(Charset.forName("UTF-8"))
val arr = JSONArray(json)
val list = MutableList(arr.length()) { i -> arr.getString(i) }
orderedIdsCache = list
list
} catch (e: Exception) {
Log.e(tag, "Weder header_order.xlsx noch header_order.json verfügbar/gültig: ${e.message}")
Toast.makeText(context, t("no_header_template_found"), Toast.LENGTH_LONG).show()
emptyList()
}
}
private fun loadOrderedIdsFromExcel(assetFileName: String): List<String> {
context.assets.open(assetFileName).use { input ->
XSSFWorkbook(input).use { wb ->
val sheet = wb.getSheetAt(0) ?: return emptyList()
val row = sheet.getRow(0) ?: return emptyList()
val first = row.firstCellNum.toInt()
val last = row.lastCellNum.toInt() // exklusiv
val out = mutableListOf<String>()
for (i in first until last) {
val cell = row.getCell(i) ?: continue
val value = when (cell.cellType) {
org.apache.poi.ss.usermodel.CellType.STRING -> cell.stringCellValue
org.apache.poi.ss.usermodel.CellType.NUMERIC ->
if (org.apache.poi.ss.usermodel.DateUtil.isCellDateFormatted(cell))
cell.dateCellValue.time.toString()
else {
val n = cell.numericCellValue
if (n % 1.0 == 0.0) n.toLong().toString() else n.toString()
}
org.apache.poi.ss.usermodel.CellType.BOOLEAN -> cell.booleanCellValue.toString()
org.apache.poi.ss.usermodel.CellType.FORMULA -> cell.richStringCellValue.string
else -> ""
}.trim()
if (value.isEmpty()) continue
if (i == first && value == "#") continue // „#“ in Spalte 0 ignorieren
out.add(value)
}
return out
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,158 @@
package com.dano.test1
import android.widget.Button
import android.widget.EditText
import android.widget.Toast
import kotlinx.coroutines.*
import com.dano.test1.data.CompletedQuestionnaire
class LoadButtonHandler(
private val activity: MainActivity,
private val loadButton: Button,
private val editText: EditText,
private val languageIDProvider: () -> String,
private val questionnaireEntriesProvider: () -> List<QuestionItem.QuestionnaireEntry>,
private val dynamicButtonsProvider: () -> List<Button>,
private val buttonPoints: MutableMap<String, Int>,
private val updateButtonTexts: () -> Unit,
private val setButtonsEnabled: (List<Button>) -> Unit,
private val updateMainButtonsState: (Boolean) -> Unit,
) {
fun setup() {
loadButton.text = LanguageManager.getText(languageIDProvider(), "load")
loadButton.setOnClickListener { handleLoadButton() }
}
private fun handleLoadButton() {
val inputText = editText.text.toString().trim()
if (inputText.isBlank()) {
val message = LanguageManager.getText(languageIDProvider(), "please_client_code")
Toast.makeText(activity, message, Toast.LENGTH_SHORT).show()
return
}
buttonPoints.clear()
setButtonsEnabled(emptyList()) // temporär sperren
updateButtonTexts() // Chips zeigen vorläufig „Gesperrt“
val clientCode = inputText
GlobalValues.LAST_CLIENT_CODE = clientCode
CoroutineScope(Dispatchers.IO).launch {
val client = MyApp.database.clientDao().getClientByCode(clientCode)
if (client == null) {
GlobalValues.LOADED_CLIENT_CODE = null
withContext(Dispatchers.Main) {
val message = LanguageManager.getText(languageIDProvider(), "no_profile")
Toast.makeText(activity, message, Toast.LENGTH_LONG).show()
val questionnaireEntries = questionnaireEntriesProvider()
val alwaysButtons = questionnaireEntries.mapIndexedNotNull { idx, entry ->
val btn = dynamicButtonsProvider().getOrNull(idx)
if (entry.condition is QuestionItem.Condition.AlwaysAvailable) btn else null
}
setButtonsEnabled(alwaysButtons)
updateButtonTexts() // <- nach dem Aktivieren Chips aktualisieren
}
return@launch
}
GlobalValues.LOADED_CLIENT_CODE = clientCode
withContext(Dispatchers.Main) { updateMainButtonsState(true) }
handleNormalLoad(clientCode)
}
}
private suspend fun evaluateCondition(
condition: QuestionItem.Condition?,
clientCode: String,
completedEntries: List<CompletedQuestionnaire>
): Boolean {
if (condition == null) return false
return when (condition) {
is QuestionItem.Condition.AlwaysAvailable -> true
is QuestionItem.Condition.RequiresCompleted -> {
val normalizedCompleted = completedEntries.map { normalizeQuestionnaireId(it.questionnaireId) }
condition.required.all { req ->
val nReq = normalizeQuestionnaireId(req)
normalizedCompleted.any { it.contains(nReq) || nReq.contains(it) }
}
}
is QuestionItem.Condition.QuestionCondition -> {
val answers = MyApp.database.answerDao().getAnswersForClientAndQuestionnaire(clientCode, condition.questionnaire)
val relevant = answers.find { it.questionId.endsWith(condition.questionId, ignoreCase = true) }
val answerValue = relevant?.answerValue ?: ""
when (condition.operator) {
"==" -> answerValue == condition.value
"!=" -> answerValue != condition.value
else -> false
}
}
is QuestionItem.Condition.Combined -> {
val normalizedCompleted = completedEntries.map { normalizeQuestionnaireId(it.questionnaireId) }
val reqOk = condition.requiresCompleted.isNullOrEmpty() || condition.requiresCompleted.all { req ->
val nReq = normalizeQuestionnaireId(req)
normalizedCompleted.any { it.contains(nReq) || nReq.contains(it) }
}
if (!reqOk) return false
val q = condition.questionCheck ?: return true
val answers = MyApp.database.answerDao().getAnswersForClientAndQuestionnaire(clientCode, q.questionnaire)
val relevant = answers.find { it.questionId.endsWith(q.questionId, ignoreCase = true) }
val answerValue = relevant?.answerValue ?: ""
when (q.operator) {
"==" -> answerValue == q.value
"!=" -> answerValue != q.value
else -> false
}
}
is QuestionItem.Condition.AnyOf -> {
condition.conditions.any { evaluateCondition(it, clientCode, completedEntries) }
}
}
}
private fun normalizeQuestionnaireId(name: String): String =
name.lowercase().removeSuffix(".json")
private suspend fun handleNormalLoad(clientCode: String) {
val completedEntries = withContext(Dispatchers.IO) {
MyApp.database.completedQuestionnaireDao().getAllForClient(clientCode)
}
buttonPoints.clear()
for (entry in completedEntries) {
if (entry.isDone) {
buttonPoints[entry.questionnaireId] = entry.sumPoints ?: 0
if (entry.questionnaireId.contains("questionnaire_2_rhs", ignoreCase = true)) {
RHS_POINTS = entry.sumPoints
}
}
}
val enabledButtons = mutableListOf<Button>()
val questionnaireEntries = questionnaireEntriesProvider()
val dynamicButtons = dynamicButtonsProvider()
for ((idx, entry) in questionnaireEntries.withIndex()) {
val button = dynamicButtons.getOrNull(idx) ?: continue
val isCompleted = completedEntries.any { completed ->
normalizeQuestionnaireId(completed.questionnaireId).let { completedNorm ->
val targetNorm = normalizeQuestionnaireId(entry.file)
(completedNorm.contains(targetNorm) || targetNorm.contains(completedNorm)) && completed.isDone
}
}
if (isCompleted) continue
val condMet = evaluateCondition(entry.condition, clientCode, completedEntries)
if (condMet) enabledButtons.add(button)
}
withContext(Dispatchers.Main) {
setButtonsEnabled(enabledButtons) // erst aktivieren …
updateButtonTexts() // … dann Chips/Labels korrekt setzen
}
}
}

View File

@ -0,0 +1,218 @@
package com.dano.test1
import android.app.AlertDialog
import android.content.Context
import android.util.Log
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
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
object LoginManager {
private const val SERVER_LOGIN_URL = "https://daniel-ocks.de/qdb/login.php"
private const val SERVER_CHANGE_URL = "https://daniel-ocks.de/qdb/change_password.php"
private val client = OkHttpClient()
fun loginUserWithCredentials(
context: Context,
username: String,
password: String,
onSuccess: (String) -> Unit,
onError: (String) -> Unit
) {
CoroutineScope(Dispatchers.IO).launch {
try {
val bodyJson = JSONObject()
.put("username", username)
.put("password", password)
.toString()
.toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url(SERVER_LOGIN_URL)
.post(bodyJson)
.build()
val response = client.newCall(request).execute()
val text = response.body?.string()
if (!response.isSuccessful || text == null) {
withContext(Dispatchers.Main) { onError("Fehler beim Login (${response.code})") }
return@launch
}
val json = JSONObject(text)
if (!json.optBoolean("success")) {
withContext(Dispatchers.Main) { onError(json.optString("message", "Login fehlgeschlagen")) }
return@launch
}
// Passwortwechsel erforderlich?
if (json.optBoolean("must_change_password", false)) {
withContext(Dispatchers.Main) {
showChangePasswordDialog(
context = context,
username = username,
oldPassword = password,
onChanged = { token ->
// Nach PW-Änderung direkt eingeloggt
TokenStore.save(context, token, username)
onSuccess(token)
},
onError = onError
)
}
return@launch
}
// normaler Login: Token speichern
val token = json.getString("token")
TokenStore.save(context, token, username)
withContext(Dispatchers.Main) { onSuccess(token) }
} catch (e: Exception) {
Log.e("LOGIN", "Exception", e)
withContext(Dispatchers.Main) { onError("Exception: ${e.message}") }
}
}
}
private fun showChangePasswordDialog(
context: Context,
username: String,
oldPassword: String,
onChanged: (String) -> Unit,
onError: (String) -> Unit
) {
val container = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setPadding(48, 24, 48, 0)
}
val etNew = EditText(context).apply {
hint = "Neues Passwort"
inputType = 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

@ -1,7 +1,19 @@
package com.dano.test1
import android.content.res.Configuration
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Bundle
import android.view.View
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import java.io.File
class MainActivity : AppCompatActivity() {
@ -10,18 +22,189 @@ class MainActivity : AppCompatActivity() {
var isInQuestionnaire: Boolean = false
var isFirstQuestionnairePage: Boolean = false
private var progress: ProgressBar? = null
// 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)
// Initialize the opening screen handler and show the opening screen
openingScreenHandler = HandlerOpeningScreen(this)
openingScreenHandler.init()
// === Offline-Start ermöglichen ===
// Bedingung: gespeicherter User/Token UND lokale DB vorhanden -> direkt OpeningScreen
val hasCreds = !TokenStore.getUsername(this).isNullOrBlank() && !TokenStore.getToken(this).isNullOrBlank()
val hasDb = hasLocalDb()
if (hasCreds && hasDb) {
openingScreenHandler = HandlerOpeningScreen(this)
openingScreenHandler.init()
return
}
// Sonst: Login-Dialog -> Login -> DB (einmalig) laden -> OpeningScreen
showLoginThenDownload()
}
/** Prüft, ob die lokale DB-Datei vorhanden ist. */
private fun hasLocalDb(): Boolean {
val dbFile = getDatabasePath("questionnaire_database")
return dbFile != null && dbFile.exists() && dbFile.length() > 0
}
/** Zeigt den Login-Dialog an, führt Login aus und lädt danach einmalig die DB. */
private fun showLoginThenDownload() {
val container = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(20), dp(8), dp(20), 0)
}
val etUser = EditText(this).apply {
hint = t("username_hint")
setSingleLine()
}
val etPass = EditText(this).apply {
hint = t("password_hint")
setSingleLine()
inputType = android.text.InputType.TYPE_CLASS_TEXT or
android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD
}
container.addView(etUser)
container.addView(etPass)
val dialog = AlertDialog.Builder(this)
.setTitle(t("login_required_title"))
.setView(container)
.setCancelable(false)
.setPositiveButton(t("login_btn")) { _, _ ->
val user = etUser.text.toString().trim()
val pass = etPass.text.toString()
if (user.isEmpty() || pass.isEmpty()) {
Toast.makeText(this, t("please_username_password"), Toast.LENGTH_SHORT).show()
showLoginThenDownload()
return@setPositiveButton
}
showBusy(true)
LoginManager.loginUserWithCredentials(
context = this,
username = user,
password = pass,
onSuccess = { token ->
// Nach erfolgreichem Login: einmalig komplette DB ziehen
DatabaseDownloader.downloadAndReplaceDatabase(
context = this,
token = token
) { ok ->
showBusy(false)
// Wenn Download fehlgeschlagen ist, aber evtl. schon eine DB lokal liegt,
// lassen wir den Nutzer trotzdem weiterarbeiten (Offline).
if (!ok && !hasLocalDb()) {
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, t("download_failed_use_offline"), Toast.LENGTH_LONG).show()
}
// Opening-Screen starten
openingScreenHandler = HandlerOpeningScreen(this)
openingScreenHandler.init()
openingScreenHandler.refreshHeaderStatusLive()
}
},
onError = { msg ->
showBusy(false)
val txt = t("login_failed_with_reason").replace("{reason}", msg ?: "")
Toast.makeText(this, txt, Toast.LENGTH_LONG).show()
showLoginThenDownload()
}
)
}
.setNegativeButton(t("exit_btn")) { _, _ -> finishAffinity() }
.create()
dialog.show()
}
private fun showBusy(show: Boolean) {
if (show) {
if (progress == null) {
progress = ProgressBar(this).apply {
isIndeterminate = true
(window?.decorView as? android.view.ViewGroup)?.addView(this)
}
}
progress?.visibility = View.VISIBLE
} else {
progress?.visibility = View.GONE
}
}
private fun dp(v: Int): Int = (v * resources.displayMetrics.density).toInt()
// --- LIVE NETZSTATUS (optional, für deine Status-Leiste) ---
override fun onResume() {
super.onResume()
registerNetworkCallback()
if (::openingScreenHandler.isInitialized && !isInQuestionnaire) {
openingScreenHandler.refreshHeaderStatusLive()
}
}
override fun onPause() {
super.onPause()
unregisterNetworkCallback()
}
private fun registerNetworkCallback() {
if (netCb != null) return
val cm = getSystemService(ConnectivityManager::class.java)
val req = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
netCb = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
runOnUiThread {
if (::openingScreenHandler.isInitialized && !isInQuestionnaire) {
openingScreenHandler.refreshHeaderStatusLive()
}
}
}
override fun onLost(network: Network) {
runOnUiThread {
if (::openingScreenHandler.isInitialized && !isInQuestionnaire) {
openingScreenHandler.refreshHeaderStatusLive()
}
}
}
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
runOnUiThread {
if (::openingScreenHandler.isInitialized && !isInQuestionnaire) {
openingScreenHandler.refreshHeaderStatusLive()
}
}
}
}
cm.registerNetworkCallback(req, netCb!!)
}
private fun unregisterNetworkCallback() {
val cb = netCb ?: return
val cm = getSystemService(ConnectivityManager::class.java)
cm.unregisterNetworkCallback(cb)
netCb = null
}
// --- /LIVE NETZSTATUS ---
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
}
/**
* Starts the given questionnaire and attaches it to this activity.
* @param questionnaire The questionnaire instance to start.
* @param languageID The language identifier for localization.
*/
fun startQuestionnaire(questionnaire: QuestionnaireBase<*>, languageID: String) {
isInQuestionnaire = true
isFirstQuestionnairePage = true
@ -29,24 +212,17 @@ class MainActivity : AppCompatActivity() {
questionnaire.startQuestionnaire()
}
/**
* Handle the back button press.
* If the openingScreenHandler can handle it, do not call super.
* Otherwise, call the default back press behavior.
*/
override fun onBackPressed() {
if (!openingScreenHandler.onBackPressed()) {
if (!::openingScreenHandler.isInitialized || !openingScreenHandler.onBackPressed()) {
super.onBackPressed()
}
}
/**
* Finish the questionnaire and return to the opening screen.
*/
fun finishQuestionnaire() {
// For example, switch back to the opening screen:
isInQuestionnaire = false
isFirstQuestionnairePage = false
openingScreenHandler.init()
if (::openingScreenHandler.isInitialized) {
openingScreenHandler.init()
}
}
}

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

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

View File

@ -155,14 +155,14 @@ abstract class QuestionnaireBase<T> {
protected open fun createHandlerForQuestion(question: QuestionItem): QuestionHandler? {
return when (question) {
is QuestionItem.RadioQuestion -> HandlerRadioQuestion(context, answers, points, languageID, ::goToNextQuestion, ::goToPreviousQuestion, ::goToQuestionById, ::showToast)
is QuestionItem.RadioQuestion -> HandlerRadioQuestion(context, answers, points, languageID, ::goToNextQuestion, ::goToPreviousQuestion, ::goToQuestionById, ::showToast, questionnaireMeta.id)
is QuestionItem.ClientCoachCodeQuestion -> HandlerClientCoachCode(answers, languageID, ::goToNextQuestion, ::goToPreviousQuestion, ::showToast)
is QuestionItem.DateSpinnerQuestion -> HandlerDateSpinner(context, answers, languageID, ::goToNextQuestion, ::goToPreviousQuestion, ::showToast)
is QuestionItem.ValueSpinnerQuestion -> HandlerValueSpinner(context, answers, languageID, ::goToNextQuestion, ::goToPreviousQuestion, ::goToQuestionById, ::showToast)
is QuestionItem.GlassScaleQuestion -> HandlerGlassScaleQuestion(context, answers, points, languageID, ::goToNextQuestion, ::goToPreviousQuestion, ::showToast)
is QuestionItem.DateSpinnerQuestion -> HandlerDateSpinner(context, answers, languageID, ::goToNextQuestion, ::goToPreviousQuestion, ::showToast, questionnaireMeta.id)
is QuestionItem.ValueSpinnerQuestion -> HandlerValueSpinner(context, answers, languageID, ::goToNextQuestion, ::goToPreviousQuestion, ::goToQuestionById, ::showToast, questionnaireMeta.id)
is QuestionItem.GlassScaleQuestion -> HandlerGlassScaleQuestion(context, answers, points, languageID, ::goToNextQuestion, ::goToPreviousQuestion, ::showToast, questionnaireMeta.id)
is QuestionItem.ClientNotSigned -> HandlerClientNotSigned(answers, languageID, ::goToNextQuestion, ::goToPreviousQuestion, ::showToast)
is QuestionItem.StringSpinnerQuestion -> HandlerStringSpinner(context, answers, languageID, ::goToNextQuestion, ::goToPreviousQuestion, ::showToast)
is QuestionItem.MultiCheckboxQuestion -> HandlerMultiCheckboxQuestion(context, answers, points, languageID, ::goToNextQuestion, ::goToPreviousQuestion, ::showToast)
is QuestionItem.StringSpinnerQuestion -> HandlerStringSpinner(context, answers, languageID, ::goToNextQuestion, ::goToPreviousQuestion, ::showToast, questionnaireMeta.id)
is QuestionItem.MultiCheckboxQuestion -> HandlerMultiCheckboxQuestion(context, answers, points, languageID, ::goToNextQuestion, ::goToPreviousQuestion, ::showToast, questionnaireMeta.id)
is QuestionItem.LastPage -> HandlerLastPage(
answers, languageID, ::goToNextQuestion, ::goToPreviousQuestion
) { CoroutineScope(Dispatchers.IO).launch { saveAnswersToDatabase(answers, questionnaireMeta.id) } }
@ -177,11 +177,16 @@ abstract class QuestionnaireBase<T> {
val clientCode = answers["client_code"] as? String ?: return
saveClientAndQuestionnaire(db, clientCode, questionnaireId)
// 🔥 Vor dem Speichern alte Antworten löschen
db.answerDao().deleteAnswersForClientAndQuestionnaire(clientCode, questionnaireId)
saveQuestions(db, answers, questionnaireId)
saveAnswers(db, answers, questionnaireId, clientCode)
markQuestionnaireCompleted(db, questionnaireId, clientCode)
}
private suspend fun saveClientAndQuestionnaire(db: AppDatabase, clientCode: String, questionnaireId: String) {
db.clientDao().insertClient(Client(clientCode))
db.questionnaireDao().insertQuestionnaire(Questionnaire(id = questionnaireId))

View File

@ -0,0 +1,221 @@
package com.dano.test1
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.pdf.PdfDocument
import android.util.Log
import android.widget.Button
import android.widget.EditText
import android.widget.Toast
import kotlinx.coroutines.*
class SaveButtonHandler(
private val activity: MainActivity,
private val saveButton: Button,
private val editText: EditText,
private val languageIDProvider: () -> String
) {
fun setup() {
saveButton.text = LanguageManager.getText(languageIDProvider(), "save")
saveButton.setOnClickListener { handleSaveClick() }
}
private fun handleSaveClick() {
val clientCode = editText.text.toString().trim()
if (clientCode.isBlank()) {
val message = LanguageManager.getText(languageIDProvider(), "please_client_code")
Toast.makeText(activity, message, Toast.LENGTH_SHORT).show()
return
}
GlobalValues.LAST_CLIENT_CODE = clientCode
showCompletedQuestionnaires(clientCode)
}
private fun showCompletedQuestionnaires(clientCode: String) {
CoroutineScope(Dispatchers.IO).launch {
val actualClientCode = clientCode.removeSuffix("_database")
val completedEntries = MyApp.database.completedQuestionnaireDao().getAllForClient(actualClientCode)
Log.d("PDF_DEBUG", "Completed entries for client $actualClientCode:")
for (entry in completedEntries) {
Log.d("PDF_DEBUG", "Questionnaire ID: ${entry.questionnaireId}, Done: ${entry.isDone}, Points: ${entry.sumPoints}")
}
val pdfDocument = PdfDocument()
val pageWidth = 595
val pageHeight = 842
val paint = Paint().apply { textSize = 12f }
val csvBuilder = StringBuilder()
csvBuilder.appendLine("ClientCode,QuestionnaireID,IsDone,Points,Question,Answer")
for ((index, entry) in completedEntries.withIndex()) {
val pageInfo = PdfDocument.PageInfo.Builder(pageWidth, pageHeight, index + 1).create()
var page = pdfDocument.startPage(pageInfo)
var canvas = page.canvas
var yPosition = 40f
canvas.drawText("Client Code: $actualClientCode", 20f, yPosition, paint)
yPosition += 20f
canvas.drawText("Questionnaire: ${entry.questionnaireId}", 20f, yPosition, paint)
yPosition += 20f
canvas.drawText("Status: ${entry.isDone}", 20f, yPosition, paint)
yPosition += 20f
canvas.drawText("Points: ${entry.sumPoints ?: "N/A"}", 20f, yPosition, paint)
yPosition += 30f
val answers = MyApp.database.answerDao().getAnswersForClientAndQuestionnaire(actualClientCode, entry.questionnaireId)
for (answer in answers) {
val questionKey = answer.questionId.substringAfter("-")
val questionText = LanguageManager.getText("ENGLISH", questionKey)
val rawAnswerText = LanguageManager.getText("ENGLISH", answer.answerValue)
val answerText = rawAnswerText.trim().removePrefix("[").removeSuffix("]")
yPosition = drawMultilineText(canvas, "Question: $questionText", 20f, yPosition, paint, pageWidth - 40, isBold = true)
yPosition += 8f
yPosition = drawMultilineText(canvas, "Answer: $answerText", 20f, yPosition, paint, pageWidth - 40)
yPosition += 20f
paint.strokeWidth = 0.5f
canvas.drawLine(20f, yPosition - 30f, pageWidth - 20f, yPosition - 30f, paint)
paint.strokeWidth = 0f
val sanitizedQuestion = questionText.replace(",", " ").replace("\n", " ")
val sanitizedAnswer = answerText.replace(",", " ").replace("\n", " ")
csvBuilder.appendLine("${actualClientCode},${entry.questionnaireId},${entry.isDone},${entry.sumPoints ?: ""},\"$sanitizedQuestion\",\"$sanitizedAnswer\"")
if (yPosition > pageHeight - 60) {
pdfDocument.finishPage(page)
val newPageInfo = PdfDocument.PageInfo.Builder(pageWidth, pageHeight, pdfDocument.pages.size + 1).create()
page = pdfDocument.startPage(newPageInfo)
canvas = page.canvas
yPosition = 40f
}
}
pdfDocument.finishPage(page)
}
Log.d("CSV_OUTPUT", "Generated CSV:\n${csvBuilder}")
val pdfFileName = "DatabaseOutput_${actualClientCode}.pdf"
val csvFileName = "DatabaseOutput_${actualClientCode}.csv"
val resolver = activity.contentResolver
val deleteIfExists: (String) -> Unit = { name ->
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
)
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
)
resolver.delete(deleteUri, null, null)
}
}
}
deleteIfExists(pdfFileName)
deleteIfExists(csvFileName)
try {
val pdfUri = resolver.insert(
android.provider.MediaStore.Downloads.EXTERNAL_CONTENT_URI,
android.content.ContentValues().apply {
put(android.provider.MediaStore.MediaColumns.DISPLAY_NAME, pdfFileName)
put(android.provider.MediaStore.MediaColumns.MIME_TYPE, "application/pdf")
put(android.provider.MediaStore.MediaColumns.RELATIVE_PATH, "Download/")
}
)
val csvUri = resolver.insert(
android.provider.MediaStore.Downloads.EXTERNAL_CONTENT_URI,
android.content.ContentValues().apply {
put(android.provider.MediaStore.MediaColumns.DISPLAY_NAME, csvFileName)
put(android.provider.MediaStore.MediaColumns.MIME_TYPE, "text/csv")
put(android.provider.MediaStore.MediaColumns.RELATIVE_PATH, "Download/")
}
)
pdfUri?.let {
resolver.openOutputStream(it)?.use { out -> pdfDocument.writeTo(out) }
pdfDocument.close()
}
csvUri?.let {
resolver.openOutputStream(it)?.use { out ->
out.write(csvBuilder.toString().toByteArray(Charsets.UTF_8))
}
}
withContext(Dispatchers.Main) {
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 {
setDataAndType(it, "application/pdf")
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION or android.content.Intent.FLAG_ACTIVITY_NO_HISTORY)
}
try {
activity.startActivity(intent)
} catch (e: android.content.ActivityNotFoundException) {
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) {
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()
}
}
}
}
private fun drawMultilineText(
canvas: Canvas,
text: String,
x: Float,
yStart: Float,
paint: Paint,
maxWidth: Int,
isBold: Boolean = false
): Float {
paint.isFakeBoldText = isBold
val words = text.split(" ")
var line = ""
var y = yStart
for (word in words) {
val testLine = if (line.isEmpty()) word else "$line $word"
val lineWidth = paint.measureText(testLine)
if (lineWidth > maxWidth) {
canvas.drawText(line, x, y, paint)
y += paint.textSize * 1.4f
line = word
} else {
line = testLine
}
}
if (line.isNotEmpty()) {
canvas.drawText(line, x, y, paint)
y += paint.textSize * 1.4f
}
paint.isFakeBoldText = false
return y
}
}

View File

@ -0,0 +1,46 @@
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"
fun save(context: Context, token: String, username: String) {
val now = System.currentTimeMillis()
context.getSharedPreferences(PREF, Context.MODE_PRIVATE)
.edit()
.putString(KEY_TOKEN, token) // API-/Session-Token
.putString(KEY_USER, username) // angemeldeter Benutzername
.putLong(KEY_LOGIN_TS, now) // Zeitpunkt des Logins
.apply()
}
fun getToken(context: Context): String? =
context.getSharedPreferences(PREF, Context.MODE_PRIVATE).getString(KEY_TOKEN, null)
fun getUsername(context: Context): String? =
context.getSharedPreferences(PREF, Context.MODE_PRIVATE).getString(KEY_USER, null)
fun getLoginTimestamp(context: Context): Long =
context.getSharedPreferences(PREF, Context.MODE_PRIVATE).getLong(KEY_LOGIN_TS, 0L)
fun clear(context: Context) {
context.getSharedPreferences(PREF, Context.MODE_PRIVATE).edit().clear().apply()
}
}

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#FFFFFFFF"/>
</selector>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:alpha="0.2" android:color="#FFFFFF"/>
</selector>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:alpha="0.85" android:color="#334B5A" android:state_pressed="true"/>
<item android:color="#385266"/>
</selector>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#FFFFFFFF"/>
</selector>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:alpha="0.2" android:color="#FFFFFF"/>
</selector>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:alpha="0.85" android:color="#2F855A" android:state_pressed="true"/>
<item android:color="#339966"/>
</selector>

View File

@ -0,0 +1,5 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#F2B544"/>
<corners android:radius="18dp"/>
<padding android:left="8dp" android:top="6dp" android:right="8dp" android:bottom="6dp"/>
</shape>

View File

@ -0,0 +1,5 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#43B581"/>
<corners android:radius="18dp"/>
<padding android:left="8dp" android:top="6dp" android:right="8dp" android:bottom="6dp"/>
</shape>

View File

@ -0,0 +1,5 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#E3E5EF"/>
<corners android:radius="18dp"/>
<padding android:left="8dp" android:top="6dp" android:right="8dp" android:bottom="6dp"/>
</shape>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/brand_surface"/>
<stroke android:width="1dp" android:color="@color/brand_stroke"/>
<corners android:radius="12dp"/>
<padding android:left="8dp" android:top="6dp" android:right="8dp" android:bottom="6dp"/>
</shape>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#F4F4F6"/>
<stroke android:width="1dp" android:color="#C7C7D0"/>
<corners android:radius="8dp"/>
<padding android:left="12dp" android:top="12dp" android:right="12dp" android:bottom="12dp"/>
</shape>

View File

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp" android:height="32dp"
android:viewportWidth="24" android:viewportHeight="24">
<path
android:fillColor="@android:color/transparent"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="2.8"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M15,6 L9,12 L15,18" />
</vector>

View File

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp" android:height="32dp"
android:viewportWidth="24" android:viewportHeight="24">
<path
android:fillColor="@android:color/transparent"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="2.8"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M9,6 L15,12 L9,18" />
</vector>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp" android:height="16dp"
android:viewportWidth="24" android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,6a6,6 0 1,0 0,12a6,6 0 1,0 0,-12z"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp" android:height="48dp"
android:viewportWidth="64" android:viewportHeight="64">
<!-- Glas-Outline -->
<path android:fillColor="#00000000" android:strokeColor="#424242" android:strokeWidth="3"
android:pathData="M20,6h24a6,6 0 0 1 6,6v40a6,6 0 0 1 -6,6H20a6,6 0 0 1 -6,-6V12a6,6 0 0 1 6,-6z"/>
<!-- Füllung 0% -->
<path android:fillColor="#9FA8DA" android:pathData="M22,52H42V52H22z"/>
</vector>

View File

@ -0,0 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp" android:height="48dp"
android:viewportWidth="64" android:viewportHeight="64">
<path android:fillColor="#00000000" android:strokeColor="#424242" android:strokeWidth="3"
android:pathData="M20,6h24a6,6 0 0 1 6,6v40a6,6 0 0 1 -6,6H20a6,6 0 0 1 -6,-6V12a6,6 0 0 1 6,-6z"/>
<path android:fillColor="#9FA8DA" android:pathData="M22,46H42V52H22z"/>
</vector>

View File

@ -0,0 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp" android:height="48dp"
android:viewportWidth="64" android:viewportHeight="64">
<path android:fillColor="#00000000" android:strokeColor="#424242" android:strokeWidth="3"
android:pathData="M20,6h24a6,6 0 0 1 6,6v40a6,6 0 0 1 -6,6H20a6,6 0 0 1 -6,-6V12a6,6 0 0 1 6,-6z"/>
<path android:fillColor="#9FA8DA" android:pathData="M22,38H42V52H22z"/>
</vector>

View File

@ -0,0 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp" android:height="48dp"
android:viewportWidth="64" android:viewportHeight="64">
<path android:fillColor="#00000000" android:strokeColor="#424242" android:strokeWidth="3"
android:pathData="M20,6h24a6,6 0 0 1 6,6v40a6,6 0 0 1 -6,6H20a6,6 0 0 1 -6,-6V12a6,6 0 0 1 6,-6z"/>
<path android:fillColor="#9FA8DA" android:pathData="M22,30H42V52H22z"/>
</vector>

View File

@ -0,0 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp" android:height="48dp"
android:viewportWidth="64" android:viewportHeight="64">
<path android:fillColor="#00000000" android:strokeColor="#424242" android:strokeWidth="3"
android:pathData="M20,6h24a6,6 0 0 1 6,6v40a6,6 0 0 1 -6,6H20a6,6 0 0 1 -6,-6V12a6,6 0 0 1 6,-6z"/>
<path android:fillColor="#9FA8DA" android:pathData="M22,14H42V52H22z"/>
</vector>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path
android:fillColor="#8C79F2"
android:pathData="M12,17a2,2 0,1 0,0 -4 2,2 0,0 0,0 4zM18,8h-1V6a5,5 0,0 0,-10 0v2H6a2,2 0,0 0,-2 2v8a2,2 0,0 0,2 2h12a2,2 0,0 0,2 -2v-8a2,2 0,0 0,-2 -2zM8,6a4,4 0,0 1,8 0v2H8z"/>
</vector>

View File

@ -5,90 +5,121 @@
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">
<Button
<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"
android:tag="previous"
android:layout_width="130dp"
android:layout_height="42dp"
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"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_left_tint"
app:rippleColor="@color/btn_nav_left_ripple"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.056"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.976" />
app:layout_constraintStart_toStartOf="parent" />
<Button
<com.google.android.material.button.MaterialButton
android:id="@+id/Qnext"
android:tag="next"
android:layout_width="130dp"
android:layout_height="42dp"
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"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_right_tint"
app:rippleColor="@color/btn_nav_right_ripple"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.943"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.976" />
<EditText
android:id="@+id/client_code"
android:layout_width="300dp"
android:layout_height="wrap_content"
android:background="@android:drawable/edit_text"
android:ems="10"
android:inputType="text"
android:tag="client_code"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.495"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.368" />
<EditText
android:id="@+id/coach_code"
android:layout_width="300dp"
android:layout_height="wrap_content"
android:background="@android:drawable/edit_text"
android:ems="10"
android:inputType="text"
android:tag="coach_code"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.495"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.499" />
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/textView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textAlignment="center"
android:layout_marginStart="25dp"
android:layout_marginEnd="25dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
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_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.104" />
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:textAlignment="center"
android:gravity="center"
android:textStyle="bold"
android:layout_marginStart="25dp"
android:layout_marginEnd="25dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
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_toTopOf="parent"
app:layout_constraintVertical_bias="0.188" />
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
<EditText
android:id="@+id/client_code"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintWidth_percent="0.7"
app:layout_constraintHeight_percent="0.08"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/question"
android:layout_marginTop="16dp"
android:background="@android:drawable/edit_text"
android:ems="10"
android:inputType="text"
android:tag="client_code"
android:gravity="center_vertical"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="12sp"
android:autoSizeMaxTextSize="36sp"
android:autoSizeStepGranularity="2sp" />
<EditText
android:id="@+id/coach_code"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintWidth_percent="0.7"
app:layout_constraintHeight_percent="0.08"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/client_code"
android:layout_marginTop="16dp"
android:background="@android:drawable/edit_text"
android:ems="10"
android:inputType="text"
android:tag="coach_code"
android:gravity="center_vertical"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="12sp"
android:autoSizeMaxTextSize="36sp"
android:autoSizeStepGranularity="2sp" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -9,29 +9,42 @@
tools:layout_editor_absoluteX="11dp"
tools:layout_editor_absoluteY="107dp">
<Button
<com.google.android.material.button.MaterialButton
android:id="@+id/Qprev"
android:tag="previous"
android:layout_width="130dp"
android:layout_height="42dp"
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"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_left_tint"
app:rippleColor="@color/btn_nav_left_ripple"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.056"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.976" />
app:layout_constraintStart_toStartOf="parent" />
<Button
<!-- Weiter -->
<com.google.android.material.button.MaterialButton
android:id="@+id/Qnext"
android:tag="next"
android:layout_width="130dp"
android:layout_height="42dp"
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"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_right_tint"
app:rippleColor="@color/btn_nav_right_ripple"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.943"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.976" />
app:layout_constraintEnd_toEndOf="parent" />
<EditText
@ -42,6 +55,7 @@
android:ems="10"
android:inputType="text"
android:tag="coach_code"
android:textSize="30sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.495"
@ -53,6 +67,7 @@
android:id="@+id/textView2"
android:layout_width="329dp"
android:layout_height="55dp"
android:textSize="40sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
@ -63,6 +78,7 @@
android:id="@+id/question"
android:layout_width="329dp"
android:layout_height="55dp"
android:textSize="40sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
@ -73,6 +89,7 @@
android:id="@+id/textView1"
android:layout_width="329dp"
android:layout_height="55dp"
android:textSize="40sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"

View File

@ -0,0 +1,87 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/rootClientOverview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/titleClientOverview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Client Fragebögen"
android:textStyle="bold"
android:textSize="20sp"
android:paddingBottom="8dp" />
<!-- OBERTEIL: Alle Fragebögen vollständig (kein Weight, keine vertikale ScrollView) -->
<HorizontalScrollView
android:id="@+id/qsScroll"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fillViewport="true"
android:scrollbars="horizontal">
<TableLayout
android:id="@+id/tableQuestionnaires"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:stretchColumns="1" />
</HorizontalScrollView>
<TextView
android:id="@+id/emptyViewClient"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Keine Daten."
android:visibility="gone"
android:paddingTop="8dp" />
<ProgressBar
android:id="@+id/progressBarClient"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:paddingTop="8dp"
android:paddingBottom="8dp" />
<!-- HEADER + Tabelle 2: Scrollbar bekommt den restlichen Platz -->
<TextView
android:id="@+id/headerLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="header"
android:textStyle="bold"
android:textSize="18sp"
android:paddingTop="12dp"
android:paddingBottom="6dp" />
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:fillViewport="true"
android:scrollbars="horizontal">
<ScrollView
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:fillViewport="true"
android:scrollbars="vertical">
<TableLayout
android:id="@+id/tableOrdered"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:stretchColumns="1,2" />
</ScrollView>
</HorizontalScrollView>
<Button
android:id="@+id/backButtonClient"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>

View File

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/rootDatabase"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Datenbank Clients"
android:textStyle="bold"
android:textSize="20sp"
android:paddingBottom="8dp" />
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:fillViewport="true"
android:scrollbars="horizontal">
<ScrollView
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:fillViewport="true"
android:scrollbars="vertical">
<TableLayout
android:id="@+id/tableClients"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:stretchColumns="1" />
</ScrollView>
</HorizontalScrollView>
<TextView
android:id="@+id/emptyView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Keine Clients vorhanden."
android:visibility="gone"
android:paddingTop="8dp" />
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:paddingTop="8dp"
android:paddingBottom="8dp" />
<!-- NEU: Download-Button -->
<Button
android:id="@+id/btnDownloadHeader"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Download Header" />
<Button
android:id="@+id/backButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Zurück" />
</LinearLayout>

View File

@ -1,128 +1,153 @@
<?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">
<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="wrap_content"
android:textAlignment="center"
android:layout_marginStart="25dp"
android:layout_marginEnd="25dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
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_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.104" />
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:textAlignment="center"
android:gravity="center"
android:textStyle="bold"
android:layout_marginStart="25dp"
android:layout_marginEnd="25dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
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_toTopOf="parent"
app:layout_constraintVertical_bias="0.188" />
<Spinner
android:id="@+id/spinner_value_month"
android:layout_width="142dp"
android:layout_height="35dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.453"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.341" />
<TextView
android:id="@+id/date_spinner_year"
android:layout_width="110dp"
android:layout_height="22dp"
android:tag="year"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.877"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.282" />
app:layout_constraintWidth_percent="0.9" />
<TextView
android:id="@+id/date_spinner_day"
android:layout_width="90dp"
android:layout_height="22dp"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:tag="day"
app:layout_constraintBottom_toBottomOf="parent"
android:gravity="start|center_vertical"
android:textAlignment="viewStart"
android:paddingStart="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.099"
app:layout_constraintHorizontal_bias="0.07"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.282" />
<Spinner
android:id="@+id/spinner_value_day"
android:layout_width="90dp"
android:layout_height="35dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.099"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.341" />
app:layout_constraintTop_toBottomOf="@id/question"
app:layout_constraintWidth_percent="0.22" />
<TextView
android:id="@+id/date_spinner_month"
android:layout_width="142dp"
android:layout_height="22dp"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:tag="month"
app:layout_constraintBottom_toBottomOf="parent"
android:gravity="start|center_vertical"
android:textAlignment="viewStart"
android:paddingStart="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.453"
app:layout_constraintHorizontal_bias="0.459"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.282" />
app:layout_constraintTop_toBottomOf="@id/question"
app:layout_constraintWidth_percent="0.40" />
<TextView
android:id="@+id/date_spinner_year"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="start|center_vertical"
android:paddingStart="8dp"
android:tag="year"
android:textAlignment="viewStart"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/question"
app:layout_constraintWidth_percent="0.32" />
<Spinner
android:id="@+id/spinner_value_day"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.07"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/date_spinner_day"
app:layout_constraintWidth_percent="0.22" />
<Spinner
android:id="@+id/spinner_value_month"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.459"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/date_spinner_month"
app:layout_constraintWidth_percent="0.40" />
<Spinner
android:id="@+id/spinner_value_year"
android:layout_width="110dp"
android:layout_height="35dp"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.877"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.341" />
app:layout_constraintTop_toBottomOf="@id/date_spinner_year"
app:layout_constraintWidth_percent="0.32" />
<Button
android:id="@+id/Qnext"
android:layout_width="130dp"
android:layout_height="42dp"
android:tag="next"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.943"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.976" />
<Button
<com.google.android.material.button.MaterialButton
android:id="@+id/Qprev"
android:layout_width="130dp"
android:layout_height="42dp"
android:tag="previous"
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"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_left_tint"
app:rippleColor="@color/btn_nav_left_ripple"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.056"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.976" />
app:layout_constraintStart_toStartOf="parent" />
<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"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_right_tint"
app:rippleColor="@color/btn_nav_right_ripple"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,17 +1,69 @@
<?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">
<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="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" />
<LinearLayout
android:id="@+id/glass_header"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="8dp"
android:weightSum="9"
app:layout_constraintTop_toBottomOf="@id/question"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="1" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="466dp"
android:id="@+id/glassScroll"
android:layout_width="0dp"
android:layout_height="0dp"
android:fillViewport="true"
app:layout_constraintTop_toBottomOf="@+id/question"
android:clipToPadding="false"
android:paddingTop="4dp"
android:paddingBottom="8dp"
app:layout_constraintTop_toBottomOf="@id/glass_header"
app:layout_constraintBottom_toTopOf="@+id/Qprev"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="1">
<TableLayout
android:id="@+id/glass_table"
@ -23,49 +75,36 @@
android:textStyle="bold" />
</ScrollView>
<Button
<com.google.android.material.button.MaterialButton
android:id="@+id/Qprev"
android:layout_width="130dp"
android:layout_height="wrap_content"
android:tag="previous"
android:layout_width="@dimen/nav_btn_size"
android:layout_height="@dimen/nav_btn_size"
android:layout_marginStart="20dp"
android:layout_marginBottom="16dp"
app:icon="@drawable/ic_chevron_left"
app:iconTint="@color/btn_nav_left_icon_tint"
app:iconSize="@dimen/nav_icon_size"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_left_tint"
app:rippleColor="@color/btn_nav_left_ripple"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
android:layout_margin="16dp" />
app:layout_constraintStart_toStartOf="parent" />
<Button
<com.google.android.material.button.MaterialButton
android:id="@+id/Qnext"
android:layout_width="130dp"
android:layout_height="wrap_content"
android:tag="next"
android:layout_width="@dimen/nav_btn_size"
android:layout_height="@dimen/nav_btn_size"
android:layout_marginEnd="20dp"
android:layout_marginBottom="16dp"
app:icon="@drawable/ic_chevron_right"
app:iconTint="@color/btn_nav_right_icon_tint"
app:iconSize="@dimen/nav_icon_size"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_right_tint"
app:rippleColor="@color/btn_nav_right_ripple"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_margin="16dp" />
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/textView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textAlignment="center"
android:layout_marginStart="25dp"
android:layout_marginEnd="25dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.104" />
<TextView
android:id="@+id/question"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textAlignment="center"
android:textStyle="bold"
android:layout_marginStart="25dp"
android:layout_marginEnd="25dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.188" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -5,57 +5,77 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/question"
android:layout_width="389dp"
android:layout_height="115dp"
android:tag="finish_data_entry"
android:textAlignment="viewStart"
android:textSize="24sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.376" />
<Button
<!-- Zurück (links unten) -->
<com.google.android.material.button.MaterialButton
android:id="@+id/Qprev"
android:layout_width="130dp"
android:layout_height="42dp"
android:tag="previous"
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"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_left_tint"
app:rippleColor="@color/btn_nav_left_ripple"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.056"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.976" />
app:layout_constraintStart_toStartOf="parent" />
<Button
<!-- Fertig/Speichern (rechts unten) gleiche Farbe wie Qprev -->
<com.google.android.material.button.MaterialButton
android:id="@+id/Qfinish"
android:layout_width="231dp"
android:layout_height="42dp"
android:tag="finish"
android:layout_width="@dimen/finish_btn_width"
android:layout_height="@dimen/nav_btn_size"
android:layout_marginEnd="20dp"
android:layout_marginBottom="16dp"
android:textAllCaps="false"
android:minWidth="0dp"
android:insetLeft="0dp"
android:insetRight="0dp"
android:paddingStart="24dp"
android:paddingEnd="24dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_right_tint"
app:rippleColor="@color/btn_nav_right_ripple"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.944"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.976" />
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/textView"
android:layout_width="389dp"
android:layout_height="115dp"
android:tag="finish_data_entry"
android:layout_width="0dp"
android:layout_height="0dp"
android:gravity="start|center_vertical"
android:textAlignment="viewStart"
android:textSize="24sp"
android:paddingStart="8dp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_percent="0.25"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.125" />
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="start|center_vertical"
android:textAlignment="viewStart"
android:paddingStart="8dp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_percent="0.25"
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" />
<!-- ProgressBar for loading animation -->
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyleLarge"

View File

@ -5,71 +5,91 @@
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">
<Button
<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"
android:tag="previous"
android:layout_width="130dp"
android:layout_height="42dp"
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"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_left_tint"
app:rippleColor="@color/btn_nav_left_ripple"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.056"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.976" />
app:layout_constraintStart_toStartOf="parent" />
<Button
<com.google.android.material.button.MaterialButton
android:id="@+id/Qnext"
android:tag="next"
android:layout_width="130dp"
android:layout_height="42dp"
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"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_right_tint"
app:rippleColor="@color/btn_nav_right_ripple"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.943"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.976" />
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/textView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textAlignment="center"
android:layout_marginStart="25dp"
android:layout_marginEnd="25dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
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_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.104" />
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:textAlignment="center"
android:gravity="center"
android:textStyle="bold"
android:layout_marginStart="25dp"
android:layout_marginEnd="25dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
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_toTopOf="parent"
app:layout_constraintVertical_bias="0.188" />
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
<ScrollView
android:id="@+id/scrollView"
android:layout_width="360dp"
android:layout_height="417dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.489"
android:layout_width="0dp"
android:layout_height="0dp"
android:fillViewport="true"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.658">
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/question"
app:layout_constraintBottom_toTopOf="@id/Qnext"
app:layout_constraintWidth_percent="0.9">
<LinearLayout
android:id="@+id/CheckboxContainer"
@ -78,4 +98,5 @@
android:orientation="vertical"
android:padding="16dp" />
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -3,100 +3,307 @@
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:padding="16dp">
<EditText
android:id="@+id/editText"
android:layout_width="216dp"
android:layout_height="53dp"
android:background="@android:drawable/edit_text"
android:ems="10"
android:inputType="text"
android:tag="client_code"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.897"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.091" />
<Spinner
android:id="@+id/string_spinner1"
android:layout_width="143dp"
android:layout_height="43dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.07"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.091" />
<!-- Container für dynamische Buttons -->
<LinearLayout
android:id="@+id/buttonContainer"
<com.google.android.material.card.MaterialCardView
android:id="@+id/headerCard"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="28dp"
android:orientation="vertical"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
android:layout_marginTop="32dp"
android:paddingStart="24dp"
android:paddingEnd="24dp"
android:paddingTop="20dp"
android:paddingBottom="20dp"
app:cardBackgroundColor="@color/brand_surface"
app:cardCornerRadius="16dp"
app:strokeColor="@color/brand_stroke"
app:strokeWidth="1dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/textView" />
app:layout_constraintEnd_toEndOf="parent">
<Button
android:id="@+id/loadButton"
android:layout_width="72dp"
android:layout_height="52dp"
app:layout_constraintEnd_toEndOf="@id/editText"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="@id/editText"
app:layout_constraintTop_toBottomOf="@id/editText" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<Button
android:id="@+id/uploadButton"
android:layout_width="200dp"
android:layout_height="42dp"
android:layout_marginTop="52dp"
app:layout_constraintEnd_toEndOf="@id/editText"
app:layout_constraintStart_toStartOf="@id/editText"
app:layout_constraintTop_toBottomOf="@id/editText" />
<!-- Language (eigene Zeile) -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="6dp">
<Button
android:id="@+id/downloadButton"
android:layout_width="200dp"
android:layout_height="42dp"
android:layout_marginTop="92dp"
app:layout_constraintEnd_toEndOf="@id/editText"
app:layout_constraintStart_toStartOf="@id/editText"
app:layout_constraintTop_toBottomOf="@id/editText" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="12dp"
android:paddingBottom="6dp"
android:text="Language"
android:textColor="@color/brand_text_dark"
android:textSize="12sp" />
<Button
android:id="@+id/editButton"
android:layout_width="72dp"
android:layout_height="52dp"
app:layout_constraintEnd_toEndOf="@id/editText"
app:layout_constraintStart_toStartOf="@id/editText"
app:layout_constraintTop_toBottomOf="@id/editText" />
<Spinner
android:id="@+id/string_spinner1"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:background="@drawable/bg_field_filled"
android:paddingStart="12dp"
android:paddingEnd="12dp" />
</LinearLayout>
<Button
android:id="@+id/saveButton"
android:layout_width="72dp"
android:layout_height="52dp"
app:layout_constraintEnd_toEndOf="@id/editText"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="@id/editText"
app:layout_constraintTop_toBottomOf="@id/editText" />
<!-- Client Code | Coach Code (gemeinsame Zeile) -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:baselineAligned="false"
android:paddingBottom="6dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:layout_marginEnd="8dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="12dp"
android:paddingBottom="6dp"
android:text="Client Code"
android:textColor="@color/brand_text_dark"
android:textSize="12sp" />
<EditText
android:id="@+id/editText"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:background="@drawable/bg_field_filled"
android:ems="10"
android:inputType="text"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:tag="client_code"/>
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:layout_marginStart="8dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="12dp"
android:paddingBottom="6dp"
android:text="Coach Code"
android:textColor="@color/brand_text_dark"
android:textSize="12sp" />
<EditText
android:id="@+id/coachEditText"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:background="@drawable/bg_field_locked"
android:ems="10"
android:inputType="none"
android:focusable="false"
android:focusableInTouchMode="false"
android:clickable="false"
android:cursorVisible="false"
android:longClickable="false"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:drawableEnd="@drawable/ic_lock_24"
android:drawablePadding="8dp"
android:textColor="@color/brand_text_dark"
android:textStyle="bold"
android:tag="coach_code"/>
</LinearLayout>
</LinearLayout>
<!-- Session/Online -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingTop="6dp">
<TextView
android:id="@+id/statusSession"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:text="Session: —"
android:textColor="@color/brand_text_dark"
android:textSize="13sp" />
<TextView
android:id="@+id/statusOnline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Offline"
android:textStyle="bold"
android:layout_marginEnd="8dp"
android:textSize="13sp"
android:textColor="#C62828"
android:paddingStart="12dp"/>
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<LinearLayout
android:id="@+id/primaryActionsRow"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
android:baselineAligned="false"
android:layout_marginTop="20dp"
app:layout_constraintTop_toBottomOf="@id/headerCard"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<com.google.android.material.button.MaterialButton
android:id="@+id/loadButton"
android:layout_width="0dp"
android:layout_height="@dimen/pill_height"
android:layout_weight="1"
android:layout_marginEnd="12dp"
android:textAllCaps="false"
android:textColor="@android:color/white"
app:icon="@drawable/ic_dot_16"
app:iconTint="@android:color/white"
app:iconPadding="8dp"
app:iconGravity="textStart"
app:cornerRadius="@dimen/pill_radius"
app:backgroundTint="@color/brand_purple"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/editButton"
android:layout_width="0dp"
android:layout_height="@dimen/pill_height"
android:layout_weight="1"
android:layout_marginEnd="12dp"
android:textAllCaps="false"
android:textColor="@android:color/white"
app:icon="@drawable/ic_dot_16"
app:iconTint="@android:color/white"
app:iconPadding="8dp"
app:iconGravity="textStart"
app:cornerRadius="@dimen/pill_radius"
app:backgroundTint="@color/brand_purple"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/saveButton"
android:layout_width="0dp"
android:layout_height="@dimen/pill_height"
android:layout_weight="1"
android:textAllCaps="false"
android:textColor="@android:color/white"
app:icon="@drawable/ic_dot_16"
app:iconTint="@android:color/white"
app:iconPadding="8dp"
app:iconGravity="textStart"
app:cornerRadius="@dimen/pill_radius"
app:backgroundTint="@color/brand_purple"/>
</LinearLayout>
<LinearLayout
android:id="@+id/secondaryActionsColumn"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@id/primaryActionsRow"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<com.google.android.material.button.MaterialButton
android:id="@+id/uploadButton"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="@dimen/pill_height"
android:layout_marginBottom="12dp"
android:textAllCaps="false"
android:textColor="@color/brand_purple"
app:cornerRadius="@dimen/pill_radius"
app:strokeColor="@color/brand_purple"
app:strokeWidth="@dimen/pill_stroke"
app:backgroundTint="@android:color/transparent"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/downloadButton"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="@dimen/pill_height"
android:layout_marginBottom="12dp"
android:textAllCaps="false"
android:textColor="@color/brand_purple"
app:cornerRadius="@dimen/pill_radius"
app:strokeColor="@color/brand_purple"
app:strokeWidth="@dimen/pill_stroke"
app:backgroundTint="@android:color/transparent"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/databaseButton"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="@dimen/pill_height"
android:textAllCaps="false"
android:textColor="@color/brand_purple"
app:cornerRadius="@dimen/pill_radius"
app:strokeColor="@color/brand_purple"
app:strokeWidth="@dimen/pill_stroke"
app:backgroundTint="@android:color/transparent"/>
</LinearLayout>
<TextView
android:id="@+id/textView"
android:layout_width="348dp"
android:layout_height="162dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.492"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:textStyle="bold"
android:textSize="22sp"
android:textColor="@color/brand_text_dark"
app:layout_constraintTop_toBottomOf="@id/secondaryActionsColumn"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.307" />
app:layout_constraintEnd_toEndOf="parent"/>
<androidx.core.widget.NestedScrollView
android:id="@+id/questionnaireScroll"
android:layout_width="0dp"
android:layout_height="0dp"
android:fillViewport="true"
android:clipToPadding="false"
android:paddingBottom="16dp"
app:layout_constraintTop_toBottomOf="@id/textView"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<LinearLayout
android:id="@+id/buttonContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="8dp"/>
</androidx.core.widget.NestedScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/rootQuestionnaireDetail"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/titleQuestionnaireDetail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Fragen und Antworten"
android:textStyle="bold"
android:textSize="20sp"
android:paddingBottom="8dp" />
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:fillViewport="true"
android:scrollbars="horizontal">
<ScrollView
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:fillViewport="true"
android:scrollbars="vertical">
<TableLayout
android:id="@+id/tableQA"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:stretchColumns="1,2" />
</ScrollView>
</HorizontalScrollView>
<TextView
android:id="@+id/emptyViewQA"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Keine Fragen vorhanden."
android:visibility="gone"
android:paddingTop="8dp" />
<ProgressBar
android:id="@+id/progressBarQA"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:paddingTop="8dp"
android:paddingBottom="8dp" />
<Button
android:id="@+id/backButtonQA"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Zurück" />
</LinearLayout>

View File

@ -5,70 +5,99 @@
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">
<Button
<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"
android:tag="previous"
android:layout_width="130dp"
android:layout_height="42dp"
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"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_left_tint"
app:rippleColor="@color/btn_nav_left_ripple"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.056"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.976" />
app:layout_constraintStart_toStartOf="parent" />
<Button
<com.google.android.material.button.MaterialButton
android:id="@+id/Qnext"
android:tag="next"
android:layout_width="130dp"
android:layout_height="42dp"
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"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_right_tint"
app:rippleColor="@color/btn_nav_right_ripple"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.943"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.976" />
<RadioGroup
android:id="@+id/RadioGroup"
android:layout_width="323dp"
android:layout_height="419dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.675" />
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/textView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textAlignment="center"
android:layout_marginStart="25dp"
android:layout_marginEnd="25dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
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_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.104" />
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:textAlignment="center"
android:gravity="center"
android:textStyle="bold"
android:layout_marginStart="25dp"
android:layout_marginEnd="25dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
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_toTopOf="parent"
app:layout_constraintVertical_bias="0.188" />
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
<ScrollView
android:id="@+id/radioScroll"
android:layout_width="0dp"
android:layout_height="0dp"
android:fillViewport="true"
android:clipToPadding="false"
android:paddingTop="8dp"
android:paddingBottom="8dp"
app:layout_constraintTop_toBottomOf="@id/question"
app:layout_constraintBottom_toTopOf="@id/Qnext"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.80">
<RadioGroup
android:id="@+id/RadioGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="8dp" />
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -5,68 +5,85 @@
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="250dp"
android:layout_height="35dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.498"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:layout_constraintWidth_percent="0.70"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.38" />
<Button
android:id="@+id/Qnext"
android:layout_width="130dp"
android:layout_height="42dp"
android:tag="next"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.943"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.976" />
app:layout_constraintTop_toBottomOf="@id/question" />
<Button
<com.google.android.material.button.MaterialButton
android:id="@+id/Qprev"
android:layout_width="130dp"
android:layout_height="42dp"
android:tag="previous"
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"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_left_tint"
app:rippleColor="@color/btn_nav_left_ripple"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.056"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.976" />
app:layout_constraintStart_toStartOf="parent" />
<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"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_right_tint"
app:rippleColor="@color/btn_nav_right_ripple"
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:textAlignment="center"
android:layout_marginStart="25dp"
android:layout_marginEnd="25dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
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_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.104" />
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:textAlignment="center"
android:gravity="center"
android:textStyle="bold"
android:layout_marginStart="25dp"
android:layout_marginEnd="25dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
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_toTopOf="parent"
app:layout_constraintVertical_bias="0.188" />
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -5,67 +5,87 @@
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="250dp"
android:layout_height="35dp"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.498"
app:layout_constraintHorizontal_bias="0.495"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.38" />
app:layout_constraintTop_toBottomOf="@id/question"
app:layout_constraintVertical_bias="0.027"
app:layout_constraintWidth_percent="0.70" />
<Button
android:id="@+id/Qnext"
android:layout_width="130dp"
android:layout_height="42dp"
android:tag="next"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.943"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.976" />
<Button
<com.google.android.material.button.MaterialButton
android:id="@+id/Qprev"
android:layout_width="130dp"
android:layout_height="42dp"
android:tag="previous"
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"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_left_tint"
app:rippleColor="@color/btn_nav_left_ripple"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.056"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.976" />
app:layout_constraintStart_toStartOf="parent" />
<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"
app:iconPadding="0dp"
app:cornerRadius="999dp"
app:backgroundTint="@color/btn_nav_right_tint"
app:rippleColor="@color/btn_nav_right_ripple"
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:textAlignment="center"
android:layout_marginStart="25dp"
android:layout_marginEnd="25dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
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_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.104" />
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:textAlignment="center"
android:gravity="center"
android:textStyle="bold"
android:layout_marginStart="25dp"
android:layout_marginEnd="25dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
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_toTopOf="parent"
app:layout_constraintVertical_bias="0.188" />
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.9" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -2,4 +2,8 @@
<resources>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="brand_purple">#6E56CF</color>
<color name="brand_text_dark">#2D2748</color>
<color name="brand_surface">#FFFFFFFF</color>
<color name="brand_stroke">#D8D1F0</color>
</resources>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="nav_btn_size">64dp</dimen>
<dimen name="nav_icon_size">28dp</dimen>
<dimen name="finish_btn_width">200dp</dimen>
<dimen name="pill_height">48dp</dimen>
<dimen name="pill_radius">28dp</dimen>
<dimen name="pill_stroke">2dp</dimen>
</resources>